acdream/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs
Erik 2b439cc107 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>
2026-08-15 07:54:49 +02:00

406 lines
15 KiB
C#

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