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