#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>
768 lines
31 KiB
C#
768 lines
31 KiB
C#
using AcDream.Launcher.Core.Orchestration;
|
|
using AcDream.Launcher.Core.Profiles;
|
|
using AcDream.Launcher.Core.Launching;
|
|
using AcDream.Launcher.Core.Installation;
|
|
using AcDream.Launcher.ViewModels;
|
|
|
|
namespace AcDream.Launcher.Tests;
|
|
|
|
public sealed class LauncherWindowViewModelTests
|
|
{
|
|
[Fact]
|
|
public void InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator();
|
|
using var viewModel = new LauncherWindowViewModel(
|
|
orchestrator,
|
|
new ImmediateUiDispatcher());
|
|
|
|
viewModel.Initialize();
|
|
|
|
Assert.True(orchestrator.LoadCalled);
|
|
LauncherTreeNodeViewModel server = Assert.Single(viewModel.Servers);
|
|
Assert.Equal(LauncherTreeNodeKind.Server, server.Kind);
|
|
LauncherTreeNodeViewModel account = Assert.Single(server.Children);
|
|
Assert.Equal(LauncherTreeNodeKind.Account, account.Kind);
|
|
LauncherTreeNodeViewModel character = Assert.Single(account.Children);
|
|
Assert.Equal("+Acdream", character.DisplayName);
|
|
Assert.Same(server, viewModel.SelectedNode);
|
|
|
|
LauncherSessionRowViewModel session = Assert.Single(viewModel.Sessions);
|
|
Assert.Equal("Gui", session.Mode);
|
|
Assert.Equal("Connected", session.State);
|
|
Assert.True(session.IsActive);
|
|
|
|
Assert.True(viewModel.IsFirstRunRequired);
|
|
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
|
Assert.Contains("pinned eriknihlen/acdream", viewModel.UpdatePrompt.Body, StringComparison.Ordinal);
|
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
|
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
|
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
|
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProfileCommandsExposeServerAccountCharacterCrudDialogsAndClearPasswords()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator();
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
|
|
viewModel.AddServerCommand.Execute(null);
|
|
Assert.Equal(ProfileEditorKind.AddServer, viewModel.EditorDialog.Kind);
|
|
viewModel.EditorDialog.Name = "Remote ACE";
|
|
viewModel.EditorDialog.Host = "ace.example.test";
|
|
viewModel.EditorDialog.Port = "9001";
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal(("Remote ACE", "ace.example.test", 9001), orchestrator.AddedServer);
|
|
|
|
SelectServer(viewModel);
|
|
viewModel.AddAccountCommand.Execute(null);
|
|
Assert.Equal(ProfileEditorKind.AddAccount, viewModel.EditorDialog.Kind);
|
|
viewModel.EditorDialog.Name = "second-account";
|
|
viewModel.EditorDialog.Password = "one-use-secret";
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal(
|
|
("Local ACE", "second-account", "one-use-secret"),
|
|
orchestrator.AddedAccount);
|
|
Assert.Equal(string.Empty, viewModel.EditorDialog.Password);
|
|
|
|
SelectAccount(viewModel);
|
|
viewModel.AddCharacterCommand.Execute(null);
|
|
Assert.Equal(ProfileEditorKind.AddCharacter, viewModel.EditorDialog.Kind);
|
|
viewModel.EditorDialog.Name = "+Second";
|
|
viewModel.EditorDialog.CharacterId = "0x5000000B";
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal(
|
|
("Local ACE", "testaccount", "+Second", "0x5000000B"),
|
|
orchestrator.AddedCharacter);
|
|
|
|
SelectCharacter(viewModel);
|
|
viewModel.EditSelectedCommand.Execute(null);
|
|
Assert.Equal(ProfileEditorKind.EditCharacter, viewModel.EditorDialog.Kind);
|
|
viewModel.EditorDialog.Name = "+Renamed";
|
|
viewModel.EditorDialog.CharacterId = "0x5000000C";
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal(
|
|
("Local ACE", "testaccount", "+Acdream", "+Renamed", "0x5000000C"),
|
|
orchestrator.EditedCharacter);
|
|
|
|
SelectCharacter(viewModel);
|
|
viewModel.RemoveSelectedCommand.Execute(null);
|
|
Assert.Equal(ProfileEditorKind.Remove, viewModel.EditorDialog.Kind);
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal(
|
|
("Local ACE", "testaccount", "+Acdream"),
|
|
orchestrator.RemovedCharacter);
|
|
}
|
|
|
|
[Fact]
|
|
public void ServerAndAccountEditRemoveDialogsRouteEveryMutationThroughCore()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator();
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
|
|
SelectServer(viewModel);
|
|
viewModel.EditSelectedCommand.Execute(null);
|
|
Assert.Equal(ProfileEditorKind.EditServer, viewModel.EditorDialog.Kind);
|
|
Assert.Equal("127.0.0.1", viewModel.EditorDialog.Host);
|
|
viewModel.EditorDialog.Name = "Renamed ACE";
|
|
viewModel.EditorDialog.Host = "renamed.example.test";
|
|
viewModel.EditorDialog.Port = "9010";
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal(
|
|
("Local ACE", "Renamed ACE", "renamed.example.test", 9010),
|
|
orchestrator.EditedServer);
|
|
|
|
SelectAccount(viewModel);
|
|
viewModel.EditSelectedCommand.Execute(null);
|
|
Assert.Equal(ProfileEditorKind.EditAccount, viewModel.EditorDialog.Kind);
|
|
viewModel.EditorDialog.Name = "renamed-account";
|
|
viewModel.EditorDialog.Password = "replacement-secret";
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal(
|
|
("Local ACE", "testaccount", "renamed-account", "replacement-secret"),
|
|
orchestrator.EditedAccount);
|
|
Assert.Equal(string.Empty, viewModel.EditorDialog.Password);
|
|
|
|
SelectAccount(viewModel);
|
|
viewModel.RemoveSelectedCommand.Execute(null);
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal(("Local ACE", "testaccount"), orchestrator.RemovedAccount);
|
|
|
|
SelectServer(viewModel);
|
|
viewModel.RemoveSelectedCommand.Execute(null);
|
|
viewModel.EditorDialog.SubmitCommand.Execute(null);
|
|
Assert.Equal("Local ACE", orchestrator.RemovedServer);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CharacterSettingsAndLaunchActionsPreserveTheirTypedSemantics()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator();
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
SelectCharacter(viewModel);
|
|
|
|
viewModel.CharacterLaunchMode = LaunchMode.Headless;
|
|
viewModel.CharacterPluginsText = "Plugin.One\nPlugin.Two\nPlugin.One";
|
|
viewModel.CharacterLoginCommandsText = " /tell someone, hi \n/vt start\n/tell someone, hi";
|
|
viewModel.SaveCharacterSettingsCommand.Execute(null);
|
|
|
|
Assert.NotNull(orchestrator.SettingsUpdate);
|
|
Assert.Equal(LaunchMode.Headless, orchestrator.SettingsUpdate.Value.Mode);
|
|
Assert.Equal(["Plugin.One", "Plugin.Two"], orchestrator.SettingsUpdate.Value.Plugins);
|
|
Assert.Equal(
|
|
["/tell someone, hi", "/vt start", "/tell someone, hi"],
|
|
orchestrator.SettingsUpdate.Value.Commands);
|
|
|
|
await viewModel.LaunchHeadlessCommand.ExecuteAsync();
|
|
Assert.Equal(
|
|
("Local ACE", "testaccount", "+Acdream", LaunchMode.Headless),
|
|
orchestrator.LaunchRequest);
|
|
Assert.Equal("Headless session started for +Acdream.", viewModel.OperationStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AccountGuiSelectWorksWithoutAnyCachedCharacter()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
IncludeCharacter = false,
|
|
};
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
SelectAccount(viewModel);
|
|
|
|
Assert.Empty(viewModel.SelectedNode!.Children);
|
|
Assert.True(viewModel.CanLaunchAccountGuiSelect);
|
|
await viewModel.LaunchAccountGuiSelectCommand.ExecuteAsync();
|
|
|
|
Assert.Equal(
|
|
("Local ACE", "testaccount", (string?)null, LaunchMode.GuiSelect),
|
|
orchestrator.LaunchRequest);
|
|
Assert.Equal(
|
|
"Character-select session started for testaccount.",
|
|
viewModel.OperationStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProbeUsesTheSelectedAccountAndRunningAccountDisablesIt()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator();
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
SelectAccount(viewModel);
|
|
|
|
Assert.True(viewModel.CanProbe);
|
|
await viewModel.RefreshCharactersCommand.ExecuteAsync();
|
|
Assert.Equal(("Local ACE", "testaccount"), orchestrator.ProbeRequest);
|
|
|
|
orchestrator.ProbeCapability = LauncherCapability.Unavailable(
|
|
"Stop the active session before refreshing this account.");
|
|
orchestrator.RaiseStateChanged();
|
|
|
|
SelectAccount(viewModel);
|
|
Assert.False(viewModel.CanProbe);
|
|
Assert.Contains("Stop", viewModel.ProbeDisabledReason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void LinuxKeepsLauncherAndHeadlessAvailableButExplainsDisabledGuiModes()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
Platform = LinuxPlatform(),
|
|
};
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
SelectCharacter(viewModel);
|
|
|
|
Assert.True(viewModel.ShowLinuxGraphicalNotice);
|
|
Assert.False(viewModel.CanLaunchGui);
|
|
Assert.False(viewModel.CanLaunchGuiSelect);
|
|
Assert.True(viewModel.CanLaunchHeadless);
|
|
Assert.Equal(
|
|
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason,
|
|
viewModel.LinuxGraphicalNotice);
|
|
Assert.Contains("Slice L", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
|
Assert.Contains("parked at L1", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingCoDeployedHostReasonIsVisibleForCharacterActions()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
AccountLaunchCapability = LauncherCapability.Unavailable(
|
|
"The co-deployed host is missing; reinstall or update the client."),
|
|
};
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
SelectCharacter(viewModel);
|
|
|
|
Assert.False(viewModel.CanLaunchGui);
|
|
Assert.False(viewModel.CanLaunchHeadless);
|
|
Assert.True(viewModel.ShowGuiLaunchDisabledReason);
|
|
Assert.True(viewModel.ShowHeadlessLaunchDisabledReason);
|
|
Assert.Contains("missing", viewModel.GuiLaunchDisabledReason, StringComparison.Ordinal);
|
|
Assert.Contains("missing", viewModel.HeadlessLaunchDisabledReason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LaunchErrorAndCancellationBecomeSafeVisibleState()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
LaunchHandler = _ => throw new LauncherOperationException("spawn failed safely"),
|
|
};
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
SelectCharacter(viewModel);
|
|
|
|
await viewModel.LaunchGuiCommand.ExecuteAsync();
|
|
|
|
Assert.Equal("spawn failed safely", viewModel.LastError);
|
|
Assert.Equal("Operation failed.", viewModel.OperationStatus);
|
|
Assert.False(viewModel.IsBusy);
|
|
|
|
orchestrator.LaunchHandler = async cancellationToken =>
|
|
{
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
|
return FakeLauncherOrchestrator.CreateSession();
|
|
};
|
|
Task launch = viewModel.LaunchGuiCommand.ExecuteAsync();
|
|
Assert.True(viewModel.IsBusy);
|
|
Assert.True(viewModel.CancelOperationCommand.CanExecute(null));
|
|
viewModel.CancelOperationCommand.Execute(null);
|
|
await launch;
|
|
|
|
Assert.Equal("Operation cancelled.", viewModel.OperationStatus);
|
|
Assert.False(viewModel.HasError);
|
|
Assert.False(viewModel.IsBusy);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProfileDialogRedactsARejectedCredentialAndClearsItOnClose()
|
|
{
|
|
var dialog = new ProfileEditorDialogViewModel();
|
|
dialog.Open(
|
|
ProfileEditorKind.AddAccount,
|
|
"Add account",
|
|
candidate => throw new InvalidOperationException(
|
|
$"rejected {candidate.Password}"));
|
|
dialog.Password = "do-not-display";
|
|
|
|
dialog.SubmitCommand.Execute(null);
|
|
|
|
Assert.True(dialog.IsOpen);
|
|
Assert.DoesNotContain("do-not-display", dialog.Error ?? string.Empty, StringComparison.Ordinal);
|
|
Assert.Contains("[redacted]", dialog.Error ?? string.Empty, StringComparison.Ordinal);
|
|
dialog.CancelCommand.Execute(null);
|
|
Assert.Equal(string.Empty, dialog.Password);
|
|
Assert.False(dialog.IsOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public void ModalShellsBlockBackgroundCommandsAndAreMutuallyExclusive()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator();
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
|
|
Assert.True(viewModel.AddServerCommand.CanExecute(null));
|
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
|
Assert.True(viewModel.IsModalOpen);
|
|
Assert.False(viewModel.AddServerCommand.CanExecute(null));
|
|
Assert.False(viewModel.UpdatePrompt.OpenCommand.CanExecute(null));
|
|
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
|
|
|
|
// ICommand.Execute cannot bypass the modal gate.
|
|
viewModel.AddServerCommand.Execute(null);
|
|
Assert.False(viewModel.EditorDialog.IsOpen);
|
|
Assert.Null(orchestrator.AddedServer);
|
|
|
|
viewModel.CloseActiveModal();
|
|
Assert.False(viewModel.IsModalOpen);
|
|
Assert.True(viewModel.AddServerCommand.CanExecute(null));
|
|
|
|
viewModel.AddServerCommand.Execute(null);
|
|
Assert.True(viewModel.EditorDialog.IsOpen);
|
|
Assert.False(viewModel.FirstRunWizardShell.OpenCommand.CanExecute(null));
|
|
Assert.False(viewModel.ClearFinishedSessionsCommand.CanExecute(null));
|
|
viewModel.CloseActiveModal();
|
|
Assert.False(viewModel.EditorDialog.IsOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FirstRunWizardAutoDetectsValidatesAndPublishesVerifiedInstall()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
Session = FakeLauncherOrchestrator.CreateSession(
|
|
LauncherActivityState.Exited,
|
|
"Exited cleanly."),
|
|
};
|
|
var installer = new FakeLauncherInstaller();
|
|
using var viewModel = CreateInitialized(orchestrator, installer);
|
|
|
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
|
|
|
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
|
|
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
|
Assert.True(viewModel.FirstRunWizardShell.StartCommand.CanExecute(null));
|
|
|
|
viewModel.FirstRunWizardShell.SelectDatDirectory("incomplete-manual-path");
|
|
Assert.False(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
|
viewModel.FirstRunWizardShell.SelectDatDirectory(installer.DetectedDirectory);
|
|
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
|
|
|
viewModel.FirstRunWizardShell.ThreadsText = "3";
|
|
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
|
|
|
Assert.Equal((installer.DetectedDirectory, 3), installer.InstallRequest);
|
|
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
|
|
Assert.False(viewModel.IsFirstRunRequired);
|
|
Assert.Equal(LauncherInstallPhase.Completed, viewModel.FirstRunWizardShell.Phase);
|
|
Assert.Equal(100, viewModel.FirstRunWizardShell.ProgressPercent);
|
|
Assert.False(viewModel.FirstRunWizardShell.HasError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FirstRunWizardCancellationAndFailureRemainVisibleAndPublishNothing()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
Session = FakeLauncherOrchestrator.CreateSession(
|
|
LauncherActivityState.Exited,
|
|
"Exited cleanly."),
|
|
};
|
|
var entered = new TaskCompletionSource(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var installer = new FakeLauncherInstaller
|
|
{
|
|
InstallHandler = async (_, _, progress, cancellationToken) =>
|
|
{
|
|
progress?.Report(new LauncherInstallProgress(
|
|
LauncherInstallPhase.BakingMeshes,
|
|
"Baking mesh assets.",
|
|
1,
|
|
10));
|
|
entered.SetResult();
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
|
throw new InvalidOperationException("unreachable");
|
|
},
|
|
};
|
|
using var viewModel = CreateInitialized(orchestrator, installer);
|
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
|
|
|
Task install = viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
|
await entered.Task.WaitAsync(
|
|
TimeSpan.FromSeconds(5),
|
|
TestContext.Current.CancellationToken);
|
|
Assert.True(viewModel.FirstRunWizardShell.CancelCommand.CanExecute(null));
|
|
Assert.False(viewModel.FirstRunWizardShell.CanEditInputs);
|
|
viewModel.FirstRunWizardShell.CancelCommand.Execute(null);
|
|
await install;
|
|
|
|
Assert.Equal(LauncherInstallPhase.Cancelled, viewModel.FirstRunWizardShell.Phase);
|
|
Assert.Null(orchestrator.InstalledRecord);
|
|
Assert.False(viewModel.FirstRunWizardShell.HasError);
|
|
|
|
installer.InstallHandler = (_, _, _, _) =>
|
|
Task.FromException<LauncherInstallResult>(
|
|
new LauncherInstallException("fixture bake failed"));
|
|
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
|
|
|
Assert.Equal(LauncherInstallPhase.Failed, viewModel.FirstRunWizardShell.Phase);
|
|
Assert.Contains(
|
|
"fixture bake failed",
|
|
viewModel.FirstRunWizardShell.Error ?? string.Empty,
|
|
StringComparison.Ordinal);
|
|
Assert.Null(orchestrator.InstalledRecord);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator();
|
|
using var viewModel = CreateInitialized(orchestrator);
|
|
|
|
LauncherSessionRowViewModel session = Assert.Single(viewModel.Sessions);
|
|
await session.StopCommand.ExecuteAsync();
|
|
Assert.Equal("session-1", orchestrator.StoppedSessionId);
|
|
|
|
orchestrator.Session = FakeLauncherOrchestrator.CreateSession(
|
|
LauncherActivityState.Exited,
|
|
"Exited cleanly.");
|
|
orchestrator.RaiseStateChanged();
|
|
Assert.True(viewModel.ClearFinishedSessionsCommand.CanExecute(null));
|
|
viewModel.ClearFinishedSessionsCommand.Execute(null);
|
|
Assert.True(orchestrator.ClearCalled);
|
|
}
|
|
|
|
private static LauncherWindowViewModel CreateInitialized(
|
|
FakeLauncherOrchestrator orchestrator,
|
|
ILauncherInstaller? installer = null)
|
|
{
|
|
var viewModel = new LauncherWindowViewModel(
|
|
orchestrator,
|
|
new ImmediateUiDispatcher(),
|
|
installer);
|
|
viewModel.Initialize();
|
|
return viewModel;
|
|
}
|
|
|
|
private static void SelectServer(LauncherWindowViewModel viewModel) =>
|
|
viewModel.SelectedNode = Assert.Single(viewModel.Servers);
|
|
|
|
private static void SelectAccount(LauncherWindowViewModel viewModel) =>
|
|
viewModel.SelectedNode = Assert.Single(Assert.Single(viewModel.Servers).Children);
|
|
|
|
private static void SelectCharacter(LauncherWindowViewModel viewModel) =>
|
|
viewModel.SelectedNode = Assert.Single(
|
|
Assert.Single(Assert.Single(viewModel.Servers).Children).Children);
|
|
|
|
private static LauncherPlatformCapabilities LinuxPlatform() => new(
|
|
IsWindows: false,
|
|
IsLinux: true,
|
|
CanRunHeadless: true,
|
|
CanLaunchGraphicalClient: false,
|
|
PlatformName: "Linux",
|
|
GraphicalLaunchDisabledReason:
|
|
LauncherPlatformCapabilities.LinuxGraphicalLaunchDisabledReason);
|
|
|
|
private sealed class FakeLauncherOrchestrator : ILauncherOrchestrator
|
|
{
|
|
public event EventHandler? StateChanged;
|
|
|
|
public bool LoadCalled { get; private set; }
|
|
|
|
public bool ClearCalled { get; private set; }
|
|
|
|
public LauncherPlatformCapabilities Platform { get; init; } = new(
|
|
IsWindows: true,
|
|
IsLinux: false,
|
|
CanRunHeadless: true,
|
|
CanLaunchGraphicalClient: true,
|
|
PlatformName: "Windows",
|
|
GraphicalLaunchDisabledReason: null);
|
|
|
|
public LauncherCapability ProbeCapability { get; set; } = LauncherCapability.Available;
|
|
|
|
public LauncherCapability? AccountLaunchCapability { get; set; }
|
|
|
|
public bool IncludeCharacter { get; init; } = true;
|
|
|
|
public LauncherSessionSnapshot Session { get; set; } = CreateSession();
|
|
|
|
public Func<CancellationToken, Task<LauncherSessionSnapshot>>? LaunchHandler { get; set; }
|
|
|
|
public (string Name, string Host, int Port)? AddedServer { get; private set; }
|
|
|
|
public (string Name, string NewName, string NewHost, int NewPort)? EditedServer { get; private set; }
|
|
|
|
public string? RemovedServer { get; private set; }
|
|
|
|
public (string Server, string Account, string Password)? AddedAccount { get; private set; }
|
|
|
|
public (string Server, string Account, string NewAccount, string? Password)? EditedAccount { get; private set; }
|
|
|
|
public (string Server, string Account)? RemovedAccount { get; private set; }
|
|
|
|
public (string Server, string Account, string Character, string? Id)? AddedCharacter { get; private set; }
|
|
|
|
public (string Server, string Account, string Character, string NewName, string? Id)? EditedCharacter { get; private set; }
|
|
|
|
public (string Server, string Account, string Character)? RemovedCharacter { get; private set; }
|
|
|
|
public (LaunchMode Mode, IReadOnlyList<string> Plugins, IReadOnlyList<string> Commands)? SettingsUpdate { get; private set; }
|
|
|
|
public (string Server, string Account, string? Character, LaunchMode Mode)? LaunchRequest { get; private set; }
|
|
|
|
public (string Server, string Account)? ProbeRequest { get; private set; }
|
|
|
|
public string? StoppedSessionId { get; private set; }
|
|
|
|
public LauncherInstallRecord? InstalledRecord { get; private set; }
|
|
|
|
public void LoadProfiles() => LoadCalled = true;
|
|
|
|
public LauncherStateSnapshot GetSnapshot() => new(
|
|
[CreateServerSnapshot()],
|
|
[Session],
|
|
Platform,
|
|
IsInstallationReady: InstalledRecord is not null,
|
|
InstallationStatus: InstalledRecord is null
|
|
? "No installed client is configured."
|
|
: "Client content verified.");
|
|
|
|
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
|
Platform.ForLaunchMode(mode);
|
|
|
|
public LauncherCapability GetAccountLaunchCapability(
|
|
string serverName,
|
|
string accountName,
|
|
LaunchMode mode) =>
|
|
AccountLaunchCapability ?? GetLaunchCapability(mode);
|
|
|
|
public LauncherCapability GetProbeCapability(string serverName, string accountName) =>
|
|
ProbeCapability;
|
|
|
|
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
|
{
|
|
InstalledRecord = installRecord;
|
|
StateChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void AddServer(string name, string host, int port) =>
|
|
AddedServer = (name, host, port);
|
|
|
|
public void EditServer(string name, string newName, string newHost, int newPort) =>
|
|
EditedServer = (name, newName, newHost, newPort);
|
|
|
|
public void RemoveServer(string name) => RemovedServer = name;
|
|
|
|
public void AddAccount(string serverName, string accountName, string password) =>
|
|
AddedAccount = (serverName, accountName, password);
|
|
|
|
public void EditAccount(
|
|
string serverName,
|
|
string accountName,
|
|
string newAccountName,
|
|
string? newPassword) =>
|
|
EditedAccount = (serverName, accountName, newAccountName, newPassword);
|
|
|
|
public void RemoveAccount(string serverName, string accountName) =>
|
|
RemovedAccount = (serverName, accountName);
|
|
|
|
public void AddCharacter(
|
|
string serverName,
|
|
string accountName,
|
|
string characterName,
|
|
string? characterId) =>
|
|
AddedCharacter = (serverName, accountName, characterName, characterId);
|
|
|
|
public void EditCharacterIdentity(
|
|
string serverName,
|
|
string accountName,
|
|
string characterName,
|
|
string newCharacterName,
|
|
string? newCharacterId) =>
|
|
EditedCharacter = (
|
|
serverName,
|
|
accountName,
|
|
characterName,
|
|
newCharacterName,
|
|
newCharacterId);
|
|
|
|
public void UpdateCharacterSettings(
|
|
string serverName,
|
|
string accountName,
|
|
string characterName,
|
|
LaunchMode launchMode,
|
|
IReadOnlyList<string> plugins,
|
|
IReadOnlyList<string> loginCommands) =>
|
|
SettingsUpdate = (launchMode, plugins, loginCommands);
|
|
|
|
public void RemoveCharacter(
|
|
string serverName,
|
|
string accountName,
|
|
string characterName) =>
|
|
RemovedCharacter = (serverName, accountName, characterName);
|
|
|
|
public Task<LauncherSessionSnapshot> LaunchAsync(
|
|
string serverName,
|
|
string accountName,
|
|
string? characterName,
|
|
LaunchMode mode,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
LaunchRequest = (serverName, accountName, characterName, mode);
|
|
return LaunchHandler?.Invoke(cancellationToken) ?? Task.FromResult(Session);
|
|
}
|
|
|
|
public Task<LauncherSessionSnapshot> ProbeAsync(
|
|
string serverName,
|
|
string accountName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ProbeRequest = (serverName, accountName);
|
|
return Task.FromResult(Session with { Kind = LauncherActivityKind.Probe });
|
|
}
|
|
|
|
public Task StopSessionAsync(
|
|
string sessionId,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
StoppedSessionId = sessionId;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public void PollStatus()
|
|
{
|
|
}
|
|
|
|
public void ClearFinishedSessions() => ClearCalled = true;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
public void RaiseStateChanged() => StateChanged?.Invoke(this, EventArgs.Empty);
|
|
|
|
public static LauncherSessionSnapshot CreateSession(
|
|
LauncherActivityState state = LauncherActivityState.Connected,
|
|
string status = "Connected.") => new(
|
|
"session-1",
|
|
LauncherActivityKind.Play,
|
|
"Local ACE",
|
|
"testaccount",
|
|
"+Acdream",
|
|
LaunchMode.Gui,
|
|
state,
|
|
status,
|
|
ExitCode: state == LauncherActivityState.Exited ? 0 : null,
|
|
Error: null,
|
|
CreatedAt: DateTimeOffset.UnixEpoch);
|
|
|
|
private LauncherServerSnapshot CreateServerSnapshot() => new(
|
|
"Local ACE",
|
|
"127.0.0.1",
|
|
9000,
|
|
[
|
|
new LauncherAccountSnapshot(
|
|
"Local ACE",
|
|
"testaccount",
|
|
IncludeCharacter
|
|
?
|
|
[
|
|
new LauncherCharacterSnapshot(
|
|
"Local ACE",
|
|
"testaccount",
|
|
"+Acdream",
|
|
"0x5000000A",
|
|
LaunchMode.GuiSelect,
|
|
["Existing.Plugin"],
|
|
["/tell someone, ready"],
|
|
HasRunningSession: true,
|
|
SessionStatus: "Connected."),
|
|
]
|
|
: [],
|
|
HasRunningActivity: true,
|
|
ActivityStatus: "Connected."),
|
|
]);
|
|
}
|
|
|
|
private sealed class FakeLauncherInstaller : ILauncherInstaller
|
|
{
|
|
public string DetectedDirectory { get; } = Path.GetFullPath("retail-dats");
|
|
|
|
public LauncherInstallRecord Record { get; }
|
|
|
|
public (string DatDirectory, int Threads)? InstallRequest { get; private set; }
|
|
|
|
public Func<
|
|
string,
|
|
int,
|
|
IProgress<LauncherInstallProgress>?,
|
|
CancellationToken,
|
|
Task<LauncherInstallResult>>? InstallHandler { get; set; }
|
|
|
|
public FakeLauncherInstaller()
|
|
{
|
|
Record = new LauncherInstallRecord(
|
|
DetectedDirectory,
|
|
Path.GetFullPath("data/pak/acdream.pak"),
|
|
new string('a', 64),
|
|
123,
|
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
|
}
|
|
|
|
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
|
|
[
|
|
ValidateDatDirectory(DetectedDirectory),
|
|
];
|
|
|
|
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
|
|
string.Equals(directory, DetectedDirectory, StringComparison.Ordinal)
|
|
? new DatDirectoryValidation(
|
|
DetectedDirectory,
|
|
true,
|
|
"All four required retail DAT files were found.",
|
|
[])
|
|
: new DatDirectoryValidation(
|
|
directory ?? string.Empty,
|
|
false,
|
|
"The DAT directory is incomplete.",
|
|
DatDirectoryLocator.RequiredFileNames);
|
|
|
|
public Task<InstallRecordVerification> LoadExistingAsync(
|
|
CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new InstallRecordVerification(
|
|
InstallRecordVerificationState.Missing,
|
|
null,
|
|
"Client content is not installed."));
|
|
|
|
public Task<LauncherInstallResult> InstallAsync(
|
|
string datDirectory,
|
|
int threads,
|
|
IProgress<LauncherInstallProgress>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
InstallRequest = (datDirectory, threads);
|
|
if (InstallHandler is not null)
|
|
{
|
|
return InstallHandler(
|
|
datDirectory,
|
|
threads,
|
|
progress,
|
|
cancellationToken);
|
|
}
|
|
|
|
progress?.Report(new LauncherInstallProgress(
|
|
LauncherInstallPhase.BakingMeshes,
|
|
"Baking mesh assets.",
|
|
5,
|
|
10));
|
|
progress?.Report(new LauncherInstallProgress(
|
|
LauncherInstallPhase.VerifyingPackage,
|
|
"Verifying package."));
|
|
return Task.FromResult(new LauncherInstallResult(Record));
|
|
}
|
|
}
|
|
}
|