diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs new file mode 100644 index 00000000..26636635 --- /dev/null +++ b/src/PostIt.Tests/MainPageButtonsTests.cs @@ -0,0 +1,207 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Api.Client; +using Yavsc.Blogspot; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; + +namespace PostIt.Tests; + +/// +/// Regression coverage for the three toolbar buttons on +/// that the user reported as inoperative: +/// "ACL", "Mes cercles", and "[DEV] Signature". +/// +/// Pattern (per the Avalonia headless testing docs — +/// TestableApp.Headless.XUnit/CalculatorTests): name every +/// interactive control in the XAML with x:Name="...", then +/// in the test focus the named control and raise the click via +/// window.KeyPressQwerty(PhysicalKey.Enter, ...). This is +/// the supported path — searching the visual tree via +/// GetVisualDescendants().OfType<Button>() for a +/// button by Content text is brittle and was tried first; it does +/// not work reliably when the page is hosted inside an +/// , which wraps the +/// pushed page in an internal container that the visual-tree walk +/// does not always expose under headless. +/// +/// The assertion is on the post-click top of +/// : +/// the user's bug is "I click and the dialog / page never opens", +/// so the test fails when the click doesn't push anything onto the +/// stack. We pin γ + sniff léger — the new top must be a non-null +/// , but we do not yet assert the concrete type +/// (that would require a fully stubbed App.ServiceProvider, +/// which is the next iteration of this suite). +/// +/// Each test exercises the bit that would silently break if +/// the wiring was reverted: +/// +/// "ACL" — click with a selected post pushes a page onto +/// the stack. +/// "Mes cercles" — click pushes a page onto the stack. +/// "[DEV] Signature" — click pushes a page onto the +/// stack. +/// +/// +public 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 + { + public ThrowingApi() : base( + new Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://stub.invalid", + ClientId = "stub", + Scopes = new[] { "openid" }, + }, + }, + new TokenStore(System.IO.Path.GetTempFileName())) + { } + } + + private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null) + { + var api = new ThrowingApi(); + var blog = new BlogApiClient(api, "http://localhost/"); + var vm = new MainPageViewModel(blog); + 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. + /// + private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm) + { + var window = new MainWindow(); + var page = new MainPage { DataContext = vm }; + window.Show(); + window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); + return (window, 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. + /// + private static int ClickAndCapture(MainWindow window, Button button) + { + var stackBefore = window.NavRoot.NavigationStack.Count; + button.Focus(); + window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + return stackBefore; + } + + [AvaloniaFact] + public void Acl_button_click_pushes_a_page_onto_nav_stack() + { + // Arrange: a VM whose SelectedPost is non-null so + // CanManageAcl evaluates to true and the button is + // armed. + var post = new BlogPostDto + { + Id = 42, + Title = "An existing post", + AuthorId = "u-alice" + }; + 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 + // binding) and the rest of the test is moot. + var aclButton = page.ManageAclButton; + Assert.NotNull(aclButton.Command); + Assert.True(aclButton.Command.CanExecute(null)); + + // Act + var stackBefore = ClickAndCapture(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.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } + + [AvaloniaFact] + public void Circles_button_click_pushes_a_page_onto_nav_stack() + { + // 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 circlesButton = page.OpenCirclesButton; + Assert.NotNull(circlesButton.Command); + + // Act + var stackBefore = ClickAndCapture(window, circlesButton); + + // Assert + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + "Click on 'Mes cercles' must push a new page onto the nav stack."); + var pushed = window.NavRoot.NavigationStack.Last(); + Assert.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } + + [AvaloniaFact] + public void Signature_dev_button_click_pushes_a_page_onto_nav_stack() + { + // Arrange: the "[DEV] Signature" button uses XAML's + // Click="OpenSignatureDev" attribute, so we don't bind + // a Command here — we drive the click directly. The + // handler resolves App.ServiceProvider, which is null + // in a unit test, and early-returns; that is the + // failure mode the test pins. + var vm = MakeViewModel(); + var (window, page) = MountMainPage(vm); + + var signatureButton = page.OpenSignatureDevButton; + + // Act + var stackBefore = ClickAndCapture(window, signatureButton); + + // Assert + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + "Click on '[DEV] Signature' must push a new page onto the nav stack."); + var pushed = window.NavRoot.NavigationStack.Last(); + Assert.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } +} diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 0246857e..86bcf7dd 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -33,8 +33,12 @@