diff --git a/Directory.Packages.props b/Directory.Packages.props index 84380e44..e4b09159 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,6 @@ - diff --git a/contrib/Makefile b/contrib/Makefile index 151045db..62e1e22d 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -1,4 +1,4 @@ -APP_PROJECT_NAMES=Org Blogs +APP_PROJECT_NAMES=Api Org Blogs SLNDIR=.. include $(SLNDIR)/.env @@ -7,6 +7,7 @@ include .env generated/: @mkdir -p $@ +generated/yavscApi.service: generated/yavscOrg.service: generated/yavscBlogs.service: @@ -33,11 +34,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env @echo Created service file: $@ -copy-services: copy-service-Org copy-service-Blogs +copy-services: copy-service-Org copy-service-Api copy-service-Blogs copy-service-Org: /etc/systemd/system/yavscOrg.service +copy-service-Api: /etc/systemd/system/yavscApi.service copy-service-Blogs: /etc/systemd/system/yavscBlogs.service -copy-binaries: build_publish_Org build_publish_Blogs stop-services +copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-services @for project in $(APP_PROJECT_NAMES); \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ echo "$${project} -> $${LCAPI}" ; \ @@ -53,7 +55,7 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services done @sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR) -/etc/systemd/system/yavsc%.service: generated/yavsc%.service +/etc/systemd/system/yavsc%.service: generated/yavsc%.service sudo cp $^ $@ sudo chown root:root $@ @@ -63,14 +65,14 @@ build_publish_%: clean_publish_dir_% clean_publish_dir_%: @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish -install: build_publish copy-binaries copy-services +install: build_publish copy-binaries copy-services @sudo systemctl daemon-reload @for project in $(APP_PROJECT_NAMES); \ do \ sudo systemctl enable yavsc$${project} ; \ sudo systemctl start yavsc$${project} ; \ done - + reinstall: copy-binaries @sync @for project in $(APP_PROJECT_NAMES); do \ @@ -84,12 +86,13 @@ stop-services: $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish +$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish -showConfig: +showConfig: @echo CONFIGURATION: $(CONFIGURATION) @echo BASEAPPDIR: $(BASEAPPDIR) clean: @rm -rf generated -.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean +.PHONY: build_publish mep showConfig copy-service-Api copy-service-Org copy-service-Blogs reinstall clean diff --git a/src/PostIt.Tests/Auth/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs similarity index 100% rename from src/PostIt.Tests/Auth/BearerScopeTests.cs rename to src/PostIt.Tests/BearerScopeTests.cs diff --git a/src/PostIt.Tests/Blogs/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs similarity index 72% rename from src/PostIt.Tests/Blogs/BlogApiTestFakes.cs rename to src/PostIt.Tests/BlogApiTestFakes.cs index 755d56ae..4b541e42 100644 --- a/src/PostIt.Tests/Blogs/BlogApiTestFakes.cs +++ b/src/PostIt.Tests/BlogApiTestFakes.cs @@ -64,29 +64,3 @@ internal sealed class RecordingYavscApiClient : YavscApiClient return Task.FromResult(default(T)!); } } - -/// -/// stand-in whose constructor -/// points at https://stub.invalid so any HTTP traffic -/// that escapes a test (misconfigured command, missing fake -/// handler) raises a clear -/// instead of silently hitting a real endpoint. Used by tests -/// that don't actually exercise the API client (they click a -/// button, assert on the nav stack, end of story) but whose -/// VMs require one in their constructor. -/// -internal sealed class ThrowingApi : YavscApiClient -{ - public ThrowingApi() : base( - new Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://stub.invalid", - ClientId = "stub", - Scopes = new[] { "openid" }, - }, - }, - new TokenStore(System.IO.Path.GetTempFileName())) - { } -} diff --git a/src/PostIt.Tests/Blogs/BlogPostAuthorDtoTests.cs b/src/PostIt.Tests/BlogPostAuthorDtoTests.cs similarity index 100% rename from src/PostIt.Tests/Blogs/BlogPostAuthorDtoTests.cs rename to src/PostIt.Tests/BlogPostAuthorDtoTests.cs diff --git a/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs b/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs deleted file mode 100644 index 71b21278..00000000 --- a/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs +++ /dev/null @@ -1,145 +0,0 @@ - -using Avalonia; -using Avalonia.Controls; -using Avalonia.Headless.XUnit; -using Microsoft.Extensions.DependencyInjection; -using PostIt.Services; -using PostIt.ViewModels; -using PostIt.Views; -using Yavsc.Api.Client; - -namespace PostIt.Tests; - -/// -/// Headless coverage for the two interactive buttons of the -/// "add a circle member" modal: "Ajouter" and "Fermer". -/// -/// The dialog is pushed on top of -/// via the canonical App.PushPageAsync pipeline (the -/// same path CirclesPageViewModel.OpenAddMemberAsync -/// uses). The test asserts on NavRoot.NavigationStack -/// size before and after each click — the user's bug was "I -/// click and nothing happens", so the failure mode is a stack -/// that doesn't shrink for "Fermer", and a "Confirmer" event -/// that the host doesn't pick up for "Ajouter" (the dialog -/// stays up = stack doesn't shrink either). -/// -/// Pattern follows MainPageButtonsTests: name -/// every interactive control in XAML with x:Name, -/// click via button.Command?.Execute(...) + flush -/// any async command before asserting. -/// -[Collection("PostIt Headless")] -public class AddCircleMemberDialogTests -{ - private PostItHeadlessCollection fixture; - - public AddCircleMemberDialogTests(PostItHeadlessCollection fixture, ITestOutputHelper output) - { - this.fixture = fixture; - } - /// - /// Stand-in that returns an - /// empty list. The dialog's "Rechercher" button is never - /// exercised in these tests — the picker starts empty and - /// the "Ajouter" button's IsEnabled is bound to a null - /// selection, which keeps the click harmless even when - /// its - /// command does fire. - /// - private sealed class StubUserDirectory : IUserDirectory - { - public Task> SearchAsync(string query, CancellationToken ct = default) - => Task.FromResult>(new List()); - } - - private sealed class ThrowingApi : YavscApiClient - { - public ThrowingApi() : base( - new Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://stub.invalid", - ClientId = "stub", - Scopes = new[] { "openid" }, - }, - }, - new TokenStore(System.IO.Path.GetTempFileName())) - { } - } - - /// - /// Mount a real , build a minimal - /// DI graph, push then the - /// on top of it. - /// Returns the stack size so the test can pin the delta. - /// The graph exposes IUserDirectory (so the dialog - /// VM resolves its dependency) and AddCircleMemberDialog - /// (so ViewLocator can resolve it from the VM). - /// - private static (MainWindow window, - CirclesPage page, - AddCircleMemberDialog dialog) - Mount() - { - var api = new ThrowingApi(); - var circleClient = new CircleApiClient(api, "http://localhost/"); - - var services = new ServiceCollection(); - services.AddSingleton(new Settings()); - services.AddSingleton(new StubUserDirectory()); - services.AddSingleton(circleClient); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - var sp = services.BuildServiceProvider(); - - var window = new MainWindow(); - var app = (PostIt.App)Application.Current!; - app.DataTemplates.Clear(); - app.DataTemplates.Add(new ViewLocator(sp)); - app.AttachMainWindow(window); - window.Show(); - - var circlesPage = sp.GetRequiredService(); - window.NavRoot.PushAsync(circlesPage).GetAwaiter().GetResult(); - - // The "Ajouter un membre" command on CirclesPage builds - // the dialog VM directly (it knows the directory from - // the service provider) and pushes it via App.PushPage. - var dialogVm = new AddCircleMemberDialogViewModel(sp.GetRequiredService()); - ((App)Application.Current!).PushPageAsync(dialogVm).GetAwaiter().GetResult(); - - var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog - ?? throw new System.InvalidOperationException("Dialog page not at top of stack."); - return (window, circlesPage, dialog); - } - - /// - /// Click the "Fermer" button on the dialog and assert the - /// nav stack shrinks by exactly one. - /// - [AvaloniaFact] - public void Close_button_pops_dialog_off_nav_stack() - { - // Arrange: stack starts at 2 (CirclesPage + dialog). - var window = fixture.Window; - var stackBefore = window.NavRoot.NavigationStack.Count; - Assert.Equal(2, stackBefore); - - // Act - var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog ?? throw new System.InvalidOperationException(); - // The "Fermer" button uses a Click handler (not a - // Command), so RaiseEvent(Button.ClickEvent) is the - // right way to fire it from headless code. Executing - // Command would no-op because no Command is bound. - dialog.CloseButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent)); - - // Assert: stack -1, the top is the CirclesPage again. - Assert.True(window.NavRoot.NavigationStack.Count == stackBefore - 1, - $"Click on 'Fermer' must shrink the nav stack by one. Before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}."); - Assert.IsType(window.NavRoot.NavigationStack[^1]); - } -} diff --git a/src/PostIt.Tests/Blogs/PostAclDialogTests.cs b/src/PostIt.Tests/Blogs/PostAclDialogTests.cs deleted file mode 100644 index 03ed7825..00000000 --- a/src/PostIt.Tests/Blogs/PostAclDialogTests.cs +++ /dev/null @@ -1,124 +0,0 @@ - -using System.Net; -using System.Text; -using System.Text.Json; -using Avalonia.Headless.XUnit; -using Microsoft.Extensions.DependencyInjection; -using PostIt.Services; -using PostIt.ViewModels; -using PostIt.Views; -using Yavsc.Api.Client; -using Yavsc.Blogspot; - -namespace PostIt.Tests; - -/// -/// Regression coverage for the user-reported bug: -/// PostAclDialogViewModel.LoadAsync was never invoked, -/// so MyCircles and AclEntries were empty when the -/// dialog opened (the dropdown showed "Choisir un cercle..." and -/// the list was blank, with no error to hint at why). -/// -/// The fix wires 's constructor -/// to trigger LoadAsync on the first -/// DataContextChanged, and the VM guards re-entry via -/// _loaded. Two tests pin that contract: -/// -/// LoadAsync_runs_once_on_DataContext_changed: HTTP -/// traffic shows up after the dialog is mounted. -/// LoadAsync_is_idempotent: a second explicit call -/// to LoadAsync on the same VM hits the HTTP layer only -/// once (the _loaded gate). -/// -/// -/// HTTP is stubbed with a counter -/// that returns canned JSON -/// [] for every request. The handler counts calls so the -/// tests can assert "exactly one round-trip on mount" and -/// "exactly one round-trip after two calls to LoadAsync". This -/// is the same shape used by BearerScopeTests: real -/// subclass, real -/// with an injected handler, real -/// / -/// talking to it. -/// -/// Lifecycle: shared -/// provides the already wired to -/// . Each test builds its own DI graph with -/// the counting HTTP handler and swaps it in via -/// . The -/// graph exposes PostAclDialog so the -/// resolves it from -/// . -/// -[Collection("PostIt Headless")] -public sealed class PostAclDialogTests -{ - private readonly PostItHeadlessCollection _host; - - public PostAclDialogTests(PostItHeadlessCollection host) - { - _host = host; - } - - /// - /// that replies 200 with - /// [] (a valid JSON empty array, which both - /// GetMyAclAsync and GetMyCirclesAsync can - /// deserialize) and counts the number of requests. - /// - private sealed class CountingHttpHandler : HttpMessageHandler - { - public int RequestCount { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - RequestCount++; - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("[]", Encoding.UTF8, "application/json"), - }; - return Task.FromResult(response); - } - } - - /// - /// Subclass of that routes HTTP - /// traffic through a caller-supplied - /// . Same recipe as - /// BearerScopeTests.TestableYavscApiClient — we - /// override CallAsync{T} to talk to our own - /// and skip the OIDC refresh path, - /// because the load-on-attach bug has nothing to do with - /// token refresh. - /// - private sealed class TestableYavscApiClient : YavscApiClient - { - private readonly HttpClient _http; - - public TestableYavscApiClient( - Settings settings, - TokenStore store, - HttpMessageHandler handler) - : base(settings, store, oidc: null!) - { - _http = new HttpClient(handler, disposeHandler: false); - } - - public override Task CallAsync( - HttpMethod method, string path, object? body = null, - CancellationToken ct = default) - { - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); - using var req = new HttpRequestMessage(method, absolute); - using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); - resp.EnsureSuccessStatusCode(); - using var stream = resp.Content.ReadAsStream(); - var dto = JsonSerializer.Deserialize(stream, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - return Task.FromResult(dto!); - } - } - -} diff --git a/src/PostIt.Tests/Auth/FakeAuthorizingBrowser.cs b/src/PostIt.Tests/FakeAuthorizingBrowser.cs similarity index 100% rename from src/PostIt.Tests/Auth/FakeAuthorizingBrowser.cs rename to src/PostIt.Tests/FakeAuthorizingBrowser.cs diff --git a/src/PostIt.Tests/Blogs/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs similarity index 57% rename from src/PostIt.Tests/Blogs/MainPageButtonsTests.cs rename to src/PostIt.Tests/MainPageButtonsTests.cs index 90e5b6fb..767f9c2e 100644 --- a/src/PostIt.Tests/Blogs/MainPageButtonsTests.cs +++ b/src/PostIt.Tests/MainPageButtonsTests.cs @@ -1,4 +1,6 @@ +using Avalonia; using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Headless.XUnit; using Avalonia.Input; using Avalonia.Interactivity; @@ -48,43 +50,43 @@ namespace PostIt.Tests; /// "[DEV] Signature" — click pushes a page onto the /// stack. /// -/// -/// Lifecycle: shared -/// owns the and the production DI graph. -/// Each test builds a local with -/// the fake + the page VMs and -/// registers the destination pages, then swaps it in via -/// . The -/// fixture re-attaches the ViewLocator and the MainWindow so -/// subsequent calls route through -/// the overridden graph. /// -[Collection("PostIt Headless")] -public sealed class MainPageButtonsTests +public class MainPageButtonsTests { - private readonly PostItHeadlessCollection _host; - - public MainPageButtonsTests(PostItHeadlessCollection host) + /// + /// Fake that throws on any + /// wire call. These tests never invoke a command that hits + /// the API — only the click → nav side of the pipeline is + /// asserted. + /// + private sealed class ThrowingApi : YavscApiClient { - _host = host; + public ThrowingApi() : base( + new Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://stub.invalid", + ClientId = "stub", + Scopes = new[] { "openid" }, + }, + }, + new TokenStore(System.IO.Path.GetTempFileName())) + { } } - /// - /// Build the test DI graph: for - /// the API clients (the click tests never hit the wire; - /// any traffic would be a wiring bug), the real - /// / - /// / that the page VM - /// resolves, and the page + dialog + VM registrations the - /// needs to resolve the three - /// push targets. - /// - private MainPageViewModel BuildViewModel(BlogPostDto? selectedPost = null) + private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null) { var api = new ThrowingApi(); var blog = new BlogApiClient(api, "http://localhost/"); var circle = new CircleApiClient(api, "http://localhost/"); var acl = new BlogAclApiClient(api, "http://localhost/"); + // Minimal DI graph: only what MainPageViewModel resolves + // when the user clicks a navigation button. Today that's + // SignaturePageViewModel / CirclesPageViewModel / ACL + // dependencies. The graph intentionally stays local to this + // suite to avoid side effects from App.BuildServices() (real + // token-store wiring). var services = new ServiceCollection(); services.AddSingleton(new Settings()); services.AddSingleton(circle); @@ -94,31 +96,51 @@ public sealed class MainPageButtonsTests services.AddTransient(); services.AddTransient(); services.AddTransient(); - var sp = services.BuildServiceProvider(); - - var vm = new MainPageViewModel(blog, services: sp); + var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider()); if (selectedPost is not null) vm.SelectedPost = selectedPost; return vm; } /// - /// Push a with the given VM onto - /// the shared 's nav stack. Clears - /// any pages the previous test left behind (the fixture's - /// MainWindow is shared across every test class). Returns - /// the live page so the test can access its named buttons. + /// Mount a real (as + /// SessionStatusBannerTests does), push a + /// with the given VM onto + /// NavRoot. PushAsync is awaited (via + /// GetAwaiter().GetResult()) so the page is on the + /// nav stack before the test tries to interact with its + /// named buttons. The window is shown so the visual tree is + /// realised and KeyPressQwerty has a real + /// to dispatch against. /// - private MainPage MountAsync(MainPageViewModel vm) + private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm) { + var window = new MainWindow(); var page = new MainPage { DataContext = vm }; - _host.PushAsync(page); - return page; + var app = (PostIt.App)Application.Current!; + if (vm.Services is not null) + { + app.DataTemplates.Clear(); + app.DataTemplates.Add(new ViewLocator(vm.Services)); + } + app.AttachMainWindow(window); + window.Show(); + window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); + return (window, page); } /// - /// Click a button by executing its - /// and draining any so the - /// caller can assert on the resulting nav stack immediately. + /// Click a button by focusing it and pressing Enter — the + /// supported headless pattern (cf. CalculatorTests in the + /// Avalonia.Samples repo). Returns the nav-stack count + /// before the click so the caller can assert on the delta. + /// KeyPressQwerty is dispatched on the + /// itself — it is the that owns the + /// headless implementation, and routing the key through any + /// descendant TopLevel (e.g. one obtained via + /// TopLevel.GetTopLevel(button)) fails with a + /// NullReferenceException from the headless impl + /// because the descendant does not carry the + /// PlatformHandle the harness expects. /// private static int ClickAndCapture(MainWindow window, Button button) { @@ -143,8 +165,8 @@ public sealed class MainPageButtonsTests Title = "An existing post", AuthorId = "u-alice" }; - var vm = BuildViewModel(post); - var page = MountAsync(vm); + var vm = MakeViewModel(post); + var (window, page) = MountMainPage(vm); // Sanity: the button's command is bound and CanExecute // is true. If this fails, the bug is upstream (XAML @@ -154,12 +176,12 @@ public sealed class MainPageButtonsTests Assert.True(aclButton.Command.CanExecute(null)); // Act - var stackBefore = ClickAndCapture(_host.Window, aclButton); + var stackBefore = ClickAndCapture(window, aclButton); // Assert γ + sniff léger: stack grew, new top is a Page. - Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore, - $"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {_host.Window.NavRoot.NavigationStack.Count}."); - var pushed = _host.Window.NavRoot.NavigationStack[^1]; + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + $"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}."); + var pushed = window.NavRoot.NavigationStack.Last(); Assert.NotNull(pushed); Assert.IsAssignableFrom(pushed); } @@ -169,19 +191,19 @@ public sealed class MainPageButtonsTests { // Arrange: OpenCircles has no CanExecute guard today — // any click should fire it and push the page. - var vm = BuildViewModel(); - var page = MountAsync(vm); + var vm = MakeViewModel(); + var (window, page) = MountMainPage(vm); var circlesButton = page.OpenCirclesButton; Assert.NotNull(circlesButton.Command); // Act - var stackBefore = ClickAndCapture(_host.Window, circlesButton); + var stackBefore = ClickAndCapture(window, circlesButton); // Assert - Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore, + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, "Click on 'Mes cercles' must push a new page onto the nav stack."); - var pushed = _host.Window.NavRoot.NavigationStack[^1]; + var pushed = window.NavRoot.NavigationStack.Last(); Assert.NotNull(pushed); Assert.IsAssignableFrom(pushed); } @@ -192,25 +214,25 @@ public sealed class MainPageButtonsTests // Arrange: the "[DEV] Signature" button is bound to the // MainPageViewModel.OpenSignatureDevCommand [RelayCommand]. // The click must push SignaturePage on top of NavRoot. - // The ServiceCollection registered in BuildViewModel - // provides SignaturePageViewModel so the command can - // resolve it via DI and call App.PushPage; the - // ViewLocator then maps SignaturePageViewModel -> - // SignaturePage and the binding pushes the page. - var vm = BuildViewModel(); - var page = MountAsync(vm); + // The ServiceCollection registered in MakeViewModel provides + // SignaturePageViewModel so the command can resolve it via + // DI and call App.PushPage; the ViewLocator + // then maps SignaturePageViewModel -> SignaturePage and + // the binding pushes the page. + var vm = MakeViewModel(); + var (window, page) = MountMainPage(vm); var signatureButton = page.OpenSignatureDevButton; Assert.NotNull(signatureButton.Command); Assert.True(signatureButton.Command.CanExecute(null)); // Act - var stackBefore = ClickAndCapture(_host.Window, signatureButton); + var stackBefore = ClickAndCapture(window, signatureButton); // Assert - Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore, + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, "Click on '[DEV] Signature' must push a new page onto the nav stack."); - var pushed = _host.Window.NavRoot.NavigationStack[^1]; + var pushed = window.NavRoot.NavigationStack.Last(); Assert.NotNull(pushed); Assert.IsAssignableFrom(pushed); } diff --git a/src/PostIt.Tests/Blogs/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs similarity index 62% rename from src/PostIt.Tests/Blogs/MainPageSaveTests.cs rename to src/PostIt.Tests/MainPageSaveTests.cs index 918b40de..b6bf963a 100644 --- a/src/PostIt.Tests/Blogs/MainPageSaveTests.cs +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -1,22 +1,22 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; -using Microsoft.Extensions.DependencyInjection; +using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; -using Yavsc.Api.Client; -using Yavsc.Blogspot; - namespace PostIt.Tests; /// /// Headless UI tests for the "Save" flow in . -/// Uses the shared (a real -/// with the production DI graph attached -/// to ) plus a local -/// that swaps -/// for the recording fake. +/// The pattern is the one SessionStatusBannerTests +/// established: [AvaloniaFact], a +/// hosting the page (via a because +/// MainPage is a ContentPage), then drive the +/// controls through their public surface and assert on what +/// saw go on the wire. /// /// The bug we are pinning: the title TextBox is /// currently {Binding SelectedPost.Title, Mode=TwoWay}. @@ -31,38 +31,41 @@ namespace PostIt.Tests; /// pass once the VM owns a dedicated Title/Article /// buffer that the XAML binds to and the Save command consumes. /// -[Collection("PostIt Headless")] -public sealed class MainPageSaveTests +public class MainPageSaveTests { - private readonly PostItHeadlessCollection _host; - - public MainPageSaveTests(PostItHeadlessCollection host) - { - _host = host; - } - [AvaloniaFact] - public void Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body() + public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body() { - // Arrange: VM with a recording API client, mounted on - // the shared MainWindow's nav stack. + // Arrange: VM with a recording API client, mounted in a + // headless window via a Frame (MainPage is a ContentPage, + // not a Control, so it needs a navigation host). var recorder = new CallRecorder(); - - var blog = _host.Services.GetRequiredService(); + var api = new RecordingYavscApiClient(recorder); + var blog = new BlogApiClient(api, "http://localhost/"); var viewModel = new MainPageViewModel(blog); + var page = new MainPage { DataContext = viewModel }; - _host.PushAsync(page); + // MainPage is a ContentPage (a Page, not a Control), so it + // must be hosted in a navigation surface. The production + // MainWindow.axaml uses NavigationPage, and the API is the + // same one App.axaml.cs drives at boot (PushAsync, fire- + // and-forget in prod because the page is the top of the + // stack immediately). + var nav = new NavigationPage(); + _ = nav.PushAsync(page); + var window = new Window { Content = nav }; + window.Show(); // Act: type a title into the editor's TextBox without - // first selecting a post in the list — the only state - // in which a new post can be created. Then click Save. - var titleBox = _host.Window.GetVisualDescendants() + // first selecting a post in the list — the only state in + // which a new post can be created. Then click Save. + var titleBox = window.GetVisualDescendants() .OfType() .First(t => t.PlaceholderText == "Title"); const string typed = "Mon premier billet"; titleBox.Text = typed; - var saveButton = _host.Window.GetVisualDescendants() + var saveButton = window.GetVisualDescendants() .OfType