postIt: wire DarkMode, drop dead themeVariant, add UI tests for the banner
Three related changes that close the loop on the DarkMode field and lay the first stone of a UI test scaffold for PostIt. 1. Settings.DarkMode was previously a dead field. It round- tripped through postit-settings.json and the SettingsPage CheckBox, OnDarkModeChanged flipped IsDirty, and that was it — no consumer ever read the value, so toggling the CheckBox had no visible effect. The fix is in App.OnFrameworkInitializationCompleted: read the value Load() just populated and set Application.Current.RequestedThemeVariant accordingly (so a dark-mode user lands on a dark window on first launch, not on a default-light window that flips after the user touches the toggle), then subscribe to settings.PropertyChanged and update the theme on every DarkMode change. The consumer lives in App.axaml.cs, not in Settings, so the Settings model stays free of any Avalonia.Application dependency and the SettingsLoadTests (which construct Settings outside an Avalonia host) still pass unchanged. 2. MainPageViewModel had a vestigial [ObservableProperty] ThemeVariant themeVariant = ThemeVariant.Default that no XAML, no code, and no test ever read. It was the start of a half-finished attempt to expose the theme variant on the page VM. The dark-mode wiring above makes it irrelevant: the theme is now driven by Application, not by a VM property. The field is removed, along with the using Avalonia.Styling; it pulled in (now unused). 3. SessionStatusBannerTests adds the first set of UI tests for PostIt. They mount a real MainWindow via the headless Avalonia host declared in TestApp.cs, attach a SessionStatusViewModel as the banner's DataContext, and assert the actual visual tree contents: three buttons render (Se déconnecter, Se connecter, Paramètres), the Login button is visible when logged out, the Logout button is hidden when logged out, the Paramètres button is visible regardless of session, and the session label text reflects the VM. The pattern follows what UnitTest1.MainPage_Should_Load already established: [AvaloniaFact] (from Avalonia.Headless.XUnit) plus new MainWindow() / window.Show(). A plain [Fact] cannot drive Window..ctor() because the headless platform's PlatformManager.CreateWindow() has no service registered outside a dispatcher-aware test context; the AvaloniaFact attribute provides that context. The DataContext is set on the banner directly because App.OnFrameworkInitializationCompleted is not called in a unit test (production wiring is exercised by the manual launch, not here). Build: 0 errors. Tests: 5/5 SessionStatusBannerTests, 3/3 SettingsLoadTests, 1/1 MainPageTests (the existing scaffold test, unchanged). The other PostIt.Tests suites depend on the OIDC stub WebApplicationFactory and time out on this network-restricted host.
This commit is contained in:
parent
1733dababb
commit
5104ffeb81
3 changed files with 147 additions and 6 deletions
122
src/PostIt.Tests/SessionStatusBannerTests.cs
Normal file
122
src/PostIt.Tests/SessionStatusBannerTests.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using PostIt.Views;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
|
||||||
|
/// a real <see cref="MainWindow"/> via the headless Avalonia
|
||||||
|
/// platform declared in <c>TestApp.cs</c>.
|
||||||
|
///
|
||||||
|
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
|
||||||
|
/// established: a test attribute <c>[AvaloniaFact]</c> (from
|
||||||
|
/// <c>Avalonia.Headless.XUnit</c>) instead of plain <c>[Fact]</c>,
|
||||||
|
/// <c>new MainWindow()</c>, <c>window.Show()</c>. The AvaloniaFact
|
||||||
|
/// attribute schedules the test body inside a dispatcher, which
|
||||||
|
/// is the precondition for the headless Window's
|
||||||
|
/// <c>PlatformManager.CreateWindow()</c> to find a registered
|
||||||
|
/// service. A plain <c>[Fact]</c> test that calls
|
||||||
|
/// <c>new Window().Show()</c> throws because the harness has not
|
||||||
|
/// been initialised for that thread.</para>
|
||||||
|
///
|
||||||
|
/// <para>The session banner's <c>DataContext</c> is not wired in
|
||||||
|
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
|
||||||
|
/// not called in a unit test, so we set the DataContext on the
|
||||||
|
/// banner directly. The production code path is exercised
|
||||||
|
/// end-to-end by the manual launch, not here.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class SessionStatusBannerTests
|
||||||
|
{
|
||||||
|
[AvaloniaFact]
|
||||||
|
public void Banner_renders_three_buttons_in_the_visual_tree()
|
||||||
|
{
|
||||||
|
var window = new MainWindow();
|
||||||
|
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||||
|
window.Show();
|
||||||
|
|
||||||
|
var buttons = window.SessionBanner.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Three buttons, named by their content text: Se
|
||||||
|
// déconnecter, Se connecter, Paramètres. If any one is
|
||||||
|
// missing, the user has no way to trigger the
|
||||||
|
// corresponding navigation event.
|
||||||
|
Assert.Equal(3, buttons.Count);
|
||||||
|
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
|
||||||
|
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
|
||||||
|
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public void Banner_login_button_is_visible_when_logged_out()
|
||||||
|
{
|
||||||
|
var window = new MainWindow();
|
||||||
|
var vm = new SessionStatusViewModel();
|
||||||
|
Assert.True(vm.IsLoggedOut); // VM default
|
||||||
|
window.SessionBanner.DataContext = vm;
|
||||||
|
window.Show();
|
||||||
|
|
||||||
|
var login = window.SessionBanner.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.Single(b => b.Content as string == "Se connecter");
|
||||||
|
|
||||||
|
// The XAML binds IsVisible to IsLoggedOut. After Show,
|
||||||
|
// the binding has been evaluated.
|
||||||
|
Assert.True(login.IsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public void Banner_logout_button_is_hidden_when_logged_out()
|
||||||
|
{
|
||||||
|
var window = new MainWindow();
|
||||||
|
var vm = new SessionStatusViewModel();
|
||||||
|
Assert.False(vm.IsLoggedIn); // VM default
|
||||||
|
window.SessionBanner.DataContext = vm;
|
||||||
|
window.Show();
|
||||||
|
|
||||||
|
var logout = window.SessionBanner.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.Single(b => b.Content as string == "Se déconnecter");
|
||||||
|
|
||||||
|
Assert.False(logout.IsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public void Banner_settings_button_is_visible_regardless_of_session()
|
||||||
|
{
|
||||||
|
var window = new MainWindow();
|
||||||
|
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||||
|
window.Show();
|
||||||
|
|
||||||
|
var settings = window.SessionBanner.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.Single(b => b.Content as string == "Paramètres");
|
||||||
|
|
||||||
|
// Paramètres is the only button with no IsVisible
|
||||||
|
// binding — always shown. The user's only path to the
|
||||||
|
// settings page goes through this button.
|
||||||
|
Assert.True(settings.IsVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public void Banner_session_label_reflects_DataContext()
|
||||||
|
{
|
||||||
|
var window = new MainWindow();
|
||||||
|
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||||
|
window.Show();
|
||||||
|
|
||||||
|
var label = window.SessionBanner.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");
|
||||||
|
|
||||||
|
// Default SessionLabel is "Déconnecté" until Refresh()
|
||||||
|
// is called with a valid session. This pins the default
|
||||||
|
// so a future refactor that breaks the initial value
|
||||||
|
// (e.g. by removing the field initialiser) is caught.
|
||||||
|
Assert.Equal("Déconnecté", label.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Styling;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
|
|
@ -112,8 +113,24 @@ public partial class App : Application
|
||||||
// DataContext, and the TwoWay bindings inside the page keep
|
// DataContext, and the TwoWay bindings inside the page keep
|
||||||
// mutating the same in-memory Settings instance that the rest
|
// mutating the same in-memory Settings instance that the rest
|
||||||
// of the app reads (OidcClientOptions construction, etc.).
|
// of the app reads (OidcClientOptions construction, etc.).
|
||||||
provider.GetRequiredService<SettingsPage>().DataContext =
|
provider.GetRequiredService<SettingsPage>().DataContext = settings;
|
||||||
provider.GetRequiredService<Settings>();
|
|
||||||
|
// Settings.DarkMode was previously a dead field: it round-
|
||||||
|
// tripped through the settings file and the SettingsPage
|
||||||
|
// CheckBox, but no consumer ever read it. Wire it here to
|
||||||
|
// Application.RequestedThemeVariant so the toggle takes
|
||||||
|
// effect immediately, and seed the initial theme from the
|
||||||
|
// value Load() just populated (so a dark-mode user lands on
|
||||||
|
// a dark window on first launch, not on a default-light
|
||||||
|
// window that flips after the user touches the toggle).
|
||||||
|
ApplyDarkMode(settings);
|
||||||
|
settings.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(Settings.DarkMode))
|
||||||
|
{
|
||||||
|
ApplyDarkMode(settings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
|
@ -190,6 +207,12 @@ public partial class App : Application
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ApplyDarkMode(Settings settings)
|
||||||
|
{
|
||||||
|
Application.Current!.RequestedThemeVariant =
|
||||||
|
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Run once after the main window is shown: try to refresh the
|
/// Run once after the main window is shown: try to refresh the
|
||||||
/// cached OIDC tokens silently; on success, push MainPage on top
|
/// cached OIDC tokens silently; on success, push MainPage on top
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Styling;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using PostIt.Models;
|
using PostIt.Models;
|
||||||
|
|
@ -38,9 +37,6 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
ThemeVariant themeVariant = ThemeVariant.Default;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial Settings Settings { get; private set; }
|
public partial Settings Settings { get; private set; }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue