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);
}
}