diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs index 26636635..767f9c2e 100644 --- a/src/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt.Tests/MainPageButtonsTests.cs @@ -5,6 +5,7 @@ using Avalonia.Headless.XUnit; using Avalonia.Input; using Avalonia.Interactivity; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using Yavsc.Api.Client; using Yavsc.Blogspot; using PostIt.Services; @@ -78,7 +79,24 @@ public class MainPageButtonsTests { var api = new ThrowingApi(); var blog = new BlogApiClient(api, "http://localhost/"); - var vm = new MainPageViewModel(blog); + 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 MainPageViewModel(blog, services: services.BuildServiceProvider()); if (selectedPost is not null) vm.SelectedPost = selectedPost; return vm; } @@ -98,6 +116,13 @@ public class MainPageButtonsTests { 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); @@ -120,8 +145,11 @@ public class MainPageButtonsTests private static int ClickAndCapture(MainWindow window, Button button) { var stackBefore = window.NavRoot.NavigationStack.Count; - button.Focus(); - window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + button.Command?.Execute(button.CommandParameter); + if (button.Command is IAsyncRelayCommand asyncCommand) + { + asyncCommand.ExecutionTask?.GetAwaiter().GetResult(); + } return stackBefore; } @@ -183,16 +211,20 @@ public class MainPageButtonsTests [AvaloniaFact] public void Signature_dev_button_click_pushes_a_page_onto_nav_stack() { - // Arrange: the "[DEV] Signature" button uses XAML's - // Click="OpenSignatureDev" attribute, so we don't bind - // a Command here — we drive the click directly. The - // handler resolves App.ServiceProvider, which is null - // in a unit test, and early-returns; that is the - // failure mode the test pins. + // 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); diff --git a/src/PostIt/PostIt/App.axaml b/src/PostIt/PostIt/App.axaml index b179024a..92497b74 100644 --- a/src/PostIt/PostIt/App.axaml +++ b/src/PostIt/PostIt/App.axaml @@ -2,10 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:PostIt" x:Class="PostIt.App"> - - - - + + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 5c9c7567..ef35c875 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Avalonia; @@ -48,11 +49,11 @@ public partial class App : Application // build is ever reconfigured to skip the early check. if (TryHandOffCustomSchemeUrl()) return; - var serviceProvider = BuildServices(); - AttachServiceProvider(serviceProvider); - var settings = serviceProvider.GetRequiredService(); - var sessionStatus = serviceProvider.GetRequiredService(); - var api = serviceProvider.GetRequiredService(); + this.ServiceProvider = BuildServices(); + AttachServiceProvider(ServiceProvider); + var settings = ServiceProvider.GetRequiredService(); + var sessionStatus = ServiceProvider.GetRequiredService(); + var api = ServiceProvider.GetRequiredService(); DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(ServiceProvider)); @@ -86,8 +87,7 @@ public partial class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - var homePage = ServiceProvider.GetRequiredService(); - homePage.DataContext = ServiceProvider.GetRequiredService(); + var homeVm = ServiceProvider.GetRequiredService(); window = new MainWindow(); window.SessionBanner.DataContext = sessionStatus; @@ -95,9 +95,8 @@ public partial class App : Application // Build the navigation stack from scratch: HomePage is the // root in both cases. App.BootAsync will push MainPage on // top if the silent refresh succeeds. - window.DataContext = homePage.DataContext; desktop.MainWindow = window; - _ = window.NavRoot.PushAsync(homePage); + _ = PushPageAsync(homeVm); // When the user logs out, route back to HomePage. We // ReplaceAsync the current top so we don't grow the stack @@ -107,8 +106,6 @@ public partial class App : Application { var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var nav = w.NavRoot; - var hp = ServiceProvider.GetRequiredService(); - hp.DataContext = ServiceProvider.GetRequiredService(); _ = nav.PopToRootAsync(); }; @@ -120,35 +117,7 @@ public partial class App : Application _ = PushMainPageAsync(); }; - // When the user clicks the "Paramètres" button on the - // session banner, push the SettingsPage singleton on top - // of the current navigation stack. The DataContext is - // already wired at composition time (see the - // provider.GetRequiredService().DataContext - // assignment above), so this handler is a pure - // navigation concern. - // - // Anti-empilement guard: if the SettingsPage is already - // at the top of the stack, do nothing. NavigationPage's - // PushAsync does not deduplicate; calling it twice with - // the same instance would push it a second time and the - // user would have to tap Back twice to leave. Reference - // comparison is correct here because SettingsPage is a - // singleton — there is exactly one instance to compare - // against. - sessionStatus.OpenSettingsRequested += () => - { - var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; - var settingsPage = ServiceProvider.GetRequiredService(); - var stack = w.NavRoot.NavigationStack; - if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage)) - { - return; - } - _ = w.NavRoot.PushAsync(settingsPage); - }; - - window.Opened += async (_, _) => await BootAsync(ServiceProvider, api); + window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { @@ -246,6 +215,17 @@ public partial class App : Application Settings.BindToServiceProvider(sp); } + /// + /// Test-only hook: bind a concrete so + /// command-driven navigation paths () can + /// push onto a real in headless + /// fixtures that do not run the full desktop lifetime bootstrap. + /// + internal void AttachMainWindow(MainWindow mainWindow) + { + window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); + } + private static void ApplyDarkMode(Settings settings) { Application.Current!.RequestedThemeVariant = @@ -273,19 +253,18 @@ public partial class App : Application } /// - /// Resolve a fresh MainPage + VM from DI and push it on top + /// Resolve a fresh MainPageViewModel from DI and push its + /// mapped page (via ) on top /// of the current navigation stack. Used both by /// (silent refresh at boot) and by SessionStatusViewModel.LoginSucceeded /// (interactive login from the banner). Pulled out as a helper so /// the two callers can't drift apart. /// - public static async Task PushMainPageAsync() + public static Task PushMainPageAsync() { var app = (App)Current; var mainVm = app.ServiceProvider.GetRequiredService(); - var mainPage = app.ServiceProvider.GetRequiredService(); - mainPage.DataContext = mainVm; - await app.window.FindControl("NavRoot").PushAsync(mainPage).ConfigureAwait(true); + return app.PushPageAsync(mainVm); } private bool TryHandOffCustomSchemeUrl() @@ -320,4 +299,46 @@ public partial class App : Application return true; } + internal void PushPage(ViewModelBase vm) + { + _ = PushPageAsync(vm); + } + + internal Task PushPageAsync(ViewModelBase vm) + { + if (window is null) + { + throw new InvalidOperationException("MainWindow is not initialized yet."); + } + + var template = DataTemplates.FirstOrDefault(t => t.Match(vm)); + if (template is null) + { + throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}."); + } + + var view = template.Build(vm); + if (view is null) + { + throw new InvalidOperationException( + $"Template for {vm.GetType().Name} returned ."); + } + + if (view is not Page page) + { + throw new InvalidOperationException( + $"Template for {vm.GetType().Name} returned {view.GetType().Name}, expected a Page."); + } + + page.DataContext = vm; + + // Avoid stacking the same singleton page twice (e.g. SettingsPage). + var stack = window.NavRoot.NavigationStack; + if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) + { + return Task.CompletedTask; + } + + return window.NavRoot.PushAsync(page); + } } diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index a5169ae2..e3a541ea 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -2,11 +2,14 @@ using System; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; +using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; using PostIt.Services; +using PostIt.Views; namespace PostIt.ViewModels; @@ -47,9 +50,6 @@ public partial class MainPageViewModel : ViewModelBase [ObservableProperty] public partial bool DraftIsPublished { get; set; } - [ObservableProperty] - public partial ViewModelBase? CurrentViewModel { get; set; } - public Settings SettingsModel { get; } [ObservableProperty] @@ -81,9 +81,46 @@ public partial class MainPageViewModel : ViewModelBase /// public BlogApiClient? BlogClient { get; } + /// + /// DI container the VM uses to resolve navigation targets + /// (other ViewModels) when the user clicks a toolbar button + /// that opens a sub-screen. Owned by App.ServiceProvider + /// in production; injected directly in tests. The VM resolves + /// ViewModels via this provider, never Views — the + /// actual to push is decided by + /// at bind time, per CONTRIBUTING.md + /// §"Navigation (PostIt)". + /// + public IServiceProvider? Services { get; } + + private SignaturePageViewModel? _signatureModel; + + /// + /// Resolved on first access. Lazy so the test path (which + /// never pushes SignaturePage) does not require a + /// fully-built DI graph just to construct the VM. Mirrors the + /// pattern of for the Settings case. + /// + public SignaturePageViewModel SignatureModel => + _signatureModel ??= ResolveSignatureModel(); + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + private SignaturePageViewModel ResolveSignatureModel() + { + var sp = ResolveServices(); + return sp.GetRequiredService(); + } + + private IServiceProvider ResolveServices() + { + return Services ?? (Application.Current as App)?.ServiceProvider ?? + throw new InvalidOperationException( + "No IServiceProvider available for navigation. Inject one in tests " + + "or ensure App.ServiceProvider is initialized in production."); + } + public MainPageViewModel() { @@ -115,7 +152,6 @@ public partial class MainPageViewModel : ViewModelBase DraftTitle = string.Empty; DraftArticle = string.Empty; DraftIsPublished = false; - CurrentViewModel = this; } /// Save is enabled as soon as the user has typed @@ -135,10 +171,11 @@ public partial class MainPageViewModel : ViewModelBase /// . Production code uses the /// (Settings, BlogApiClient) overload below. /// - public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) + public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) { SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; + Services = services; Init(settings); } @@ -309,15 +346,30 @@ public partial class MainPageViewModel : ViewModelBase }); } + /// + /// DEV ONLY: open the signature capture page. The production + /// entry point is a SignalR push from Yavsc.Org ("devis + /// received, sign here"); this command is the dev-time + /// shortcut to reach the page without that infrastructure. + /// Aligned on the same VM-first navigation pattern as + /// : the VM resolves the target VM + /// through , the ViewLocator picks + /// the matching Control at bind time. No + /// Click handler, no App.ServiceProvider + /// access from the view layer. + /// [RelayCommand] - internal void OpenSettings() + internal async Task OpenSignatureDev() { - CurrentViewModel = SettingsModel; + await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); } - private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost) + private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) { - throw new NotImplementedException(); + var sp = ResolveServices(); + var aclClient = sp.GetRequiredService(); + var circleClient = sp.GetRequiredService(); + return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); } private async Task RefreshPostsAsync() @@ -383,19 +435,16 @@ public partial class MainPageViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanManageAcl))] - public void ManageAcl() + public async Task ManageAcl() { if (SelectedPost is null) return; - CurrentViewModel = GetACLViewModel(SelectedPost); + await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); } - /// - /// Raised when the user asks to open the circles page (full - /// CRUD on their own circles). Same routing as - /// . - /// - public event EventHandler? OpenCirclesRequested; - [RelayCommand] - public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); + public async Task OpenCircles() + { + var circlesVm = ResolveServices().GetRequiredService(); + await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); + } } diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs index 55f2cab4..a1ad48ce 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -2,6 +2,7 @@ using System; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using PostIt.Services; namespace PostIt.ViewModels; @@ -32,15 +33,6 @@ public partial class SessionStatusViewModel : ViewModelBase /// HomePage so the user lands on the blog editor. public event System.Action? LoginSucceeded; - /// Raised when the user clicks the "Paramètres" button on - /// the session banner. App.axaml.cs listens and pushes - /// SettingsPage (resolved from DI, bound to the canonical - /// Settings singleton) on top of the current navigation - /// stack. Same event pattern as and - /// so the VM stays decoupled from - /// NavigationPage / window lifetime. - public event System.Action? OpenSettingsRequested; - [ObservableProperty] public partial bool IsLoggedIn { get; private set; } @@ -144,9 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase } [RelayCommand] - public async System.Threading.Tasks.Task OpenSettingsCommand() + internal async Task OpenSettings() { - OpenSettingsRequested?.Invoke(); - await System.Threading.Tasks.Task.CompletedTask; + var app = (App)App.Current!; + await app.PushPageAsync(app.ServiceProvider.GetRequiredService()).ConfigureAwait(true); } + } diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 86bcf7dd..e30d1a84 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -59,8 +59,8 @@ MainPage.axaml.cs once the SignalR handler lands. -->