using AcDream.Launcher.Core.Orchestration; using AcDream.Launcher.Core.Profiles; using AcDream.Launcher.Core.Launching; 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("LA9", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal); Assert.Contains("LA10", viewModel.UpdatePromptShell.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.UpdatePromptShell.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 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) { var viewModel = new LauncherWindowViewModel( orchestrator, new ImmediateUiDispatcher()); 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>? 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 Plugins, IReadOnlyList 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 void LoadProfiles() => LoadCalled = true; public LauncherStateSnapshot GetSnapshot() => new( [CreateServerSnapshot()], [Session], Platform, IsInstallationReady: false, InstallationStatus: "No installed client is configured."); 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) { } 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 plugins, IReadOnlyList loginCommands) => SettingsUpdate = (launchMode, plugins, loginCommands); public void RemoveCharacter( string serverName, string accountName, string characterName) => RemovedCharacter = (serverName, accountName, characterName); public Task 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 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."), ]); } }