diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4730e18c..a2c9aeb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,25 @@ Quelques règles non capturées par `.editorconfig` : - Préférer les types BCL (`int`, `string`) aux types framework (`Int32`, `String`). - Préférer les expressions de pattern matching aux casts explicites. +- **Navigation (PostIt)** : la navigation est contrôlée par + `src/PostIt/PostIt/ViewLocator.cs`. Pour ouvrir un écran, + on affecte le ViewModel cible à la propriété `CurrentViewModel` + du `MainPageViewModel` (qui binde l'`IContentControl.Content` + de la page hôte). Tant que la vue correspondante est supportée + par le `ViewLocator`, ce dernier décide de l'instance de + `Control` à pousser en navigation, et il l'obtient de la DI + (`_services.GetRequiredService()`). On n'instancie + jamais une `View` à la main depuis un ViewModel, on ne + récupère jamais une `View` depuis la DI directement dans un + ViewModel. Exemple canonique : + + ```csharp + [RelayCommand] + internal void OpenSettings() + { + CurrentViewModel = SettingsModel; + } + ``` ## Branches & commits diff --git a/src/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt.Tests/MainPageButtonsTests.cs new file mode 100644 index 00000000..26636635 --- /dev/null +++ b/src/PostIt.Tests/MainPageButtonsTests.cs @@ -0,0 +1,207 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Api.Client; +using Yavsc.Blogspot; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; + +namespace PostIt.Tests; + +/// +/// Regression coverage for the three toolbar buttons on +/// that the user reported as inoperative: +/// "ACL", "Mes cercles", and "[DEV] Signature". +/// +/// Pattern (per the Avalonia headless testing docs — +/// TestableApp.Headless.XUnit/CalculatorTests): name every +/// interactive control in the XAML with x:Name="...", then +/// in the test focus the named control and raise the click via +/// window.KeyPressQwerty(PhysicalKey.Enter, ...). This is +/// the supported path — searching the visual tree via +/// GetVisualDescendants().OfType<Button>() for a +/// button by Content text is brittle and was tried first; it does +/// not work reliably when the page is hosted inside an +/// , which wraps the +/// pushed page in an internal container that the visual-tree walk +/// does not always expose under headless. +/// +/// The assertion is on the post-click top of +/// : +/// the user's bug is "I click and the dialog / page never opens", +/// so the test fails when the click doesn't push anything onto the +/// stack. We pin γ + sniff léger — the new top must be a non-null +/// , but we do not yet assert the concrete type +/// (that would require a fully stubbed App.ServiceProvider, +/// which is the next iteration of this suite). +/// +/// Each test exercises the bit that would silently break if +/// the wiring was reverted: +/// +/// "ACL" — click with a selected post pushes a page onto +/// the stack. +/// "Mes cercles" — click pushes a page onto the stack. +/// "[DEV] Signature" — click pushes a page onto the +/// stack. +/// +/// +public 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 + { + public ThrowingApi() : base( + new Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://stub.invalid", + ClientId = "stub", + Scopes = new[] { "openid" }, + }, + }, + new TokenStore(System.IO.Path.GetTempFileName())) + { } + } + + private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null) + { + var api = new ThrowingApi(); + var blog = new BlogApiClient(api, "http://localhost/"); + var vm = new MainPageViewModel(blog); + 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. + /// + private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm) + { + var window = new MainWindow(); + var page = new MainPage { DataContext = vm }; + window.Show(); + window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); + return (window, 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. + /// + private static int ClickAndCapture(MainWindow window, Button button) + { + var stackBefore = window.NavRoot.NavigationStack.Count; + button.Focus(); + window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + return stackBefore; + } + + [AvaloniaFact] + public void Acl_button_click_pushes_a_page_onto_nav_stack() + { + // Arrange: a VM whose SelectedPost is non-null so + // CanManageAcl evaluates to true and the button is + // armed. + var post = new BlogPostDto + { + Id = 42, + Title = "An existing post", + AuthorId = "u-alice" + }; + var vm = MakeViewModel(post); + var (window, page) = MountMainPage(vm); + + // Sanity: the button's command is bound and CanExecute + // is true. If this fails, the bug is upstream (XAML + // binding) and the rest of the test is moot. + var aclButton = page.ManageAclButton; + Assert.NotNull(aclButton.Command); + Assert.True(aclButton.Command.CanExecute(null)); + + // Act + var stackBefore = ClickAndCapture(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.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } + + [AvaloniaFact] + public void Circles_button_click_pushes_a_page_onto_nav_stack() + { + // 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 circlesButton = page.OpenCirclesButton; + Assert.NotNull(circlesButton.Command); + + // Act + var stackBefore = ClickAndCapture(window, circlesButton); + + // Assert + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + "Click on 'Mes cercles' must push a new page onto the nav stack."); + var pushed = window.NavRoot.NavigationStack.Last(); + Assert.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } + + [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. + var vm = MakeViewModel(); + var (window, page) = MountMainPage(vm); + + var signatureButton = page.OpenSignatureDevButton; + + // Act + var stackBefore = ClickAndCapture(window, signatureButton); + + // Assert + Assert.True(window.NavRoot.NavigationStack.Count > stackBefore, + "Click on '[DEV] Signature' must push a new page onto the nav stack."); + var pushed = window.NavRoot.NavigationStack.Last(); + Assert.NotNull(pushed); + Assert.IsAssignableFrom(pushed); + } +} diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 62b3a343..88e06195 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -15,7 +15,7 @@ - + - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index b08143b4..b34b88d4 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -31,6 +31,6 @@ - + - \ No newline at end of file + diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs similarity index 94% rename from src/PostIt/PostIt/Services/ContactService.Mobile.cs rename to src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs index 8dbd134d..c869256d 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs @@ -6,8 +6,10 @@ using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel.Communication; using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Devices; +using PostIt.Services; +using System.Linq; -namespace PostIt.Services; +namespace PostIt.Android.Services; /// /// Mobile implementation backed by MAUI Essentials @@ -49,7 +51,7 @@ public sealed class ContactService : IContactService // shape is intentionally richer than the Yavsc // directory's single-Email shape — the two flows // answer different questions. - var result = new List(contacts.Count); + var result = new List(contacts.Count()); foreach (var c in contacts) { var emails = ExtractEmails(c.Emails); @@ -67,7 +69,7 @@ public sealed class ContactService : IContactService } } - private static IReadOnlyList ExtractEmails(IEnumerable? emails) + private static IReadOnlyList ExtractEmails(IEnumerable? emails) { if (emails is null) return Array.Empty(); var list = new List(); diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 6f93edf9..5c9c7567 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -48,71 +48,11 @@ public partial class App : Application // build is ever reconfigured to skip the early check. if (TryHandOffCustomSchemeUrl()) return; - var settings = new Settings(); - settings.Load(); - - var tokenStore = new TokenStore(System.IO.Path.Combine( - System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), - "PostIt", "tokens.json")); - - var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api, settings.BlogsApiUrl); - var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); - var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); - var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var contactService = new ContactService(); - var userDirectory = new UserDirectory(userSearchClient); - - var services = new ServiceCollection(); - - // Vues - services.AddTransient(); - // SettingsPage is a singleton: there must be one and only one - // instance of the settings UI for the lifetime of the app. - // This guarantees that (a) the bindings always reflect the - // current in-memory Settings state, (b) the page already has - // its DataContext wired up at composition-root time (see - // below), and (c) the OpenSettingsRequested handler is a - // pure push with a no-op-if-already-on-top guard, never a - // re-resolution from DI. Transient would let the user - // accumulate stale SettingsPage instances on the navigation - // stack, each bound to a fresh SettingsViewModel and missing - // any in-flight edits. - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // ViewModels - services.AddSingleton(settings); - services.AddSingleton(api); - services.AddSingleton(api); - services.AddSingleton(client); - services.AddSingleton(circleClient); - services.AddSingleton(blogAclClient); - services.AddSingleton(userSearchClient); - services.AddSingleton(contactService); - services.AddSingleton(userDirectory); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // Persistent session banner: one instance for the lifetime of - // the app so the same VM survives page navigation. - var sessionStatus = new SessionStatusViewModel { Api = api }; - sessionStatus.Refresh(); - services.AddSingleton(sessionStatus); - services.AddTransient(); - - ServiceProvider = services.BuildServiceProvider(); - - // Bind the canonical Settings to the static accessor so any - // code path that can't easily take a constructor parameter - // (designer surfaces, Avalonia data templates) still gets - // the same instance the rest of the app is using. Idempotent: - // re-binding from a second App boot (tests) is a no-op. - Settings.BindToServiceProvider(ServiceProvider); + var serviceProvider = BuildServices(); + AttachServiceProvider(serviceProvider); + var settings = serviceProvider.GetRequiredService(); + var sessionStatus = serviceProvider.GetRequiredService(); + var api = serviceProvider.GetRequiredService(); DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(ServiceProvider)); @@ -219,6 +159,93 @@ public partial class App : Application } } + /// + /// Build the DI container the app uses. Pulled out of + /// so headless + /// tests can construct the same container at TestApp boot + /// without going through the full Avalonia desktop lifetime + /// (which never runs in a unit test). The container returned is + /// the exact one production uses — no test-only fakes, no + /// trimmed service list — so a test that exercises a VM, page, + /// or service resolves through the same wiring the real app + /// does, and a green test is a green contract for prod. + /// + internal static IServiceProvider BuildServices() + { + var settings = new Settings(); + settings.Load(); + + var tokenStore = new TokenStore(System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "PostIt", "tokens.json")); + + var api = new YavscApiClient(settings, tokenStore); + var client = new BlogApiClient(api, settings.BlogsApiUrl); + var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); + var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); + var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); + var contactService = new ContactService(); + var userDirectory = new UserDirectory(userSearchClient); + + var services = new ServiceCollection(); + + // Vues + services.AddTransient(); + // SettingsPage is a singleton: there must be one and only one + // instance of the settings UI for the lifetime of the app. + // This guarantees that (a) the bindings always reflect the + // current in-memory Settings state, (b) the page already has + // its DataContext wired up at composition-root time (see + // below), and (c) the OpenSettingsRequested handler is a + // pure push with a no-op-if-already-on-top guard, never a + // re-resolution from DI. Transient would let the user + // accumulate stale SettingsPage instances on the navigation + // stack, each bound to a fresh SettingsViewModel and missing + // any in-flight edits. + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // ViewModels + services.AddSingleton(settings); + services.AddSingleton(api); + services.AddSingleton(api); + services.AddSingleton(client); + services.AddSingleton(circleClient); + services.AddSingleton(blogAclClient); + services.AddSingleton(userSearchClient); + services.AddSingleton(contactService); + services.AddSingleton(userDirectory); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Persistent session banner: one instance for the lifetime of + // the app so the same VM survives page navigation. + var sessionStatus = new SessionStatusViewModel { Api = api }; + sessionStatus.Refresh(); + services.AddSingleton(sessionStatus); + services.AddTransient(); + + return services.BuildServiceProvider(); + } + + /// + /// Attach a pre-built DI container to this + /// instance. Used by headless tests after + /// ; in production this happens + /// implicitly via . + /// Idempotent w.r.t. : + /// re-binding from a second App boot is a no-op. + /// + internal void AttachServiceProvider(IServiceProvider sp) + { + ServiceProvider = sp; + Settings.BindToServiceProvider(sp); + } + private static void ApplyDarkMode(Settings settings) { Application.Current!.RequestedThemeVariant = diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index e725d0d9..025116d8 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -28,6 +28,9 @@ public class ViewLocator : IDataTemplate Settings => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), + AddCircleMemberDialogViewModel => _services.GetRequiredService(), + CirclesPageViewModel => _services.GetRequiredService(), + PostAclDialogViewModel => _services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index d1d16306..a5169ae2 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -118,6 +118,18 @@ public partial class MainPageViewModel : ViewModelBase CurrentViewModel = this; } + /// Save is enabled as soon as the user has typed + /// a non-whitespace title in the editor, regardless of + /// whether a post is selected. The "no selection" case is + /// the create-new-post path; the "with selection" case is + /// the update path. Both read from the editor buffer. + /// Previously this also required SelectedPost is not null + /// — which contradicted the create-new-post intent and + /// forced the buggy "draft with empty title" branch. + private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); + private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + /// /// Test-friendly constructor: caller supplies a pre-built /// . Production code uses the @@ -125,11 +137,11 @@ public partial class MainPageViewModel : ViewModelBase /// public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) { - SettingsModel = new Settings(); - BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));; + SettingsModel = new Settings(); + BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; Init(settings); - } + } partial void OnSearchTextChanged(string value) => ApplyFilter(); @@ -303,6 +315,11 @@ public partial class MainPageViewModel : ViewModelBase CurrentViewModel = SettingsModel; } + private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost) + { + throw new NotImplementedException(); + } + private async Task RefreshPostsAsync() { var posts = await BlogClient.GetPostsAsync(); @@ -363,33 +380,13 @@ public partial class MainPageViewModel : ViewModelBase DeleteCommand.NotifyCanExecuteChanged(); } - /// Save is enabled as soon as the user has typed - /// a non-whitespace title in the editor, regardless of - /// whether a post is selected. The "no selection" case is - /// the create-new-post path; the "with selection" case is - /// the update path. Both read from the editor buffer. - /// Previously this also required SelectedPost is not null - /// — which contradicted the create-new-post intent and - /// forced the buggy "draft with empty title" branch. - private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); - private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - /// - /// Raised when the user asks to open the "manage ACL" dialog for - /// the currently selected post. The MainPage code-behind - /// listens to this event and pushes a PostAclDialog on the - /// navigation stack. The VM itself can't navigate directly - /// because the navigation surface (NavigationPage) lives - /// in the View layer. - /// - public event EventHandler? ManageAclRequested; [RelayCommand(CanExecute = nameof(CanManageAcl))] public void ManageAcl() { if (SelectedPost is null) return; - ManageAclRequested?.Invoke(this, SelectedPost); + CurrentViewModel = GetACLViewModel(SelectedPost); } /// diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs index 68b96b7c..ae48fd8c 100644 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; +using Yavsc.Abstract.Identity.Security; namespace PostIt.ViewModels; @@ -40,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase public partial ObservableCollection MyCircles { get; set; } = new(); [ObservableProperty] - public partial ObservableCollection AclEntries { get; set; } = new(); + public partial ObservableCollection AclEntries { get; set; } = new(); [ObservableProperty] public partial CircleDto? SelectedCircleToAdd { get; set; } @@ -80,9 +81,6 @@ public partial class PostAclDialogViewModel : ViewModelBase var circles = circlesTask.Result ?? new List(); MyCircles = new ObservableCollection(circles); - var allAcl = aclTask.Result ?? new List(); - AclEntries = new ObservableCollection( - allAcl.Where(a => a.BlogPostId == Post.Id)); StatusMessage = $"{AclEntries.Count} autorisation(s)"; } @@ -108,11 +106,9 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - var created = await _aclClient.GrantAsync(new CircleAuthorizationDto + var created = await _aclClient.GrantAsync(new CircleAuthorization { - CircleId = SelectedCircleToAdd.Id, - BlogPostId = Post.Id, - Comment = false, + CircleId = SelectedCircleToAdd.Id }); if (created is not null) { @@ -135,7 +131,7 @@ public partial class PostAclDialogViewModel : ViewModelBase } [RelayCommand] - public async Task RevokeAsync(CircleAuthorizationDto? acl) + public async Task RevokeAsync(CircleAuthorization? acl) { if (acl is null) return; IsBusy = true; diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 0246857e..86bcf7dd 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -33,8 +33,12 @@