acdream/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs

544 lines
21 KiB
C#

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;
/// <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.
///
/// The Avalonia test constructs real <see cref="MainWindow"/> instances against
/// the compiled XAML and drives them 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
/// phase 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 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<string> 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<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));
}
finally
{
CloseTestWindow(window);
}
}
private static void CloseTestWindow(MainWindow window)
{
// Avalonia objects are thread-affine. A shown test window must be
// closed and its compositor cleanup pumped by the same Avalonia test
// session that created it; leaving it for runner/GC teardown made the
// full parallel solution intermittently clean it from another thread.
if (window.IsVisible)
{
window.Close();
}
Dispatcher.UIThread.RunJobs();
}
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>
/// Gate-round-1 review F1: the crash reporter's safety rests on the
/// invariant that no code path interpolates a credential VALUE into an
/// exception message — the launcher genuinely holds passwords
/// (ProfileEditorDialogViewModel, AccountProfile.Password,
/// StartRequest.Password), so "no password in any field" was never the
/// guarantee. This test pins the real one against the most
/// credential-adjacent realistic failure: a profiles-shaped document
/// that CONTAINS the password and is corrupted AFTER it, so the JSON
/// parser has consumed the credential value before throwing.
/// System.Text.Json quotes paths and positions, never values — if that
/// (or any future throw site) ever changes, this fails and the sink
/// needs the status-stream's credential scanning.
/// </summary>
[Fact]
public void CrashReportNeverContainsAStoredPassword()
{
string root = Path.Combine(
Path.GetTempPath(),
"acdream-tests",
Path.GetRandomFileName());
string dataDirectory = Path.Combine(root, "data");
const string password = "hunter2-gate-round-1-secret";
string corruptProfiles =
"{ \"version\": 1, \"servers\": [ { \"name\": \"s\", \"host\": \"h\", "
+ "\"port\": 9000, \"accounts\": [ { \"account\": \"a\", \"password\": \""
+ password
+ "\", \"characters\": [ } ] } ] }";
Exception failure;
try
{
_ = System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(
corruptProfiles);
throw new InvalidOperationException(
"The corrupt fixture unexpectedly parsed; the test premise is broken.");
}
catch (System.Text.Json.JsonException jsonFailure)
{
failure = new InvalidOperationException(
"Profile load failed during startup.",
jsonFailure);
}
try
{
string? report = Program.TryWriteCrashReport(
["--data-dir", dataDirectory],
failure);
Assert.NotNull(report);
// Isolation re-pinned: the report must land under the caller's
// --data-dir, never the machine's real data root.
Assert.StartsWith(dataDirectory, report, StringComparison.OrdinalIgnoreCase);
string content = File.ReadAllText(report);
Assert.Contains("JsonException", content);
Assert.Contains(" at ", content);
Assert.DoesNotContain(password, content, StringComparison.OrdinalIgnoreCase);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
/// <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);
}
}