merge: Campaign LA gate round — headless MainWindow view tests close #399

Falsification-proven: 12/12 fail against AvaloniaXamlLoader.Load (the #398
crash shape), 12/12 pass against InitializeComponent. Launcher tests 66/66
on Windows and native Ubuntu, no display required. xunit -> xunit.v3 in the
launcher test project (required by Avalonia.Headless.XUnit 12.1.1 net10.0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 07:55:55 +02:00
commit fb64af8b25
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.

View file

@ -9,10 +9,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Headless.XUnit" Version="12.1.1" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>

View file

@ -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<LauncherUpdateException>(
() => composition.Updater.CheckAsync());
() => composition.Updater.CheckAsync(TestContext.Current.CancellationToken));
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
}

View file

@ -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);

View file

@ -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;
/// <summary>
/// Closes #399: no test ever constructed <see cref="MainWindow"/>, so the
/// #398 defect class (code-behind dereferencing an x:Name field that
/// <c>AvaloniaXamlLoader.Load(this)</c> never assigns, instead of the
/// generated <c>InitializeComponent()</c>) reached the user gate through
/// 14,012 green tests that were all ViewModel-only.
///
/// Every test here constructs a real <see cref="MainWindow"/> against the
/// real compiled XAML and drives it exactly the way <c>App.axaml.cs</c>
/// does: assign a live <see cref="LauncherWindowViewModel"/> as
/// <c>DataContext</c>, then exercise the modal open/close paths that
/// dereference the named controls (the bug class lives in
/// MainWindow.axaml.cs's <c>OnViewModelPropertyChanged</c> and
/// <c>FocusActiveModal</c>). That focus work is queued via
/// <c>Dispatcher.UIThread.Post</c>, so every test pumps the headless
/// dispatcher with <see cref="Dispatcher.RunJobs"/> before asserting — a
/// test that only sets a property and asserts would pass vacuously
/// without ever running <c>FocusActiveModal</c>.
/// </summary>
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<string> 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<Button>()
.First(button => Equals(button.Content, "+ Server"));
addServerButton.Focus();
Assert.Same(addServerButton, CurrentFocus(window));
viewModel.EditorDialog.Open(ProfileEditorKind.AddServer, "Fixture title", _ => { });
Dispatcher.UIThread.RunJobs();
Control serverName = (Control)GetNamedField(window, "ServerNameTextBox")!;
Assert.Same(serverName, CurrentFocus(window));
viewModel.EditorDialog.Close();
Dispatcher.UIThread.RunJobs();
// addServerButton was focused before the dialog opened, so the
// restore branch (focusToRestore.Focus()) is what ran here, not
// the ProfilesTree.Focus() fallback exercised by the tests above.
Assert.Same(addServerButton, CurrentFocus(window));
}
private static Control? CurrentFocus(MainWindow window) =>
Avalonia.Controls.TopLevel.GetTopLevel(window)?.FocusManager?.GetFocusedElement()
as Control;
private static object? GetNamedField(MainWindow window, string name) =>
typeof(MainWindow)
.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?.GetValue(window);
private static LauncherWindowViewModel CreateViewModel()
{
var viewModel = new LauncherWindowViewModel(
new StubOrchestrator(),
new ImmediateUiDispatcher());
viewModel.Initialize();
return viewModel;
}
private static string FindRepositoryRoot()
{
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
{
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
/// <summary>
/// Minimal no-op orchestrator. These tests exercise MainWindow's own
/// dispatcher/focus wiring, not orchestrator behavior (already covered
/// by <see cref="LauncherWindowViewModelTests"/>), so every mutation is
/// a no-op and the snapshot is deliberately empty.
/// </summary>
private sealed class StubOrchestrator : ILauncherOrchestrator
{
// Never raised: these tests exercise MainWindow's dispatcher/focus
// wiring directly and never trigger an orchestrator-side refresh.
#pragma warning disable CS0067
public event EventHandler? StateChanged;
#pragma warning restore CS0067
public void LoadProfiles()
{
}
public LauncherStateSnapshot GetSnapshot() => new(
Servers: [],
Sessions: [],
Platform: LauncherPlatformCapabilities.Detect(),
IsInstallationReady: false,
InstallationStatus: "Fixture: installation not ready.");
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
LauncherCapability.Available;
public LauncherCapability GetAccountLaunchCapability(
string serverName,
string accountName,
LaunchMode mode) => LauncherCapability.Available;
public LauncherCapability GetProbeCapability(string serverName, string accountName) =>
LauncherCapability.Available;
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
}
public void AddServer(string name, string host, int port)
{
}
public void EditServer(string name, string newName, string newHost, int newPort)
{
}
public void RemoveServer(string name)
{
}
public void AddAccount(string serverName, string accountName, string password)
{
}
public void EditAccount(
string serverName,
string accountName,
string newAccountName,
string? newPassword)
{
}
public void RemoveAccount(string serverName, string accountName)
{
}
public void AddCharacter(
string serverName,
string accountName,
string characterName,
string? characterId)
{
}
public void EditCharacterIdentity(
string serverName,
string accountName,
string characterName,
string newCharacterName,
string? newCharacterId)
{
}
public void UpdateCharacterSettings(
string serverName,
string accountName,
string characterName,
LaunchMode launchMode,
IReadOnlyList<string> plugins,
IReadOnlyList<string> loginCommands)
{
}
public void RemoveCharacter(string serverName, string accountName, string characterName)
{
}
public Task<LauncherSessionSnapshot> LaunchAsync(
string serverName,
string accountName,
string? characterName,
LaunchMode mode,
CancellationToken cancellationToken = default) =>
Task.FromResult(CreateSession());
public Task<LauncherSessionSnapshot> ProbeAsync(
string serverName,
string accountName,
CancellationToken cancellationToken = default) =>
Task.FromResult(CreateSession());
public Task StopSessionAsync(
string sessionId,
TimeSpan timeout,
CancellationToken cancellationToken = default) => Task.CompletedTask;
public void PollStatus()
{
}
public void ClearFinishedSessions()
{
}
public void Dispose()
{
}
private static LauncherSessionSnapshot CreateSession() => new(
"fixture-session",
LauncherActivityKind.Play,
"Fixture server",
"fixture-account",
"+Fixture",
LaunchMode.Gui,
LauncherActivityState.Connected,
"Connected.",
ExitCode: null,
Error: null,
CreatedAt: DateTimeOffset.UnixEpoch);
}
}

View file

@ -0,0 +1,21 @@
using Avalonia;
using Avalonia.Headless;
[assembly: AvaloniaTestApplication(typeof(AcDream.Launcher.Tests.TestAppBuilder))]
namespace AcDream.Launcher.Tests;
/// <summary>
/// Headless Avalonia application entry point for <c>[AvaloniaFact]</c>/
/// <c>[AvaloniaTheory]</c> tests (see #399). Reuses the launcher's real
/// <see cref="App"/> so the FluentTheme styles declared in App.axaml are
/// live for every test — <see cref="MainWindow"/>'s controls (TreeView,
/// Button, TextBox, ...) need a real control theme to template and focus
/// correctly, not just an unstyled visual tree.
/// </summary>
public static class TestAppBuilder
{
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UseHeadless(new AvaloniaHeadlessPlatformOptions());
}