using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; 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 MainViewModel 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); services.AddSingleton(acl); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); var vm = new MainViewModel(blog, services: services.BuildServiceProvider()); 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 (MainView window, MainPage page) MountMainPage(MainViewModel vm) { var window = new MainView(); var page = new MainPage { DataContext = vm }; var app = (PostIt.App)Application.Current!; app.AttachMainWindow(window); 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(MainView window, Button button) { var stackBefore = window.NavRoot.NavigationStack.Count; button.Command?.Execute(button.CommandParameter); if (button.Command is IAsyncRelayCommand asyncCommand) { asyncCommand.ExecutionTask?.GetAwaiter().GetResult(); } 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 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); var signatureButton = page.OpenSignatureDevButton; Assert.NotNull(signatureButton.Command); Assert.True(signatureButton.Command.CanExecute(null)); // 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); } }