diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs index 5ab27055..767f9c2e 100644 --- a/src/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt.Tests/MainPageButtonsTests.cs @@ -79,17 +79,23 @@ public class MainPageButtonsTests { 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 (for the [DEV] Signature toolbar - // shortcut). Anything the SignaturePage or its VM touch - // transitively must be registered here too — the test - // refuses to share App.BuildServices() because that one - // constructs a real YavscApiClient pointing at the host's - // token store, which is exactly the noise we want out of - // a UI-driving test. + // 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; @@ -110,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); @@ -132,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; } @@ -200,7 +216,7 @@ public class MainPageButtonsTests // 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 assign it to CurrentViewModel; the ViewLocator + // DI and call App.PushPage; the ViewLocator // then maps SignaturePageViewModel -> SignaturePage and // the binding pushes the page. var vm = MakeViewModel(); diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 17c74224..ef35c875 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -87,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; @@ -96,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 @@ -108,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(); }; @@ -121,34 +117,6 @@ 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(this.ServiceProvider, api); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) @@ -247,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 = @@ -274,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() @@ -322,6 +300,11 @@ public partial class App : Application } internal void PushPage(ViewModelBase vm) + { + _ = PushPageAsync(vm); + } + + internal Task PushPageAsync(ViewModelBase vm) { if (window is null) { @@ -353,9 +336,9 @@ public partial class App : Application var stack = window.NavRoot.NavigationStack; if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) { - return; + return Task.CompletedTask; } - _ = window.NavRoot.PushAsync(page); + return window.NavRoot.PushAsync(page); } } diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index fb2be9fd..e3a541ea 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -109,18 +109,18 @@ public partial class MainPageViewModel : ViewModelBase private SignaturePageViewModel ResolveSignatureModel() { - var sp = Services ?? (Application.Current as App)?.ServiceProvider; - if (sp is null) - { - throw new InvalidOperationException( - "Cannot resolve SignaturePageViewModel: no IServiceProvider " + - "was injected and App.ServiceProvider is null. This is a " + - "test-time wiring bug — the test must construct an " + - "IServiceProvider that registers SignaturePageViewModel."); - } + 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() { @@ -351,7 +351,7 @@ public partial class MainPageViewModel : ViewModelBase /// 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 nav-via-CurrentViewModel pattern as + /// 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 @@ -359,14 +359,17 @@ public partial class MainPageViewModel : ViewModelBase /// access from the view layer. /// [RelayCommand] - internal void OpenSignatureDev() + internal async Task OpenSignatureDev() { - ((App)App.Current).PushPage(SignatureModel); + 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() @@ -432,19 +435,16 @@ public partial class MainPageViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanManageAcl))] - public void ManageAcl() + public async Task ManageAcl() { if (SelectedPost is null) return; - ((App)App.Current).PushPage(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 a5ab6cfe..a1ad48ce 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -33,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; } @@ -145,10 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase } [RelayCommand] - internal void OpenSettings() + internal async Task OpenSettings() { - var app = (App)App.Current; - app.PushPage(app.ServiceProvider.GetRequiredService()); + var app = (App)App.Current!; + await app.PushPageAsync(app.ServiceProvider.GetRequiredService()).ConfigureAwait(true); } }