From 2b439cc1071dfa68b444bf1e25292dd37db0148e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 07:54:49 +0200 Subject: [PATCH] =?UTF-8?q?test(launcher):=20Campaign=20LA=20=E2=80=94=20h?= =?UTF-8?q?eadless=20MainWindow=20view=20tests=20close=20#399?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #398 was a crash on every modal open/close caused by MainWindow's constructor calling AvaloniaXamlLoader.Load(this) instead of the generated InitializeComponent() — only InitializeComponent assigns the x:Name backing fields, so every named control was null and the first Dispatcher.UIThread.Post callback in OnViewModelPropertyChanged threw NullReferenceException, killing the process. It reached the user gate because no test in tests/AcDream.Launcher.Tests (ViewModel-only) ever constructed a MainWindow. #399 is the process gap that let that class of defect through 14,012 green tests. Adds Avalonia.Headless.XUnit 12.1.1 to the launcher test project. Its net10.0 dependency group targets xunit v3, so the project migrates xunit 2.9.3 -> xunit.v3 3.2.2 (drop-in: all 54 pre-existing tests compile and pass unchanged under dotnet test via xunit.runner.visualstudio 3.1.4, which already supported v1/v2/v3; two call sites needed TestContext.Current.CancellationToken per the new xUnit1051 analyzer). TestAppBuilder.cs wires [assembly: AvaloniaTestApplication] to a headless AppBuilder.Configure() so the real App.axaml FluentTheme is live in tests. MainWindowViewTests.cs adds 12 [AvaloniaFact]/[AvaloniaTheory] tests: - an explicit non-null + type check of every x:Name field the code-behind dereferences (ProfilesTree, ServerNameTextBox, AccountNameTextBox, CharacterNameTextBox, EditorSubmitButton, FirstRunDatDirectoryTextBox, FirstRunCloseButton, UpdateCloseButton) - a reflection sweep over every x:Name found in MainWindow.axaml, so a future named control without a matching non-null field fails loudly - one open+close round trip per ProfileEditorKind (all seven, including Remove), plus the first-run wizard and the update prompt, each pumping Dispatcher.UIThread.RunJobs() so the queued focus callback actually executes instead of just being asserted vacuously - a dedicated test for the _focusBeforeModal-restore branch (not just the ProfilesTree.Focus() fallback), anchored on a real focusable button since ProfilesTree (TreeView) has Focusable="False" under FluentTheme — its own tab stops are TreeViewItem rows, so the close-path assertions check "no exception escaped the dispatcher" rather than "focus landed on ProfilesTree" Falsification (required evidence): reverting MainWindow's constructor to AvaloniaXamlLoader.Load(this) and rerunning gives 12 failed / 0 passed — 10 tests throw NullReferenceException at MainWindow.FocusActiveModal, propagating cleanly out of Dispatcher.UIThread.RunJobs() (confirming dispatcher exceptions are not silently swallowed), and the 2 reflection tests fail on an explicit "x:Name 'ProfilesTree' was null after construction" message. Restoring InitializeComponent() gives 12 passed / 0 failed. Full launcher suite: 66 passed / 0 failed, reproduced on both Windows and native Ubuntu (WSL, no display/Xvfb — Avalonia.Headless needs none). AcDream.Launcher.Core.Tests: 317/317 unaffected. No CI workflow change needed: .github/workflows/headless-portability.yml's portable-launcher job already runs dotnet test on the launcher test project on both windows-latest and ubuntu-latest with no display setup, which is sufficient for Avalonia.Headless. Closes #399. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 53 ++- .../AcDream.Launcher.Tests.csproj | 3 +- .../LauncherUpdateCompositionTests.cs | 2 +- .../LauncherWindowViewModelTests.cs | 4 +- .../MainWindowViewTests.cs | 406 ++++++++++++++++++ .../AcDream.Launcher.Tests/TestAppBuilder.cs | 21 + 6 files changed, 478 insertions(+), 11 deletions(-) create mode 100644 tests/AcDream.Launcher.Tests/MainWindowViewTests.cs create mode 100644 tests/AcDream.Launcher.Tests/TestAppBuilder.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 70672e8d..c83a90b5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,7 +26,8 @@ What does NOT go here: ## #399 — Launcher: no test ever constructs MainWindow, so code-behind defects reach the user gate -**Status:** OPEN +**Status:** DONE (this commit, Campaign LA UI-test slice) — closed via +`tests/AcDream.Launcher.Tests/MainWindowViewTests.cs`. **Severity:** HIGH (process class: this gap let #398 — a crash on every modal open/close — pass 14,012 green tests and reach the user gate) **Filed:** 2026-08-15 (found while launching the launcher for the LA11 gate) @@ -38,14 +39,50 @@ view. `LauncherWindowViewModelTests` proved the modal state machine while the code-behind that consumes it was never executed once, which is exactly how #398's null `x:Name` fields survived every automated gate. -**Fix direction.** Add `Avalonia.Headless.XUnit` to the launcher test -project and a focused view test that constructs `MainWindow`, asserts every -`x:Name` field the code-behind dereferences is non-null, and drives each -modal (editor kinds, first-run wizard, update prompt) through open and -close so the focus/restore paths actually run. That catches the class — -any null named control or throwing handler — not just #398's spelling. +**Fix landed.** Added `Avalonia.Headless.XUnit` 12.1.1 to the launcher test +project (its net10.0 dependency group targets **xunit v3**, so the project +migrated `xunit` 2.9.3 → `xunit.v3` 3.2.2 — a drop-in swap; all 54 +pre-existing `[Fact]`/`[Theory]`/`Assert.*` tests compiled and passed +unchanged, only two call sites needed `TestContext.Current.CancellationToken` +per the new `xUnit1051` analyzer). `TestAppBuilder` +(`tests/AcDream.Launcher.Tests/TestAppBuilder.cs`) wires +`[assembly: AvaloniaTestApplication]` to a headless `AppBuilder.Configure()` +so FluentTheme (declared in the real `App.axaml`) is live for every test. +`MainWindowViewTests.cs` adds 12 `[AvaloniaFact]`/`[AvaloniaTheory]` tests: +an explicit non-null check of every `x:Name` field the code-behind +dereferences, a reflection sweep over every `x:Name` in the markup (so a +future named control without a matching non-null field fails loudly), and +one open+close round trip per `ProfileEditorKind` (all seven, including +`Remove`) plus the first-run wizard and the update prompt — each pumping +`Dispatcher.UIThread.RunJobs()` so the `Dispatcher.UIThread.Post` callback +in `OnViewModelPropertyChanged`/`FocusActiveModal` actually executes, not +just gets queued. A dedicated test proves the `_focusBeforeModal != null` +restore branch (not just the `ProfilesTree.Focus()` fallback) also runs +clean, anchored on a real focusable button since `ProfilesTree` (a +`TreeView`) has `Focusable="False"` under FluentTheme — its own tab stops +are `TreeViewItem` rows, so the close-path assertions check "no exception +escaped" rather than "focus landed on ProfilesTree" (that would be a false +expectation, not the bug this issue is about). -**Acceptance:** a headless view test fails against the pre-#398 code +**Falsification (required evidence).** Reverting `MainWindow`'s constructor +to `AvaloniaXamlLoader.Load(this)` and rerunning: **12 failed / 0 passed** +— 10 tests throw `System.NullReferenceException` at +`AcDream.Launcher.MainWindow.FocusActiveModal` (propagating cleanly out of +`Dispatcher.UIThread.RunJobs()`, confirming dispatcher exceptions are not +swallowed), the 2 reflection tests fail on an explicit +"`x:Name 'ProfilesTree' was null after construction`" message. Restoring +`InitializeComponent()`: **12 passed / 0 failed**. Full launcher suite: +**66 passed / 0 failed** (Windows and native Ubuntu/WSL, both post-fix). +`tests/AcDream.Launcher.Core.Tests`: 317/317 unaffected. + +**CI.** `.github/workflows/headless-portability.yml`'s `portable-launcher` +job already runs `dotnet test tests/AcDream.Launcher.Tests/...` on both +`windows-latest` and `ubuntu-latest` with no display setup — no workflow +change was needed, since `Avalonia.Headless` requires no real windowing +system (confirmed directly: the new tests pass unmodified under WSL/native +Linux with no `DISPLAY` or Xvfb). + +**Acceptance (met):** a headless view test fails against the pre-#398 code (`AvaloniaXamlLoader.Load`) and passes after, and runs in the portable Windows+Ubuntu CI lane alongside the existing launcher tests. diff --git a/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj b/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj index 872bec9d..ef2da372 100644 --- a/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj +++ b/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj @@ -9,10 +9,11 @@ + - + diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs index d2f2ed98..1cdcae41 100644 --- a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs @@ -59,7 +59,7 @@ public sealed class LauncherUpdateCompositionTests : IDisposable Assert.False(capability.IsAvailable); Assert.Contains(exception.Message, capability.Reason, StringComparison.Ordinal); LauncherUpdateException updateError = await Assert.ThrowsAsync( - () => composition.Updater.CheckAsync()); + () => composition.Updater.CheckAsync(TestContext.Current.CancellationToken)); Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal); } diff --git a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs index 18fd3897..b747e5d9 100644 --- a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs @@ -389,7 +389,9 @@ public sealed class LauncherWindowViewModelTests viewModel.FirstRunWizardShell.OpenCommand.Execute(null); Task install = viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync(); - await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await entered.Task.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); Assert.True(viewModel.FirstRunWizardShell.CancelCommand.CanExecute(null)); Assert.False(viewModel.FirstRunWizardShell.CanEditInputs); viewModel.FirstRunWizardShell.CancelCommand.Execute(null); diff --git a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs new file mode 100644 index 00000000..c117563a --- /dev/null +++ b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs @@ -0,0 +1,406 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using AcDream.Launcher.Core.Installation; +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.ViewModels; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace AcDream.Launcher.Tests; + +/// +/// Closes #399: no test ever constructed , so the +/// #398 defect class (code-behind dereferencing an x:Name field that +/// AvaloniaXamlLoader.Load(this) never assigns, instead of the +/// generated InitializeComponent()) reached the user gate through +/// 14,012 green tests that were all ViewModel-only. +/// +/// Every test here constructs a real against the +/// real compiled XAML and drives it exactly the way App.axaml.cs +/// does: assign a live as +/// DataContext, then exercise the modal open/close paths that +/// dereference the named controls (the bug class lives in +/// MainWindow.axaml.cs's OnViewModelPropertyChanged and +/// FocusActiveModal). That focus work is queued via +/// Dispatcher.UIThread.Post, so every test pumps the headless +/// dispatcher with before asserting — a +/// test that only sets a property and asserts would pass vacuously +/// without ever running FocusActiveModal. +/// +public sealed class MainWindowViewTests +{ + // Every x:Name in MainWindow.axaml, kept in sync with the reflection + // sweep below so a newly-added named control without a matching field + // fails loudly instead of silently reaching InitializeComponent(). + private static readonly (string Name, Type Type)[] ExpectedNamedControls = + [ + ("ProfilesTree", typeof(TreeView)), + ("ServerNameTextBox", typeof(TextBox)), + ("AccountNameTextBox", typeof(TextBox)), + ("CharacterNameTextBox", typeof(TextBox)), + ("EditorSubmitButton", typeof(Button)), + ("FirstRunDatDirectoryTextBox", typeof(TextBox)), + ("FirstRunCloseButton", typeof(Button)), + ("UpdateCloseButton", typeof(Button)), + ]; + + [AvaloniaFact] + public void EveryExplicitlyNamedControlIsAssignedAfterConstruction() + { + var window = new MainWindow(); + + foreach ((string name, Type type) in ExpectedNamedControls) + { + object? value = GetNamedField(window, name); + Assert.True( + value is not null, + $"x:Name '{name}' was null after construction. Only the " + + "generated InitializeComponent() assigns x:Name backing " + + "fields; AvaloniaXamlLoader.Load(this) alone leaves them " + + "null (this is the #398 defect class)."); + Assert.IsAssignableFrom(type, value); + } + } + + [AvaloniaFact] + public void ReflectionSweepOfEveryXNameInMarkupFindsANonNullBackingField() + { + string markupPath = Path.Combine( + FindRepositoryRoot(), + "src", + "AcDream.Launcher", + "MainWindow.axaml"); + string markup = File.ReadAllText(markupPath); + List names = Regex + .Matches(markup, "x:Name=\"([^\"]+)\"") + .Select(match => match.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .ToList(); + + // The markup must still declare at least the controls the + // code-behind dereferences; an empty sweep would make this test + // vacuous. + Assert.True(names.Count >= ExpectedNamedControls.Length); + + var window = new MainWindow(); + foreach (string name in names) + { + FieldInfo? field = typeof(MainWindow).GetField( + name, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.True(field is not null, $"No backing field found for x:Name '{name}'."); + object? value = field!.GetValue(window); + Assert.True( + value is not null, + $"x:Name '{name}' resolved to a field but its value was null " + + "after construction."); + } + } + + [AvaloniaTheory] + [InlineData(ProfileEditorKind.AddServer, "ServerNameTextBox")] + [InlineData(ProfileEditorKind.EditServer, "ServerNameTextBox")] + [InlineData(ProfileEditorKind.AddAccount, "AccountNameTextBox")] + [InlineData(ProfileEditorKind.EditAccount, "AccountNameTextBox")] + [InlineData(ProfileEditorKind.AddCharacter, "CharacterNameTextBox")] + [InlineData(ProfileEditorKind.EditCharacter, "CharacterNameTextBox")] + [InlineData(ProfileEditorKind.Remove, "EditorSubmitButton")] + public void OpeningEachEditorKindFocusesItsPrimaryFieldAndClosingRunsTheFallbackWithoutThrowing( + ProfileEditorKind kind, + string expectedFocusFieldName) + { + using LauncherWindowViewModel viewModel = CreateViewModel(); + var window = new MainWindow { DataContext = viewModel }; + window.Show(); + + viewModel.EditorDialog.Open(kind, "Fixture title", _ => { }); + Assert.True(viewModel.EditorDialog.IsOpen); + Dispatcher.UIThread.RunJobs(); + + Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!; + Assert.Same(expectedFocus, CurrentFocus(window)); + + viewModel.EditorDialog.Close(); + Assert.False(viewModel.EditorDialog.IsOpen); + + // Nothing held focus before the dialog opened, so + // OnViewModelPropertyChanged's close branch posts the fallback + // (ProfilesTree.Focus()). Pumping the dispatcher is what actually + // *runs* FocusActiveModal's caller and its ProfilesTree + // dereference — this is the #398 defect class: with + // AvaloniaXamlLoader.Load(this) instead of InitializeComponent(), + // ProfilesTree is null here and this throws + // NullReferenceException out of the dispatcher. TreeView's Fluent + // template sets Focusable="False" (focus lives on TreeViewItem + // rows, not the tree itself), so a successful, non-throwing + // ProfilesTree.Focus() call still leaves focus at null — that is + // expected, not a failure. + Dispatcher.UIThread.RunJobs(); + Assert.NotSame(expectedFocus, CurrentFocus(window)); + } + + [AvaloniaFact] + public void OpeningAndClosingTheFirstRunWizardFocusesAndRunsTheCloseFallbackWithoutThrowing() + { + using LauncherWindowViewModel viewModel = CreateViewModel(); + var window = new MainWindow { DataContext = viewModel }; + window.Show(); + + viewModel.FirstRunWizardShell.OpenCommand.Execute(null); + Assert.True(viewModel.FirstRunWizardShell.IsOpen); + Dispatcher.UIThread.RunJobs(); + + Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!; + Assert.Same(datDirectoryBox, CurrentFocus(window)); + + viewModel.FirstRunWizardShell.CloseCommand.Execute(null); + Assert.False(viewModel.FirstRunWizardShell.IsOpen); + + // See the comment in the editor-kind theory above: this pump is + // what actually executes the ProfilesTree.Focus() fallback. + Dispatcher.UIThread.RunJobs(); + Assert.NotSame(datDirectoryBox, CurrentFocus(window)); + } + + [AvaloniaFact] + public async Task OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing() + { + using LauncherWindowViewModel viewModel = CreateViewModel(); + var window = new MainWindow { DataContext = viewModel }; + window.Show(); + + await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync(); + Assert.True(viewModel.UpdatePrompt.IsOpen); + Dispatcher.UIThread.RunJobs(); + + Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!; + Assert.Same(closeButton, CurrentFocus(window)); + + viewModel.UpdatePrompt.CloseCommand.Execute(null); + Assert.False(viewModel.UpdatePrompt.IsOpen); + + // See the comment in the editor-kind theory above: this pump is + // what actually executes the ProfilesTree.Focus() fallback. + Dispatcher.UIThread.RunJobs(); + Assert.NotSame(closeButton, CurrentFocus(window)); + } + + [AvaloniaFact] + public void ClosingAModalRestoresThePreviouslyFocusedControlWithoutThrowing() + { + using LauncherWindowViewModel viewModel = CreateViewModel(); + var window = new MainWindow { DataContext = viewModel }; + window.Show(); + + // ProfilesTree itself is not a Fluent focus target (its template + // sets Focusable="False"; individual TreeViewItem rows are the + // real tab stops), so use another genuinely focusable, always + // visible control from the same non-modal chrome as the + // "previously focused" anchor for the _focusBeforeModal != null + // branch of MainWindow.OnViewModelPropertyChanged. + Control addServerButton = window + .GetVisualDescendants() + .OfType