test(launcher): Campaign LA — headless MainWindow view tests close #399

#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<App>() 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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 07:54:49 +02:00
parent ef9d610459
commit 2b439cc107
6 changed files with 478 additions and 11 deletions

View file

@ -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<App>()`
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.