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. /// /// 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"); // 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."); } } [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