diff --git a/Directory.Packages.props b/Directory.Packages.props
index e4b09159..84380e44 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,6 +18,7 @@
+
diff --git a/contrib/Makefile b/contrib/Makefile
index 62e1e22d..151045db 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -1,4 +1,4 @@
-APP_PROJECT_NAMES=Api Org Blogs
+APP_PROJECT_NAMES=Org Blogs
SLNDIR=..
include $(SLNDIR)/.env
@@ -7,7 +7,6 @@ include .env
generated/:
@mkdir -p $@
-generated/yavscApi.service:
generated/yavscOrg.service:
generated/yavscBlogs.service:
@@ -34,12 +33,11 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@
-copy-services: copy-service-Org copy-service-Api copy-service-Blogs
+copy-services: copy-service-Org copy-service-Blogs
copy-service-Org: /etc/systemd/system/yavscOrg.service
-copy-service-Api: /etc/systemd/system/yavscApi.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
-copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-services
+copy-binaries: build_publish_Org build_publish_Blogs stop-services
@for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \
@@ -55,7 +53,7 @@ copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-serv
done
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR)
-/etc/systemd/system/yavsc%.service: generated/yavsc%.service
+/etc/systemd/system/yavsc%.service: generated/yavsc%.service
sudo cp $^ $@
sudo chown root:root $@
@@ -65,14 +63,14 @@ build_publish_%: clean_publish_dir_%
clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
-install: build_publish copy-binaries copy-services
+install: build_publish copy-binaries copy-services
@sudo systemctl daemon-reload
@for project in $(APP_PROJECT_NAMES); \
do \
sudo systemctl enable yavsc$${project} ; \
sudo systemctl start yavsc$${project} ; \
done
-
+
reinstall: copy-binaries
@sync
@for project in $(APP_PROJECT_NAMES); do \
@@ -86,13 +84,12 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
-$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
-showConfig:
+showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR)
clean:
@rm -rf generated
-.PHONY: build_publish mep showConfig copy-service-Api copy-service-Org copy-service-Blogs reinstall clean
+.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean
diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/Auth/BearerScopeTests.cs
similarity index 100%
rename from src/PostIt.Tests/BearerScopeTests.cs
rename to src/PostIt.Tests/Auth/BearerScopeTests.cs
diff --git a/src/PostIt.Tests/FakeAuthorizingBrowser.cs b/src/PostIt.Tests/Auth/FakeAuthorizingBrowser.cs
similarity index 100%
rename from src/PostIt.Tests/FakeAuthorizingBrowser.cs
rename to src/PostIt.Tests/Auth/FakeAuthorizingBrowser.cs
diff --git a/src/PostIt.Tests/OidcStubAuthority.cs b/src/PostIt.Tests/Auth/OidcStubAuthority.cs
similarity index 100%
rename from src/PostIt.Tests/OidcStubAuthority.cs
rename to src/PostIt.Tests/Auth/OidcStubAuthority.cs
diff --git a/src/PostIt.Tests/AddCircleMemberDialogTests.cs b/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs
similarity index 93%
rename from src/PostIt.Tests/AddCircleMemberDialogTests.cs
rename to src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs
index ab1f3a69..71b21278 100644
--- a/src/PostIt.Tests/AddCircleMemberDialogTests.cs
+++ b/src/PostIt.Tests/Blogs/AddCircleMemberDialogTests.cs
@@ -1,6 +1,4 @@
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
+
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
@@ -31,8 +29,15 @@ 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
@@ -73,7 +78,10 @@ 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/");
@@ -117,7 +125,7 @@ public class AddCircleMemberDialogTests
public void Close_button_pops_dialog_off_nav_stack()
{
// Arrange: stack starts at 2 (CirclesPage + dialog).
- var (window, _, _) = Mount();
+ var window = fixture.Window;
var stackBefore = window.NavRoot.NavigationStack.Count;
Assert.Equal(2, stackBefore);
diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/Blogs/BlogApiTestFakes.cs
similarity index 72%
rename from src/PostIt.Tests/BlogApiTestFakes.cs
rename to src/PostIt.Tests/Blogs/BlogApiTestFakes.cs
index 4b541e42..755d56ae 100644
--- a/src/PostIt.Tests/BlogApiTestFakes.cs
+++ b/src/PostIt.Tests/Blogs/BlogApiTestFakes.cs
@@ -64,3 +64,29 @@ 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/BlogPostAuthorDtoTests.cs b/src/PostIt.Tests/Blogs/BlogPostAuthorDtoTests.cs
similarity index 100%
rename from src/PostIt.Tests/BlogPostAuthorDtoTests.cs
rename to src/PostIt.Tests/Blogs/BlogPostAuthorDtoTests.cs
diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/Blogs/MainPageButtonsTests.cs
similarity index 57%
rename from src/PostIt.Tests/MainPageButtonsTests.cs
rename to src/PostIt.Tests/Blogs/MainPageButtonsTests.cs
index 767f9c2e..90e5b6fb 100644
--- a/src/PostIt.Tests/MainPageButtonsTests.cs
+++ b/src/PostIt.Tests/Blogs/MainPageButtonsTests.cs
@@ -1,6 +1,4 @@
-using Avalonia;
using Avalonia.Controls;
-using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
@@ -50,43 +48,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.
///
-public class MainPageButtonsTests
+[Collection("PostIt Headless")]
+public sealed 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
+ private readonly PostItHeadlessCollection _host;
+
+ public MainPageButtonsTests(PostItHeadlessCollection host)
{
- public ThrowingApi() : base(
- new Settings
- {
- Authentication = new AuthenticationSettings
- {
- Authority = "https://stub.invalid",
- ClientId = "stub",
- Scopes = new[] { "openid" },
- },
- },
- new TokenStore(System.IO.Path.GetTempFileName()))
- { }
+ _host = host;
}
- private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
+ ///
+ /// 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)
{
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);
@@ -96,51 +94,31 @@ public class MainPageButtonsTests
services.AddTransient();
services.AddTransient();
services.AddTransient();
- var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
+ var sp = services.BuildServiceProvider();
+
+ var vm = new MainPageViewModel(blog, services: sp);
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.
+ /// 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.
///
- private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
+ private MainPage MountAsync(MainPageViewModel vm)
{
- var window = new MainWindow();
var page = new MainPage { DataContext = vm };
- 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);
+ _host.PushAsync(page);
+ return 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.
+ /// Click a button by executing its
+ /// and draining any so the
+ /// caller can assert on the resulting nav stack immediately.
///
private static int ClickAndCapture(MainWindow window, Button button)
{
@@ -165,8 +143,8 @@ public class MainPageButtonsTests
Title = "An existing post",
AuthorId = "u-alice"
};
- var vm = MakeViewModel(post);
- var (window, page) = MountMainPage(vm);
+ var vm = BuildViewModel(post);
+ var page = MountAsync(vm);
// Sanity: the button's command is bound and CanExecute
// is true. If this fails, the bug is upstream (XAML
@@ -176,12 +154,12 @@ public class MainPageButtonsTests
Assert.True(aclButton.Command.CanExecute(null));
// Act
- var stackBefore = ClickAndCapture(window, aclButton);
+ var stackBefore = ClickAndCapture(_host.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.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.NotNull(pushed);
Assert.IsAssignableFrom(pushed);
}
@@ -191,19 +169,19 @@ public class MainPageButtonsTests
{
// 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 vm = BuildViewModel();
+ var page = MountAsync(vm);
var circlesButton = page.OpenCirclesButton;
Assert.NotNull(circlesButton.Command);
// Act
- var stackBefore = ClickAndCapture(window, circlesButton);
+ var stackBefore = ClickAndCapture(_host.Window, circlesButton);
// Assert
- Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
+ Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
"Click on 'Mes cercles' must push a new page onto the nav stack.");
- var pushed = window.NavRoot.NavigationStack.Last();
+ var pushed = _host.Window.NavRoot.NavigationStack[^1];
Assert.NotNull(pushed);
Assert.IsAssignableFrom(pushed);
}
@@ -214,25 +192,25 @@ public 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 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);
+ // 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);
var signatureButton = page.OpenSignatureDevButton;
Assert.NotNull(signatureButton.Command);
Assert.True(signatureButton.Command.CanExecute(null));
// Act
- var stackBefore = ClickAndCapture(window, signatureButton);
+ var stackBefore = ClickAndCapture(_host.Window, signatureButton);
// Assert
- Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
+ Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
- var pushed = window.NavRoot.NavigationStack.Last();
+ var pushed = _host.Window.NavRoot.NavigationStack[^1];
Assert.NotNull(pushed);
Assert.IsAssignableFrom(pushed);
}
diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/Blogs/MainPageSaveTests.cs
similarity index 62%
rename from src/PostIt.Tests/MainPageSaveTests.cs
rename to src/PostIt.Tests/Blogs/MainPageSaveTests.cs
index b6bf963a..918b40de 100644
--- a/src/PostIt.Tests/MainPageSaveTests.cs
+++ b/src/PostIt.Tests/Blogs/MainPageSaveTests.cs
@@ -1,22 +1,22 @@
-using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
-using Yavsc.Blogspot;
-using Yavsc.Api.Client;
+using Microsoft.Extensions.DependencyInjection;
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 .
-/// 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.
+/// Uses the shared (a real
+/// with the production DI graph attached
+/// to ) plus a local
+/// that swaps
+/// for the recording fake.
///
/// The bug we are pinning: the title TextBox is
/// currently {Binding SelectedPost.Title, Mode=TwoWay}.
@@ -31,41 +31,38 @@ namespace PostIt.Tests;
/// pass once the VM owns a dedicated Title/Article
/// buffer that the XAML binds to and the Save command consumes.
///
-public class MainPageSaveTests
+[Collection("PostIt Headless")]
+public sealed class MainPageSaveTests
{
- [AvaloniaFact]
- public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
- {
- // 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 api = new RecordingYavscApiClient(recorder);
- var blog = new BlogApiClient(api, "http://localhost/");
- var viewModel = new MainPageViewModel(blog);
+ 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()
+ {
+ // Arrange: VM with a recording API client, mounted on
+ // the shared MainWindow's nav stack.
+ var recorder = new CallRecorder();
+
+ var blog = _host.Services.GetRequiredService();
+ var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel };
- // 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();
+ _host.PushAsync(page);
// 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 = 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 = _host.Window.GetVisualDescendants()
.OfType()
.First(t => t.PlaceholderText == "Title");
const string typed = "Mon premier billet";
titleBox.Text = typed;
- var saveButton = window.GetVisualDescendants()
+ var saveButton = _host.Window.GetVisualDescendants()
.OfType