using System.Reflection;
using System.Xml.Linq;
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.
///
/// The Avalonia test constructs real instances against
/// the compiled XAML and drives them 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
/// phase 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)),
];
// Lane=Manual: this is the ONE test in the repo that needs a real desktop
// session. Measured 2026-08-19 across five environments — passes on a dev
// desktop and on the CI Windows box over SSH; fails identically under
// act_runner's step context and on Linux, always in Test Case Cleanup with
// "The calling thread cannot access this object" while a compositor is
// being CONSTRUCTED (Compositor..ctor -> DefaultRenderLoop.Add ->
// VerifyAccess). Neither serializing the assembly (xunit.runner.json, then
// a compiled-in CollectionBehavior attribute) nor removing the test's only
// await changed it, so it is not parallelism and not a thread hop in the
// test body — it is Avalonia's headless session lifecycle in a
// desktop-less environment. Run it deliberately:
// dotnet test tests/AcDream.Launcher.Tests --filter Lane=Manual
[AvaloniaFact]
[Trait("Lane", "Manual")]
public async Task CompiledMarkupAndEveryModalFocusPathRunInOneOwnedAvaloniaSession()
{
// Avalonia's headless compositor is thread-affine. Keep the complete
// MainWindow matrix inside one runner-owned application session so
// xUnit cannot tear one compositor down on a different worker while
// starting the next scenario. Each named method remains a separate
// assertion phase for readable failure stacks.
EveryExplicitlyNamedControlIsAssignedAfterConstruction();
ReflectionSweepOfEveryXNameInMarkupFindsANonNullBackingField();
OpeningEveryEditorKindFocusesItsPrimaryFieldAndClosingRunsTheFallbackWithoutThrowing();
OpeningAndClosingTheFirstRunWizardFocusesAndRunsTheCloseFallbackWithoutThrowing();
await OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing();
ClosingAModalRestoresThePreviouslyFocusedControlWithoutThrowing();
}
private static 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);
}
}
private static void ReflectionSweepOfEveryXNameInMarkupFindsANonNullBackingField()
{
string markupPath = Path.Combine(
FindRepositoryRoot(),
"src",
"AcDream.Launcher",
"MainWindow.axaml");
// Walk the markup as XML rather than regexing the raw text:
// template-scoped names (inside a DataTemplate/ControlTemplate/
// ItemTemplate) get NO generated backing field, so demanding one
// would false-fail the first time a template gains an x:Name
// (gate-round-1 review F5 — latent today, MainWindow has two
// templates with none inside).
XDocument document = XDocument.Load(markupPath);
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
List names = document
.Descendants()
.Where(element => element.Attribute(x + "Name") is not null)
.Where(element => !element
.Ancestors()
.Any(ancestor => ancestor.Name.LocalName.EndsWith(
"Template",
StringComparison.Ordinal)))
.Select(element => element.Attribute(x + "Name")!.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.");
}
}
private static void OpeningEveryEditorKindFocusesItsPrimaryFieldAndClosingRunsTheFallbackWithoutThrowing()
{
(ProfileEditorKind Kind, string FocusFieldName)[] cases =
[
(ProfileEditorKind.AddServer, "ServerNameTextBox"),
(ProfileEditorKind.EditServer, "ServerNameTextBox"),
(ProfileEditorKind.AddAccount, "AccountNameTextBox"),
(ProfileEditorKind.EditAccount, "AccountNameTextBox"),
(ProfileEditorKind.AddCharacter, "CharacterNameTextBox"),
(ProfileEditorKind.EditCharacter, "CharacterNameTextBox"),
(ProfileEditorKind.Remove, "EditorSubmitButton"),
];
foreach ((ProfileEditorKind kind, string expectedFocusFieldName) in cases)
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
try
{
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));
}
finally
{
CloseTestWindow(window);
}
}
}
private static void OpeningAndClosingTheFirstRunWizardFocusesAndRunsTheCloseFallbackWithoutThrowing()
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
try
{
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));
}
finally
{
CloseTestWindow(window);
}
}
private static async Task OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing()
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
try
{
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));
}
finally
{
CloseTestWindow(window);
}
}
private static void ClosingAModalRestoresThePreviouslyFocusedControlWithoutThrowing()
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
try
{
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