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