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