diff --git a/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs b/src/PostIt.Tests/AddCircleMemberDialogTests.cs similarity index 93% rename from src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs rename to src/PostIt.Tests/AddCircleMemberDialogTests.cs index 71b21278..ab1f3a69 100644 --- a/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs +++ b/src/PostIt.Tests/AddCircleMemberDialogTests.cs @@ -1,4 +1,6 @@ - +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; @@ -29,15 +31,8 @@ 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 @@ -78,10 +73,7 @@ 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/"); @@ -125,7 +117,7 @@ public class AddCircleMemberDialogTests public void Close_button_pops_dialog_off_nav_stack() { // Arrange: stack starts at 2 (CirclesPage + dialog). - var window = fixture.Window; + var (window, _, _) = Mount(); var stackBefore = window.NavRoot.NavigationStack.Count; Assert.Equal(2, stackBefore); 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/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