diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2c9aeb1..4730e18c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,25 +64,6 @@ 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 deleted file mode 100644 index 26636635..00000000 --- a/src/PostIt.Tests/MainPageButtonsTests.cs +++ /dev/null @@ -1,207 +0,0 @@ -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 88e06195..62b3a343 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 b34b88d4..b08143b4 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/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 5c9c7567..6f93edf9 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -48,11 +48,71 @@ 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(); + 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); DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(ServiceProvider)); @@ -159,93 +219,6 @@ 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.Android/Services/ContactService.Mobile.cs b/src/PostIt/PostIt/Services/ContactService.Mobile.cs similarity index 94% rename from src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs rename to src/PostIt/PostIt/Services/ContactService.Mobile.cs index c869256d..8dbd134d 100644 --- a/src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt/Services/ContactService.Mobile.cs @@ -6,10 +6,8 @@ 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.Android.Services; +namespace PostIt.Services; /// /// Mobile implementation backed by MAUI Essentials @@ -51,7 +49,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); @@ -69,7 +67,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/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 025116d8..e725d0d9 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -28,9 +28,6 @@ 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 a5169ae2..d1d16306 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -118,18 +118,6 @@ 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 @@ -137,11 +125,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(); @@ -315,11 +303,6 @@ public partial class MainPageViewModel : ViewModelBase CurrentViewModel = SettingsModel; } - private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost) - { - throw new NotImplementedException(); - } - private async Task RefreshPostsAsync() { var posts = await BlogClient.GetPostsAsync(); @@ -380,13 +363,33 @@ 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; - CurrentViewModel = GetACLViewModel(SelectedPost); + ManageAclRequested?.Invoke(this, SelectedPost); } /// diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs index ae48fd8c..68b96b7c 100644 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -8,7 +8,6 @@ using CommunityToolkit.Mvvm.Input; using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; -using Yavsc.Abstract.Identity.Security; namespace PostIt.ViewModels; @@ -41,7 +40,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; } @@ -81,6 +80,9 @@ 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)"; } @@ -106,9 +108,11 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - var created = await _aclClient.GrantAsync(new CircleAuthorization + var created = await _aclClient.GrantAsync(new CircleAuthorizationDto { - CircleId = SelectedCircleToAdd.Id + CircleId = SelectedCircleToAdd.Id, + BlogPostId = Post.Id, + Comment = false, }); if (created is not null) { @@ -131,7 +135,7 @@ public partial class PostAclDialogViewModel : ViewModelBase } [RelayCommand] - public async Task RevokeAsync(CircleAuthorization? acl) + public async Task RevokeAsync(CircleAuthorizationDto? 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 86bcf7dd..0246857e 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -33,12 +33,8 @@