The launcher spawned the headless host as
acdream-headless --config <path>
but HeadlessCommandLine.Parse reads arguments[0] as the COMMAND and accepts
only "validate" or "run". So every launcher-started headless session and every
"Refresh characters" died on its first instruction with
Invalid command. Run --help for usage. (exit 64)
The user's own cache shows it six times over two days. It was invisible because
the failure is an exit code in a status file, not something the UI says out
loud — which is how it survived a whole campaign whose gates exercised the
headless host through its CLI directly, never through the launcher's spec.
The graphical host takes a bare "--session-config" and has no command word;
this sibling call was written to match it. Both headless call sites now pass
"run" first. A probe is an ordinary "run" whose session config carries
mode: "probe" — the difference is in the document, not the command line, so
one fix repairs refresh and headless play together.
LauncherHeadlessCommandLineContractTests is the connection that was missing:
it takes the argument vector the launcher will really use and hands it to the
parser the host will really use, for probe and for headless play, and pins that
the graphical arguments are deliberately NOT a headless command line. The two
sides cannot drift again without failing here. Headless.Tests already
referenced both assemblies, so this needed no new coupling.
Also LU7, at the user's direction: a selected character now offers only Play
and Headless. Choosing a character means choosing to play AS that character, so
"Character select" — which deliberately picks no character — belongs to the
account page alone, where it already lives. The per-character GuiSelect command
and its capability are removed rather than left as dead surface.
Full solution 14,374 passed, 0 failed under the release-gate filter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
936 lines
38 KiB
C#
936 lines
38 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);
|
|
// LU6: rows read as account / character / plain state. The launch mode
|
|
// is gone, and "Connected" is shown as what it means to a player.
|
|
Assert.Equal("testaccount", session.Account);
|
|
Assert.Equal("+Acdream", session.Character);
|
|
Assert.Equal("Character select", session.State);
|
|
Assert.True(session.IsActive);
|
|
|
|
Assert.True(viewModel.IsFirstRunRequired);
|
|
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
|
// LU3: the update question says nothing and shows nothing until the
|
|
// startup check actually finds something out of date.
|
|
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
|
Assert.Empty(viewModel.UpdatePrompt.Body);
|
|
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);
|
|
// LU7: a selected character offers only Play and Headless. Character
|
|
// select is an account-level action and no longer has a per-character
|
|
// command to gate.
|
|
Assert.False(viewModel.CanLaunchAccountGuiSelect);
|
|
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));
|
|
// LU3: there is no user-openable update panel any more — the update
|
|
// question only ever appears by itself, at startup.
|
|
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
|
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);
|
|
|
|
// LU1: nothing has asked for a verification yet. Startup's own
|
|
// verification happens in the composition root, not here.
|
|
Assert.Empty(installer.LoadExistingCalls);
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LU4. A successful first-run setup ends with a plain "Setup complete"
|
|
/// panel and one OK button that returns to the launcher — not a finished
|
|
/// progress bar the user has to decide what to do with.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task FirstRunSetupEndsWithACompletionPanelThatOkReturnsFrom()
|
|
{
|
|
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.False(viewModel.FirstRunWizardShell.IsCompleted);
|
|
Assert.True(viewModel.FirstRunWizardShell.ShowSetupForm);
|
|
|
|
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
|
|
|
Assert.True(viewModel.FirstRunWizardShell.IsCompleted);
|
|
// The form is gone; the completion panel is what the user sees.
|
|
Assert.False(viewModel.FirstRunWizardShell.ShowSetupForm);
|
|
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
|
// The record is already published, so the launcher behind the dialog
|
|
// is in its launch-enabled state before OK is pressed.
|
|
Assert.NotNull(orchestrator.InstalledRecord);
|
|
|
|
viewModel.FirstRunWizardShell.AcknowledgeCompletionCommand.Execute(null);
|
|
|
|
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
|
|
Assert.False(viewModel.FirstRunWizardShell.IsCompleted);
|
|
Assert.True(viewModel.FirstRunWizardShell.ShowSetupForm);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LU4. Cancellation and failure must NOT claim success. They keep their
|
|
/// existing status/error reporting and leave the form on screen.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AFailedFirstRunSetupNeverShowsTheCompletionPanel()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
Session = FakeLauncherOrchestrator.CreateSession(
|
|
LauncherActivityState.Exited,
|
|
"Exited cleanly."),
|
|
};
|
|
var installer = new FakeLauncherInstaller
|
|
{
|
|
InstallHandler = (_, _, _, _) =>
|
|
Task.FromException<LauncherInstallResult>(
|
|
new LauncherInstallException("The bake tool exited with code 3.")),
|
|
};
|
|
using var viewModel = CreateInitialized(orchestrator, installer);
|
|
|
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
|
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
|
|
|
Assert.False(viewModel.FirstRunWizardShell.IsCompleted);
|
|
Assert.True(viewModel.FirstRunWizardShell.ShowSetupForm);
|
|
Assert.True(viewModel.FirstRunWizardShell.HasError);
|
|
Assert.Null(orchestrator.InstalledRecord);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LU1. Ordinary startup trusts a remembered digest so the window is not
|
|
/// held behind a multi-second hash of a ~28 GiB file; "Verify files" is
|
|
/// the deliberate way to make it read the whole package again, so it must
|
|
/// force the full hash rather than hit the same fast path.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task VerifyFilesForcesAFullHashAndPublishesTheResult()
|
|
{
|
|
// Verification is gated on no session running, same as install and
|
|
// update: a failed verification clears the install record, and doing
|
|
// that under a live client would be incoherent.
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
Session = FakeLauncherOrchestrator.CreateSession(
|
|
LauncherActivityState.Exited,
|
|
"Exited cleanly."),
|
|
};
|
|
var record = new LauncherInstallRecord(
|
|
"C:/dats",
|
|
"C:/data/pak/acdream.pak",
|
|
new string('a', 64),
|
|
4096,
|
|
4);
|
|
var installer = new FakeLauncherInstaller
|
|
{
|
|
NextVerification = new InstallRecordVerification(
|
|
InstallRecordVerificationState.Verified,
|
|
record,
|
|
"Client content verified."),
|
|
};
|
|
using var viewModel = CreateInitialized(orchestrator, installer);
|
|
|
|
await viewModel.VerifyContentCommand.ExecuteAsync();
|
|
|
|
Assert.Equal([true], installer.LoadExistingCalls);
|
|
Assert.Same(record, orchestrator.InstalledRecord);
|
|
Assert.Equal("Client content verified.", viewModel.OperationStatus);
|
|
Assert.Null(viewModel.LastError);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LU1. A package that fails its full verification must clear the install
|
|
/// record — the launcher may not start a client against content it just
|
|
/// proved is wrong — and must say why.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task VerifyFilesClearsTheInstallRecordWhenVerificationFails()
|
|
{
|
|
using var orchestrator = new FakeLauncherOrchestrator
|
|
{
|
|
Session = FakeLauncherOrchestrator.CreateSession(
|
|
LauncherActivityState.Exited,
|
|
"Exited cleanly."),
|
|
};
|
|
var installer = new FakeLauncherInstaller
|
|
{
|
|
NextVerification = new InstallRecordVerification(
|
|
InstallRecordVerificationState.Invalid,
|
|
null,
|
|
"The prepared package SHA-256 does not match the install record."),
|
|
};
|
|
using var viewModel = CreateInitialized(orchestrator, installer);
|
|
|
|
await viewModel.VerifyContentCommand.ExecuteAsync();
|
|
|
|
Assert.Null(orchestrator.InstalledRecord);
|
|
Assert.Contains("SHA-256", viewModel.OperationStatus, StringComparison.Ordinal);
|
|
Assert.Contains("SHA-256", viewModel.LastError!, StringComparison.Ordinal);
|
|
}
|
|
|
|
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);
|
|
|
|
/// <summary>LU1: what the next <see cref="LoadExistingAsync"/> returns.
|
|
/// Defaults to "not installed", which is what every pre-existing test
|
|
/// in this file expects.</summary>
|
|
public InstallRecordVerification NextVerification { get; set; } =
|
|
new(
|
|
InstallRecordVerificationState.Missing,
|
|
null,
|
|
"Client content is not installed.");
|
|
|
|
/// <summary>LU1: the <c>forceFullVerification</c> values this fake was
|
|
/// called with, oldest first.</summary>
|
|
public List<bool> LoadExistingCalls { get; } = [];
|
|
|
|
public Task<InstallRecordVerification> LoadExistingAsync(
|
|
CancellationToken cancellationToken = default,
|
|
bool forceFullVerification = false)
|
|
{
|
|
LoadExistingCalls.Add(forceFullVerification);
|
|
return Task.FromResult(NextVerification);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
}
|