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