diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs index 26636635..5ab27055 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,18 @@ public class MainPageButtonsTests { var api = new ThrowingApi(); var blog = new BlogApiClient(api, "http://localhost/"); - var vm = new MainPageViewModel(blog); + // 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. + var services = new ServiceCollection(); + services.AddTransient(); + var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider()); if (selectedPost is not null) vm.SelectedPost = selectedPost; return vm; } @@ -183,16 +195,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 assign it to CurrentViewModel; 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..17c74224 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)); @@ -148,7 +149,7 @@ public partial class App : Application _ = 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) { @@ -320,4 +321,41 @@ public partial class App : Application return true; } + internal void PushPage(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; + } + + _ = window.NavRoot.PushAsync(page); + } } diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index a5169ae2..fb2be9fd 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 = 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."); + } + return sp.GetRequiredService(); + } + 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,10 +346,22 @@ 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 nav-via-CurrentViewModel 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 void OpenSignatureDev() { - CurrentViewModel = SettingsModel; + ((App)App.Current).PushPage(SignatureModel); } private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost) @@ -386,7 +435,7 @@ public partial class MainPageViewModel : ViewModelBase public void ManageAcl() { if (SelectedPost is null) return; - CurrentViewModel = GetACLViewModel(SelectedPost); + ((App)App.Current).PushPage(GetACLViewModel(SelectedPost)); } /// diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs index 55f2cab4..a5ab6cfe 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; @@ -144,9 +145,10 @@ public partial class SessionStatusViewModel : ViewModelBase } [RelayCommand] - public async System.Threading.Tasks.Task OpenSettingsCommand() + internal void OpenSettings() { - OpenSettingsRequested?.Invoke(); - await System.Threading.Tasks.Task.CompletedTask; + var app = (App)App.Current; + app.PushPage(app.ServiceProvider.GetRequiredService()); } + } 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. -->