From a44c04ad77da56099a1bc5a33831c720872043cf Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 20 Aug 2026 23:59:21 +0100 Subject: [PATCH] =?UTF-8?q?feat(postit):=20circles+ACL=20UI,=20blog=20fixt?= =?UTF-8?q?ure=E2=86=92SQLite,=20seed=20default=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled end-of-branch commit on feat/postit-acl-members. PostIt UI for circles + per-post ACL - Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders (Bearer/OIDC scope tests vs. blog API fakes live where they belong) and introduces PostItHeadlessCollection so the Avalonia.Headless tests share a single xUnit collection instead of contending with the EF-Core test host. - Adds BlogAclApiTests (a brand-new behavioural layer over POST /api/v1/blogacl) and the fakes it relies on (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember DialogTests); pulls UserId-through-OIDC-sub path into BearerScopeTests / FakeAuthorizingBrowser / OidcStubAuthority. - App.axaml.cs gets a small PushPageAsync touch-up the new tests rely on. - Drops UnitTest1.cs (xUnit scaffold, never used). Yavsc.Blogs.Tests — SQLite instead of InMemory - Bumps Yavsc.Blogs.Tests.csproj on Microsoft.EntityFrameworkCore.Sqlite and rewrites BlogsWebServerFixture to hold a single shared SqliteConnection (Cache=Shared) for the fixture lifetime, with a sync Dispose close to dodge async teardown hangs. Reason: the EF Core InMemory provider silently ignores FKs, which masked the kind of bug we are about to pin in the ACL tests. SQLite enforces them, so any future INSERT that forgets to seed its parent rows fails loudly here instead of passing the test and breaking prod. - PublishEndpointTests and BlogApiSmokeTests get a one-line tweak to follow the new connection lifecycle. Foreign-key fallout: seed the default user in the fixture - Adds BlogsWebServerFixture.SeedUser(userName). Now that SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every test that POST/PUT/DELETE a BlogPost and sends AuthorId= 'tester' in the payload needs an AspNetUsers row to satisfy the FK or it returns 500 with SQLite Error 19. - BlogApiTests wraps the existing ResetDatabase with a ResetAndSeedDefaultUser helper for the six mutating tests; the four GET-only and ModelState-only tests keep the bare ResetDatabase. - Side benefit: every test in Yavsc.Blogs.Tests now finishes cleanly instead of hanging at teardown — previously a stuck test held the shared SqliteConnection open and the next tests waited indefinitely. Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25 green from a clean run, no fixture teardown hang. --- Directory.Packages.props | 1 + contrib/Makefile | 19 +- .../{ => Auth}/BearerScopeTests.cs | 0 .../{ => Auth}/FakeAuthorizingBrowser.cs | 0 .../{ => Auth}/OidcStubAuthority.cs | 0 .../{ => Blogs}/AddCircleMemberDialogTests.cs | 18 +- .../{ => Blogs}/BlogApiTestFakes.cs | 26 ++ .../{ => Blogs}/BlogPostAuthorDtoTests.cs | 0 .../{ => Blogs}/MainPageButtonsTests.cs | 146 +++++------ .../{ => Blogs}/MainPageSaveTests.cs | 71 +++--- src/PostIt.Tests/Blogs/PostAclDialogTests.cs | 124 ++++++++++ src/PostIt.Tests/PostAclDialogTests.cs | 234 ------------------ src/PostIt.Tests/PostItHeadlessCollection.cs | 177 +++++++++++++ src/PostIt.Tests/SessionStatusBannerTests.cs | 93 +++---- src/PostIt.Tests/UnitTest1.cs | 16 -- src/PostIt/PostIt/App.axaml.cs | 5 +- src/Yavsc.Blogs.Tests/BlogAclApiTests.cs | 161 ++++++++++++ src/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs | 1 + src/Yavsc.Blogs.Tests/BlogApiTests.cs | 29 ++- .../BlogsWebServerFixture.cs | 148 +++++++++-- src/Yavsc.Blogs.Tests/PublishEndpointTests.cs | 2 +- .../Yavsc.Blogs.Tests.csproj | 1 + 22 files changed, 809 insertions(+), 463 deletions(-) rename src/PostIt.Tests/{ => Auth}/BearerScopeTests.cs (100%) rename src/PostIt.Tests/{ => Auth}/FakeAuthorizingBrowser.cs (100%) rename src/PostIt.Tests/{ => Auth}/OidcStubAuthority.cs (100%) rename src/PostIt.Tests/{ => Blogs}/AddCircleMemberDialogTests.cs (93%) rename src/PostIt.Tests/{ => Blogs}/BlogApiTestFakes.cs (72%) rename src/PostIt.Tests/{ => Blogs}/BlogPostAuthorDtoTests.cs (100%) rename src/PostIt.Tests/{ => Blogs}/MainPageButtonsTests.cs (57%) rename src/PostIt.Tests/{ => Blogs}/MainPageSaveTests.cs (62%) create mode 100644 src/PostIt.Tests/Blogs/PostAclDialogTests.cs delete mode 100644 src/PostIt.Tests/PostAclDialogTests.cs create mode 100644 src/PostIt.Tests/PostItHeadlessCollection.cs delete mode 100644 src/PostIt.Tests/UnitTest1.cs create mode 100644 src/Yavsc.Blogs.Tests/BlogAclApiTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e4b09159..84380e44 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + diff --git a/contrib/Makefile b/contrib/Makefile index 62e1e22d..151045db 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -1,4 +1,4 @@ -APP_PROJECT_NAMES=Api Org Blogs +APP_PROJECT_NAMES=Org Blogs SLNDIR=.. include $(SLNDIR)/.env @@ -7,7 +7,6 @@ include .env generated/: @mkdir -p $@ -generated/yavscApi.service: generated/yavscOrg.service: generated/yavscBlogs.service: @@ -34,12 +33,11 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env @echo Created service file: $@ -copy-services: copy-service-Org copy-service-Api copy-service-Blogs +copy-services: copy-service-Org 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_Api build_publish_Blogs stop-services +copy-binaries: build_publish_Org build_publish_Blogs stop-services @for project in $(APP_PROJECT_NAMES); \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ echo "$${project} -> $${LCAPI}" ; \ @@ -55,7 +53,7 @@ copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-serv 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 $@ @@ -65,14 +63,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 \ @@ -86,13 +84,12 @@ 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-Api copy-service-Org copy-service-Blogs reinstall clean +.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/Auth/BearerScopeTests.cs similarity index 100% rename from src/PostIt.Tests/BearerScopeTests.cs rename to src/PostIt.Tests/Auth/BearerScopeTests.cs diff --git a/src/PostIt.Tests/FakeAuthorizingBrowser.cs b/src/PostIt.Tests/Auth/FakeAuthorizingBrowser.cs similarity index 100% rename from src/PostIt.Tests/FakeAuthorizingBrowser.cs rename to src/PostIt.Tests/Auth/FakeAuthorizingBrowser.cs diff --git a/src/PostIt.Tests/OidcStubAuthority.cs b/src/PostIt.Tests/Auth/OidcStubAuthority.cs similarity index 100% rename from src/PostIt.Tests/OidcStubAuthority.cs rename to src/PostIt.Tests/Auth/OidcStubAuthority.cs diff --git a/src/PostIt.Tests/AddCircleMemberDialogTests.cs b/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs similarity index 93% rename from src/PostIt.Tests/AddCircleMemberDialogTests.cs rename to src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs index ab1f3a69..71b21278 100644 --- a/src/PostIt.Tests/AddCircleMemberDialogTests.cs +++ b/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; + using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; @@ -31,8 +29,15 @@ namespace PostIt.Tests; /// 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 @@ -73,7 +78,10 @@ public class AddCircleMemberDialogTests /// VM resolves its dependency) and AddCircleMemberDialog /// (so ViewLocator can resolve it from the VM). /// - private static (MainWindow window, CirclesPage page, AddCircleMemberDialog dialog) Mount() + private static (MainWindow window, + CirclesPage page, + AddCircleMemberDialog dialog) + Mount() { var api = new ThrowingApi(); var circleClient = new CircleApiClient(api, "http://localhost/"); @@ -117,7 +125,7 @@ public class AddCircleMemberDialogTests public void Close_button_pops_dialog_off_nav_stack() { // Arrange: stack starts at 2 (CirclesPage + dialog). - var (window, _, _) = Mount(); + var window = fixture.Window; var stackBefore = window.NavRoot.NavigationStack.Count; Assert.Equal(2, stackBefore); diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/Blogs/BlogApiTestFakes.cs similarity index 72% rename from src/PostIt.Tests/BlogApiTestFakes.cs rename to src/PostIt.Tests/Blogs/BlogApiTestFakes.cs index 4b541e42..755d56ae 100644 --- a/src/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt.Tests/Blogs/BlogApiTestFakes.cs @@ -64,3 +64,29 @@ 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/BlogPostAuthorDtoTests.cs b/src/PostIt.Tests/Blogs/BlogPostAuthorDtoTests.cs similarity index 100% rename from src/PostIt.Tests/BlogPostAuthorDtoTests.cs rename to src/PostIt.Tests/Blogs/BlogPostAuthorDtoTests.cs diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/Blogs/MainPageButtonsTests.cs similarity index 57% rename from src/PostIt.Tests/MainPageButtonsTests.cs rename to src/PostIt.Tests/Blogs/MainPageButtonsTests.cs index 767f9c2e..90e5b6fb 100644 --- a/src/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt.Tests/Blogs/MainPageButtonsTests.cs @@ -1,6 +1,4 @@ -using Avalonia; using Avalonia.Controls; -using Avalonia.Headless; using Avalonia.Headless.XUnit; using Avalonia.Input; using Avalonia.Interactivity; @@ -50,43 +48,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. /// -public class MainPageButtonsTests +[Collection("PostIt Headless")] +public sealed class MainPageButtonsTests { - /// - /// 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 + private readonly PostItHeadlessCollection _host; + + public MainPageButtonsTests(PostItHeadlessCollection host) { - public ThrowingApi() : base( - new Settings - { - Authentication = new AuthenticationSettings - { - Authority = "https://stub.invalid", - ClientId = "stub", - Scopes = new[] { "openid" }, - }, - }, - new TokenStore(System.IO.Path.GetTempFileName())) - { } + _host = host; } - private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null) + /// + /// 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) { 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); @@ -96,51 +94,31 @@ public class MainPageButtonsTests services.AddTransient(); services.AddTransient(); services.AddTransient(); - var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider()); + var sp = services.BuildServiceProvider(); + + var vm = new MainPageViewModel(blog, services: sp); if (selectedPost is not null) vm.SelectedPost = selectedPost; return vm; } /// - /// 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. + /// 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. /// - private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm) + private MainPage MountAsync(MainPageViewModel vm) { - var window = new MainWindow(); var page = new MainPage { DataContext = vm }; - 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); + _host.PushAsync(page); + return page; } /// - /// 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. + /// Click a button by executing its + /// and draining any so the + /// caller can assert on the resulting nav stack immediately. /// private static int ClickAndCapture(MainWindow window, Button button) { @@ -165,8 +143,8 @@ public class MainPageButtonsTests Title = "An existing post", AuthorId = "u-alice" }; - var vm = MakeViewModel(post); - var (window, page) = MountMainPage(vm); + var vm = BuildViewModel(post); + var page = MountAsync(vm); // Sanity: the button's command is bound and CanExecute // is true. If this fails, the bug is upstream (XAML @@ -176,12 +154,12 @@ public class MainPageButtonsTests Assert.True(aclButton.Command.CanExecute(null)); // Act - var stackBefore = ClickAndCapture(window, aclButton); + var stackBefore = ClickAndCapture(_host.Window, aclButton); // Assert γ + sniff léger: stack grew, new top is a Page. - 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.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.NotNull(pushed); Assert.IsAssignableFrom(pushed); } @@ -191,19 +169,19 @@ public class MainPageButtonsTests { // Arrange: OpenCircles has no CanExecute guard today — // any click should fire it and push the page. - var vm = MakeViewModel(); - var (window, page) = MountMainPage(vm); + var vm = BuildViewModel(); + var page = MountAsync(vm); var circlesButton = page.OpenCirclesButton; Assert.NotNull(circlesButton.Command); // Act - var stackBefore = ClickAndCapture(window, circlesButton); + var stackBefore = ClickAndCapture(_host.Window, circlesButton); // Assert - Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore, "Click on 'Mes cercles' must push a new page onto the nav stack."); - var pushed = window.NavRoot.NavigationStack.Last(); + var pushed = _host.Window.NavRoot.NavigationStack[^1]; Assert.NotNull(pushed); Assert.IsAssignableFrom(pushed); } @@ -214,25 +192,25 @@ public 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 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); + // 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); var signatureButton = page.OpenSignatureDevButton; Assert.NotNull(signatureButton.Command); Assert.True(signatureButton.Command.CanExecute(null)); // Act - var stackBefore = ClickAndCapture(window, signatureButton); + var stackBefore = ClickAndCapture(_host.Window, signatureButton); // Assert - Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore, "Click on '[DEV] Signature' must push a new page onto the nav stack."); - var pushed = window.NavRoot.NavigationStack.Last(); + var pushed = _host.Window.NavRoot.NavigationStack[^1]; Assert.NotNull(pushed); Assert.IsAssignableFrom(pushed); } diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/Blogs/MainPageSaveTests.cs similarity index 62% rename from src/PostIt.Tests/MainPageSaveTests.cs rename to src/PostIt.Tests/Blogs/MainPageSaveTests.cs index b6bf963a..918b40de 100644 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt.Tests/Blogs/MainPageSaveTests.cs @@ -1,22 +1,22 @@ -using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; -using Yavsc.Blogspot; -using Yavsc.Api.Client; +using Microsoft.Extensions.DependencyInjection; 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 . -/// 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. +/// Uses the shared (a real +/// with the production DI graph attached +/// to ) plus a local +/// that swaps +/// for the recording fake. /// /// The bug we are pinning: the title TextBox is /// currently {Binding SelectedPost.Title, Mode=TwoWay}. @@ -31,41 +31,38 @@ namespace PostIt.Tests; /// pass once the VM owns a dedicated Title/Article /// buffer that the XAML binds to and the Save command consumes. /// -public class MainPageSaveTests +[Collection("PostIt Headless")] +public sealed class MainPageSaveTests { - [AvaloniaFact] - public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body() - { - // 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 api = new RecordingYavscApiClient(recorder); - var blog = new BlogApiClient(api, "http://localhost/"); - var viewModel = new MainPageViewModel(blog); + 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() + { + // Arrange: VM with a recording API client, mounted on + // the shared MainWindow's nav stack. + var recorder = new CallRecorder(); + + var blog = _host.Services.GetRequiredService(); + var viewModel = new MainPageViewModel(blog); var page = new MainPage { DataContext = viewModel }; - // 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(); + _host.PushAsync(page); // 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 = 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 = _host.Window.GetVisualDescendants() .OfType() .First(t => t.PlaceholderText == "Title"); const string typed = "Mon premier billet"; titleBox.Text = typed; - var saveButton = window.GetVisualDescendants() + var saveButton = _host.Window.GetVisualDescendants() .OfType