2026-08-18 23:24:08 +01:00
|
|
|
|
using Avalonia;
|
|
|
|
|
|
using Avalonia.Controls;
|
|
|
|
|
|
using Avalonia.Headless.XUnit;
|
|
|
|
|
|
using CommunityToolkit.Mvvm.Input;
|
2026-08-19 17:01:58 +01:00
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
2026-08-18 23:24:08 +01:00
|
|
|
|
using Yavsc.Api.Client;
|
|
|
|
|
|
using Yavsc.Blogspot;
|
|
|
|
|
|
using PostIt.Services;
|
|
|
|
|
|
using PostIt.ViewModels;
|
|
|
|
|
|
using PostIt.Views;
|
|
|
|
|
|
|
|
|
|
|
|
namespace PostIt.Tests;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Regression coverage for the three toolbar buttons on
|
|
|
|
|
|
/// <see cref="MainPage"/> that the user reported as inoperative:
|
|
|
|
|
|
/// "ACL", "Mes cercles", and "[DEV] Signature".
|
|
|
|
|
|
///
|
|
|
|
|
|
/// <para>Pattern (per the Avalonia headless testing docs —
|
|
|
|
|
|
/// <c>TestableApp.Headless.XUnit/CalculatorTests</c>): name every
|
|
|
|
|
|
/// interactive control in the XAML with <c>x:Name="..."</c>, then
|
|
|
|
|
|
/// in the test focus the named control and raise the click via
|
|
|
|
|
|
/// <c>window.KeyPressQwerty(PhysicalKey.Enter, ...)</c>. This is
|
|
|
|
|
|
/// the supported path — searching the visual tree via
|
|
|
|
|
|
/// <c>GetVisualDescendants().OfType<Button>()</c> for a
|
|
|
|
|
|
/// button by Content text is brittle and was tried first; it does
|
|
|
|
|
|
/// not work reliably when the page is hosted inside an
|
|
|
|
|
|
/// <see cref="Avalonia.Controls.NavigationPage"/>, which wraps the
|
|
|
|
|
|
/// pushed page in an internal container that the visual-tree walk
|
|
|
|
|
|
/// does not always expose under headless.</para>
|
|
|
|
|
|
///
|
|
|
|
|
|
/// <para>The assertion is on the post-click top of
|
|
|
|
|
|
/// <see cref="Avalonia.Controls.INavigation.NavigationStack"/>:
|
|
|
|
|
|
/// 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
|
|
|
|
|
|
/// <see cref="Page"/>, but we do not yet assert the concrete type
|
|
|
|
|
|
/// (that would require a fully stubbed <c>App.ServiceProvider</c>,
|
|
|
|
|
|
/// which is the next iteration of this suite).</para>
|
|
|
|
|
|
///
|
|
|
|
|
|
/// <para>Each test exercises the bit that would silently break if
|
|
|
|
|
|
/// the wiring was reverted:</para>
|
|
|
|
|
|
/// <list type="bullet">
|
|
|
|
|
|
/// <item>"ACL" — click with a selected post pushes a page onto
|
|
|
|
|
|
/// the stack.</item>
|
|
|
|
|
|
/// <item>"Mes cercles" — click pushes a page onto the stack.</item>
|
|
|
|
|
|
/// <item>"[DEV] Signature" — click pushes a page onto the
|
|
|
|
|
|
/// stack.</item>
|
|
|
|
|
|
/// </list>
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class MainPageButtonsTests
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Fake <see cref="YavscApiClient"/> 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.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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()))
|
|
|
|
|
|
{ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 21:28:26 +01:00
|
|
|
|
private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null)
|
2026-08-18 23:24:08 +01:00
|
|
|
|
{
|
|
|
|
|
|
var api = new ThrowingApi();
|
|
|
|
|
|
var blog = new BlogApiClient(api, "http://localhost/");
|
2026-08-19 17:06:46 +01:00
|
|
|
|
var circle = new CircleApiClient(api, "http://localhost/");
|
|
|
|
|
|
var acl = new BlogAclApiClient(api, "http://localhost/");
|
2026-08-19 17:01:58 +01:00
|
|
|
|
// Minimal DI graph: only what MainPageViewModel resolves
|
|
|
|
|
|
// when the user clicks a navigation button. Today that's
|
2026-08-19 17:06:46 +01:00
|
|
|
|
// SignaturePageViewModel / CirclesPageViewModel / ACL
|
|
|
|
|
|
// dependencies. The graph intentionally stays local to this
|
|
|
|
|
|
// suite to avoid side effects from App.BuildServices() (real
|
|
|
|
|
|
// token-store wiring).
|
2026-08-19 17:01:58 +01:00
|
|
|
|
var services = new ServiceCollection();
|
2026-08-19 17:06:46 +01:00
|
|
|
|
services.AddSingleton(new Settings());
|
|
|
|
|
|
services.AddSingleton(circle);
|
|
|
|
|
|
services.AddSingleton(acl);
|
2026-08-19 17:01:58 +01:00
|
|
|
|
services.AddTransient<SignaturePageViewModel>();
|
2026-08-19 17:06:46 +01:00
|
|
|
|
services.AddTransient<CirclesPageViewModel>();
|
|
|
|
|
|
services.AddTransient<SignaturePage>();
|
|
|
|
|
|
services.AddTransient<CirclesPage>();
|
|
|
|
|
|
services.AddTransient<PostAclDialog>();
|
2026-08-23 21:28:26 +01:00
|
|
|
|
var vm = new MainViewModel(blog, services: services.BuildServiceProvider());
|
2026-08-18 23:24:08 +01:00
|
|
|
|
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
|
|
|
|
|
return vm;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-08-25 14:16:49 +01:00
|
|
|
|
/// Mount a real <see cref="MainView"/> (as
|
2026-08-18 23:24:08 +01:00
|
|
|
|
/// <c>SessionStatusBannerTests</c> does), push a
|
|
|
|
|
|
/// <see cref="MainPage"/> with the given VM onto
|
|
|
|
|
|
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
|
|
|
|
|
|
/// <c>GetAwaiter().GetResult()</c>) 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 <c>KeyPressQwerty</c> has a real
|
|
|
|
|
|
/// <see cref="TopLevel"/> to dispatch against.
|
|
|
|
|
|
/// </summary>
|
2026-08-25 14:16:49 +01:00
|
|
|
|
private static (MainView window, MainPage page) MountMainPage(MainViewModel vm)
|
2026-08-18 23:24:08 +01:00
|
|
|
|
{
|
2026-08-25 14:16:49 +01:00
|
|
|
|
var window = new MainView();
|
2026-08-18 23:24:08 +01:00
|
|
|
|
var page = new MainPage { DataContext = vm };
|
2026-08-19 17:06:46 +01:00
|
|
|
|
var app = (PostIt.App)Application.Current!;
|
|
|
|
|
|
app.AttachMainWindow(window);
|
2026-08-18 23:24:08 +01:00
|
|
|
|
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
|
|
|
|
|
return (window, page);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 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.
|
2026-08-25 14:16:49 +01:00
|
|
|
|
/// KeyPressQwerty is dispatched on the <see cref="MainView"/>
|
2026-08-18 23:24:08 +01:00
|
|
|
|
/// itself — it is the <see cref="TopLevel"/> that owns the
|
|
|
|
|
|
/// headless implementation, and routing the key through any
|
|
|
|
|
|
/// descendant TopLevel (e.g. one obtained via
|
|
|
|
|
|
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
|
|
|
|
|
|
/// <c>NullReferenceException</c> from the headless impl
|
|
|
|
|
|
/// because the descendant does not carry the
|
|
|
|
|
|
/// <c>PlatformHandle</c> the harness expects.
|
|
|
|
|
|
/// </summary>
|
2026-08-25 14:16:49 +01:00
|
|
|
|
private static int ClickAndCapture(MainView window, Button button)
|
2026-08-18 23:24:08 +01:00
|
|
|
|
{
|
|
|
|
|
|
var stackBefore = window.NavRoot.NavigationStack.Count;
|
2026-08-19 17:06:46 +01:00
|
|
|
|
button.Command?.Execute(button.CommandParameter);
|
|
|
|
|
|
if (button.Command is IAsyncRelayCommand asyncCommand)
|
|
|
|
|
|
{
|
|
|
|
|
|
asyncCommand.ExecutionTask?.GetAwaiter().GetResult();
|
|
|
|
|
|
}
|
2026-08-18 23:24:08 +01:00
|
|
|
|
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<Page>(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<Page>(pushed);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[AvaloniaFact]
|
|
|
|
|
|
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
|
|
|
|
|
|
{
|
2026-08-19 17:01:58 +01:00
|
|
|
|
// 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
|
2026-08-19 17:06:46 +01:00
|
|
|
|
// DI and call App.PushPage; the ViewLocator
|
2026-08-19 17:01:58 +01:00
|
|
|
|
// then maps SignaturePageViewModel -> SignaturePage and
|
|
|
|
|
|
// the binding pushes the page.
|
2026-08-18 23:24:08 +01:00
|
|
|
|
var vm = MakeViewModel();
|
|
|
|
|
|
var (window, page) = MountMainPage(vm);
|
|
|
|
|
|
|
|
|
|
|
|
var signatureButton = page.OpenSignatureDevButton;
|
2026-08-19 17:01:58 +01:00
|
|
|
|
Assert.NotNull(signatureButton.Command);
|
|
|
|
|
|
Assert.True(signatureButton.Command.CanExecute(null));
|
2026-08-18 23:24:08 +01:00
|
|
|
|
|
|
|
|
|
|
// 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<Page>(pushed);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|