fix(launcher): close LA4 review findings

This commit is contained in:
Erik 2026-08-14 19:02:20 +02:00
parent d0a9c65d85
commit 10a712d66b
19 changed files with 1631 additions and 134 deletions

View file

@ -0,0 +1,67 @@
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Orchestration;
public sealed class LauncherExecutableSetTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-layout-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void FromDirectoryResolvesThePublishedCoDeploymentLayout()
{
Directory.CreateDirectory(_root);
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
string graphical = Path.Combine(_root, "AcDream.App" + suffix);
string headless = Path.Combine(_root, "acdream-headless" + suffix);
File.WriteAllText(graphical, string.Empty);
File.WriteAllText(headless, string.Empty);
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
Assert.Equal(Path.GetFullPath(_root), set.WorkingDirectory);
Assert.Equal(graphical, set.GraphicalHostPath);
Assert.Equal(headless, set.HeadlessHostPath);
Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable);
Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable);
Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable);
Assert.Equal(
graphical,
set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json").ExecutablePath);
Assert.Equal(
headless,
set.CreateProbeSpec("session.json").ExecutablePath);
}
[Fact]
public void MissingPublishedHostsHaveSpecificUnavailableReasons()
{
Directory.CreateDirectory(_root);
LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root);
LauncherCapability gui = set.GetAvailability(LaunchMode.Gui);
LauncherCapability probe = set.GetAvailability(LaunchMode.Headless);
Assert.False(gui.IsAvailable);
Assert.Contains("graphical client", gui.Reason, StringComparison.Ordinal);
Assert.Contains(set.GraphicalHostPath, gui.Reason, StringComparison.Ordinal);
Assert.False(probe.IsAvailable);
Assert.Contains("headless host", probe.Reason, StringComparison.Ordinal);
Assert.Contains(set.HeadlessHostPath, probe.Reason, StringComparison.Ordinal);
Assert.Throws<LauncherOperationException>(() =>
set.CreatePlaySpec(LaunchMode.Gui, "session.json"));
Assert.Throws<LauncherOperationException>(() =>
set.CreateProbeSpec("session.json"));
}
}

View file

@ -106,6 +106,59 @@ public sealed class LauncherOrchestratorTests : IDisposable
Assert.Contains("+Acdream", inWorld.Status, StringComparison.Ordinal);
}
[Fact]
public async Task AccountGuiSelectDoesNotRequireACachedCharacterOrEmitASelector()
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
includeCharacter: false,
configService: config,
supervisorFactory: supervisors);
Assert.True(orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.GuiSelect).IsAvailable);
LauncherSessionSnapshot launched = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
characterName: null,
LaunchMode.GuiSelect);
Assert.Null(launched.CharacterName);
Assert.Equal(LaunchMode.GuiSelect, config.LastCharacter!.LaunchMode);
Assert.Equal(string.Empty, config.LastCharacter.Name);
string json = SessionConfigComposer.Serialize(config.LastComposed!.Document);
Assert.DoesNotContain("\"character\"", json, StringComparison.Ordinal);
Assert.DoesNotContain(Password, json, StringComparison.Ordinal);
Assert.Equal("gui-host", Assert.Single(supervisors.Created).Spec!.ExecutablePath);
}
[Theory]
[InlineData(LaunchMode.Gui)]
[InlineData(LaunchMode.Headless)]
public async Task AccountLaunchWithoutACharacterOnlyAcceptsGuiSelect(LaunchMode mode)
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
includeCharacter: false,
configService: config,
supervisorFactory: supervisors);
await Assert.ThrowsAsync<LauncherOperationException>(() =>
orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
characterName: null,
mode));
Assert.Equal(0, config.PlayCallCount);
Assert.Empty(supervisors.Created);
}
[Fact]
public async Task ProbeIsRefusedWhileTheAccountHasARunningLauncherActivity()
{
@ -126,6 +179,58 @@ public sealed class LauncherOrchestratorTests : IDisposable
orchestrator.ProbeAsync("Local ACE", "testaccount"));
}
[Fact]
public async Task LaunchIsRefusedWhileTheAccountProbeIsRunning()
{
using LauncherOrchestrator orchestrator = CreateOrchestrator();
await orchestrator.ProbeAsync("Local ACE", "testaccount");
LauncherCapability capability = orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.Headless);
Assert.False(capability.IsAvailable);
await Assert.ThrowsAsync<LauncherOperationException>(() =>
orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless));
}
[Fact]
public async Task ConcurrentPlayReservationsAllowExactlyOneActivityPerAccount()
{
using LauncherOrchestrator orchestrator = CreateOrchestrator();
Task<LauncherSessionSnapshot>[] attempts = Enumerable.Range(0, 2)
.Select(_ => Task.Run(async () => await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless)))
.ToArray();
try
{
await Task.WhenAll(attempts);
}
catch (LauncherOperationException)
{
// The losing reservation is the behavior under test.
}
Task<LauncherSessionSnapshot> successful = Assert.Single(
attempts,
attempt => attempt.Status == TaskStatus.RanToCompletion);
Task<LauncherSessionSnapshot> rejected = Assert.Single(attempts, attempt =>
attempt.Exception?.GetBaseException() is LauncherOperationException);
Assert.True(successful.IsCompletedSuccessfully);
Assert.True(rejected.IsFaulted);
Assert.Single(orchestrator.GetSnapshot().Sessions);
}
[Fact]
public async Task ProbeUsesTheProbeShapeAndFoldsTheReportedRosterIntoTheStore()
{
@ -206,6 +311,103 @@ public sealed class LauncherOrchestratorTests : IDisposable
Assert.Empty(supervisors.Created);
}
[Fact]
public async Task MissingCoDeployedHostsDisableActionsBeforeCompositionOrSpawn()
{
var config = new RecordingConfigService();
var supervisors = new FakeSupervisorFactory();
var executables = new LauncherExecutableSet(
"missing-gui",
"missing-headless",
fileExists: _ => false);
using LauncherOrchestrator orchestrator = CreateOrchestrator(
configService: config,
supervisorFactory: supervisors,
executables: executables);
LauncherCapability gui = orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.GuiSelect);
LauncherCapability headless = orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.Headless);
LauncherCapability probe = orchestrator.GetProbeCapability(
"Local ACE",
"testaccount");
Assert.False(gui.IsAvailable);
Assert.Contains("missing-gui", gui.Reason, StringComparison.Ordinal);
Assert.False(headless.IsAvailable);
Assert.Contains("missing-headless", headless.Reason, StringComparison.Ordinal);
Assert.False(probe.IsAvailable);
await Assert.ThrowsAsync<LauncherOperationException>(() =>
orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless));
Assert.Equal(0, config.PlayCallCount);
Assert.Empty(supervisors.Created);
}
[Fact]
public async Task ProcessExitIsTerminalAndLateStatusCannotResurrectTheAccount()
{
var supervisors = new FakeSupervisorFactory();
var statusSources = new QueueStatusSourceFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
statusSourceFactory: statusSources);
await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
Assert.Single(supervisors.Created).Exit(23);
QueueStatusSource source = Assert.Single(statusSources.Created);
source.Enqueue(Connected("s1"));
source.Enqueue(EnteredWorld("s1", "+Acdream"));
source.Enqueue(Exited("s1", 23, "host crash detail"));
orchestrator.PollStatus();
LauncherSessionSnapshot session = Assert.Single(orchestrator.GetSnapshot().Sessions);
Assert.Equal(LauncherActivityState.Exited, session.State);
Assert.Equal(23, session.ExitCode);
Assert.Contains("host crash detail", session.Status, StringComparison.Ordinal);
Assert.True(orchestrator.GetProbeCapability("Local ACE", "testaccount").IsAvailable);
}
[Fact]
public async Task HostExitReasonSurvivesTheLaterProcessExitCallback()
{
var supervisors = new FakeSupervisorFactory();
var statusSources = new QueueStatusSourceFactory();
using LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
statusSourceFactory: statusSources);
await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
Assert.Single(statusSources.Created).Enqueue(
Exited("s1", 0, "graceful host shutdown"));
orchestrator.PollStatus();
Assert.Single(supervisors.Created).Exit(0);
LauncherSessionSnapshot session = Assert.Single(orchestrator.GetSnapshot().Sessions);
Assert.Equal(LauncherActivityState.Exited, session.State);
Assert.Contains("graceful host shutdown", session.Status, StringComparison.Ordinal);
Assert.True(orchestrator.GetAccountLaunchCapability(
"Local ACE",
"testaccount",
LaunchMode.Headless).IsAvailable);
}
[Fact]
public async Task StartFailureIsVisibleButRedactsTheCredentialEverywhere()
{
@ -286,7 +488,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
LauncherPlatformCapabilities? platform = null,
ILauncherSessionConfigService? configService = null,
ILauncherProcessSupervisorFactory? supervisorFactory = null,
IStatusEventSourceFactory? statusSourceFactory = null)
IStatusEventSourceFactory? statusSourceFactory = null,
LauncherExecutableSet? executables = null)
{
string profilePath = Path.Combine(
_paths.ConfigDirectory,
@ -312,7 +515,10 @@ public sealed class LauncherOrchestratorTests : IDisposable
var orchestrator = new LauncherOrchestrator(
store,
_paths,
new LauncherExecutableSet("gui-host", "headless-host"),
executables ?? new LauncherExecutableSet(
"gui-host",
"headless-host",
fileExists: _ => true),
new LauncherInstallRecord("dats", "pak"),
platform ?? WindowsCapabilities(),
configService,
@ -357,6 +563,20 @@ public sealed class LauncherOrchestratorTests : IDisposable
CharacterName = characterName,
};
private static ExitedStatusEvent Exited(
string sessionId,
int code,
string reason) =>
new()
{
V = 1,
E = "exited",
T = DateTimeOffset.UtcNow,
SessionId = sessionId,
Code = code,
Reason = reason,
};
private sealed class RecordingConfigService : ILauncherSessionConfigService
{
public int PlayCallCount { get; private set; }
@ -462,6 +682,13 @@ public sealed class LauncherOrchestratorTests : IDisposable
StateChanged?.Invoke(this, State);
}
public void Exit(int code)
{
State = LauncherSessionState.Exited;
ExitCode = code;
StateChanged?.Invoke(this, State);
}
public void Dispose()
{
}

View file

@ -0,0 +1,171 @@
using System.Text.Json;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.Core.Tests.Profiles;
public sealed class LauncherProfileHardeningTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-launcher-profile-hardening-tests",
Guid.NewGuid().ToString("N"));
private readonly string _filePath;
public LauncherProfileHardeningTests()
{
Directory.CreateDirectory(_root);
_filePath = Path.Combine(_root, "launcher-profiles.json");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void MultiFieldEditValidatesEverythingBeforeChangingAnything()
{
LauncherProfileStore store = CreatePopulatedStore();
Assert.Throws<LauncherProfileException>(() =>
store.EditServer(
"Local ACE",
newName: "Partially renamed",
newHost: "changed.example.test",
newPort: 0));
ServerProfile server = Assert.Single(store.Document.Servers);
Assert.Equal("Local ACE", server.Name);
Assert.Equal("127.0.0.1", server.Host);
Assert.Equal(9000, server.Port);
Assert.Throws<LauncherProfileException>(() =>
store.EditCharacter(
"Local ACE",
"testaccount",
"+Acdream",
newName: "+PartiallyRenamed",
newId: "not-an-id"));
CharacterProfile character = Assert.Single(server.Accounts.Single().Characters);
Assert.Equal("+Acdream", character.Name);
Assert.Equal("0x5000000A", character.Id);
}
[Fact]
public void TransactionRestoresTheExactDocumentWhenPersistenceFails()
{
Directory.CreateDirectory(_filePath);
var store = new LauncherProfileStore(_filePath);
store.Load();
Assert.ThrowsAny<Exception>(() =>
store.ExecuteTransaction(() =>
store.AddServer("Should roll back", "host", 9000)));
Assert.Empty(store.Document.Servers);
Assert.False(File.Exists(_filePath + ".tmp"));
}
[Fact]
public void TransactionRestoresNestedCredentialAndSettingsOnMutationFailure()
{
LauncherProfileStore store = CreatePopulatedStore();
string before = JsonSerializer.Serialize(store.Document);
Assert.Throws<InvalidOperationException>(() =>
store.ExecuteTransaction(() =>
{
AccountProfile account = store.Document.Servers.Single().Accounts.Single();
account.Password = "transient-secret";
account.Characters.Single().Plugins.Add("Transient.Plugin");
throw new InvalidOperationException("simulated mutation failure");
}));
Assert.Equal(before, JsonSerializer.Serialize(store.Document));
}
public static TheoryData<string> InvalidDocuments => new()
{
{ """{"version":1,"servers":null}""" },
{ """{"version":1,"servers":[null]}""" },
{ """{"version":1,"servers":[{"name":" ","host":"h","port":9000,"accounts":[]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":" ","port":9000,"accounts":[]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":0,"accounts":[]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[]},{"name":"s","host":"h2","port":9001,"accounts":[]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[]},{"account":"a","password":"p2","characters":[]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":null,"characters":[]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":null}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x00000000","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"invalid","plugins":[],"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":null,"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":["P","P"],"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[" "]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c","id":"0x50000002","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
{ """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c1","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c2","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" },
};
[Theory]
[MemberData(nameof(InvalidDocuments))]
public void LoadRejectsSemanticallyInvalidDocuments(string json)
{
File.WriteAllText(_filePath, json);
var store = new LauncherProfileStore(_filePath);
Assert.Throws<LauncherProfileException>(() => store.Load());
}
[Fact]
public void FailedLoadDoesNotReplaceAnAlreadyLoadedDocument()
{
LauncherProfileStore store = CreatePopulatedStore();
File.WriteAllText(_filePath, """{"version":1,"servers":null}""");
Assert.Throws<LauncherProfileException>(() => store.Load());
Assert.Equal("Local ACE", Assert.Single(store.Document.Servers).Name);
}
[Fact]
public void LinuxLoadNormalizesAnExistingCredentialFileTo0600BeforeReading()
{
if (!OperatingSystem.IsLinux())
{
return;
}
File.WriteAllText(_filePath, """{"version":1,"servers":[]}""");
File.SetUnixFileMode(
_filePath,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.GroupRead
| UnixFileMode.OtherRead);
var store = new LauncherProfileStore(_filePath);
Assert.True(store.Load());
Assert.Equal(
UnixFileMode.UserRead | UnixFileMode.UserWrite,
File.GetUnixFileMode(_filePath));
}
private LauncherProfileStore CreatePopulatedStore()
{
var store = new LauncherProfileStore(_filePath);
store.Load();
store.AddServer("Local ACE", "127.0.0.1", 9000);
store.AddAccount("Local ACE", "testaccount", "password");
store.AddCharacter(
"Local ACE",
"testaccount",
"+Acdream",
"0x5000000A",
LaunchMode.Headless,
["ExamplePlugin"],
["/vt start"]);
store.Save();
return store;
}
}

View file

@ -152,6 +152,66 @@ public sealed class RosterMergeTests
Assert.Equal("+Acdream", character.Name);
}
[Fact]
public void SameNameRosterEntryCorrectsAWrongValidIdAndPreservesSettings()
{
LauncherProfileStore store = NewStoreWithServerAndAccount();
store.AddCharacter(
"Local ACE",
"testaccount",
"+Acdream",
"0x50000001",
LaunchMode.Headless,
["ExamplePlugin"],
["/vt start"]);
store.MergeRoster(
"Local ACE",
"testaccount",
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
CharacterProfile character = Assert.Single(
store.Document.Servers.Single().Accounts.Single().Characters);
Assert.Equal("0x5000000A", character.Id);
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
Assert.Equal(["ExamplePlugin"], character.Plugins);
Assert.Equal(["/vt start"], character.LoginCommands);
}
[Fact]
public void AuthoritativeMergeCollapsesCorrectIdAndSameNameDuplicates()
{
LauncherProfileStore store = NewStoreWithServerAndAccount();
AccountProfile account = store.Document.Servers.Single().Accounts.Single();
account.Characters.Add(new CharacterProfile
{
Id = "0x5000000A",
Name = "+OldName",
LaunchMode = LaunchMode.Headless,
Plugins = ["Canonical.Plugin"],
LoginCommands = ["/canonical"],
});
account.Characters.Add(new CharacterProfile
{
Id = "0x50000001",
Name = "+Acdream",
LaunchMode = LaunchMode.Gui,
Plugins = ["Duplicate.Plugin"],
LoginCommands = ["/duplicate"],
});
store.MergeRoster(
"Local ACE",
"testaccount",
[new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]);
CharacterProfile character = Assert.Single(account.Characters);
Assert.Equal("0x5000000A", character.Id);
Assert.Equal("+Acdream", character.Name);
Assert.Equal(LaunchMode.Headless, character.LaunchMode);
Assert.Equal(["Canonical.Plugin"], character.Plugins);
}
[Fact]
public void MergeThrowsForUnknownServerOrAccount()
{

View file

@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Xml.Linq;
namespace AcDream.Launcher.Tests;
@ -56,6 +57,95 @@ public sealed class LauncherProjectBoundaryTests
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", paths);
}
[Fact]
public void LinuxPublishEvaluatesAsSelfContainedSingleFile()
{
string root = FindRepositoryRoot();
string projectPath = Path.Combine(
root,
"src",
"AcDream.Launcher",
"AcDream.Launcher.csproj");
Assert.Equal("true", EvaluateProperty(projectPath, "SelfContained"));
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
}
[Fact]
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
{
string root = FindRepositoryRoot();
string markup = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.Launcher",
"MainWindow.axaml"));
string codeBehind = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.Launcher",
"MainWindow.axaml.cs"));
Assert.Equal(3, Count(markup, "KeyboardNavigation.TabNavigation=\"Cycle\""));
Assert.Equal(3, Count(markup, "KeyDown=\"OnModalKeyDown\""));
Assert.True(Count(markup, "AutomationProperties.Name=") >= 13);
Assert.True(Count(markup, "IsDefault=\"True\"") >= 3);
Assert.True(Count(markup, "IsCancel=\"True\"") >= 3);
Assert.Contains("ServerNameTextBox", markup, StringComparison.Ordinal);
Assert.Contains("AccountNameTextBox", markup, StringComparison.Ordinal);
Assert.Contains("CharacterNameTextBox", markup, StringComparison.Ordinal);
Assert.Contains("FocusActiveModal", codeBehind, StringComparison.Ordinal);
Assert.Contains("_focusBeforeModal", codeBehind, StringComparison.Ordinal);
Assert.Contains("Key.Escape", codeBehind, StringComparison.Ordinal);
}
[Fact]
public void PortabilityWorkflowBuildsTestsPublishesAndExecutesTheLauncher()
{
string workflow = File.ReadAllText(Path.Combine(
FindRepositoryRoot(),
".github",
"workflows",
"headless-portability.yml"));
Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal);
Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal);
Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal);
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal);
Assert.Contains("-r linux-x64", workflow, StringComparison.Ordinal);
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
}
private static string EvaluateProperty(string projectPath, string property)
{
var startInfo = new ProcessStartInfo("dotnet")
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
startInfo.ArgumentList.Add("msbuild");
startInfo.ArgumentList.Add(projectPath);
startInfo.ArgumentList.Add("-nologo");
startInfo.ArgumentList.Add("-property:RuntimeIdentifier=linux-x64");
startInfo.ArgumentList.Add($"-getProperty:{property}");
using Process process = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start dotnet msbuild.");
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
Assert.True(process.WaitForExit(30_000), "dotnet msbuild did not exit.");
Assert.True(
process.ExitCode == 0,
$"dotnet msbuild exited {process.ExitCode}: {error}");
return output.Trim();
}
private static int Count(string text, string value) =>
text.Split(value, StringSplitOptions.None).Length - 1;
private static string FindRepositoryRoot()
{
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })

View file

@ -160,6 +160,28 @@ public sealed class LauncherWindowViewModelTests
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()
{
@ -201,6 +223,25 @@ public sealed class LauncherWindowViewModelTests
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()
{
@ -254,6 +295,36 @@ public sealed class LauncherWindowViewModelTests
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()
{
@ -320,6 +391,10 @@ public sealed class LauncherWindowViewModelTests
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; }
@ -344,7 +419,7 @@ public sealed class LauncherWindowViewModelTests
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, string? Character, LaunchMode Mode)? LaunchRequest { get; private set; }
public (string Server, string Account)? ProbeRequest { get; private set; }
@ -362,6 +437,12 @@ public sealed class LauncherWindowViewModelTests
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;
@ -428,7 +509,7 @@ public sealed class LauncherWindowViewModelTests
public Task<LauncherSessionSnapshot> LaunchAsync(
string serverName,
string accountName,
string characterName,
string? characterName,
LaunchMode mode,
CancellationToken cancellationToken = default)
{
@ -481,7 +562,7 @@ public sealed class LauncherWindowViewModelTests
Error: null,
CreatedAt: DateTimeOffset.UnixEpoch);
private static LauncherServerSnapshot CreateServerSnapshot() => new(
private LauncherServerSnapshot CreateServerSnapshot() => new(
"Local ACE",
"127.0.0.1",
9000,
@ -489,18 +570,21 @@ public sealed class LauncherWindowViewModelTests
new LauncherAccountSnapshot(
"Local ACE",
"testaccount",
[
new LauncherCharacterSnapshot(
"Local ACE",
"testaccount",
"+Acdream",
"0x5000000A",
LaunchMode.GuiSelect,
["Existing.Plugin"],
["/tell someone, ready"],
HasRunningSession: true,
SessionStatus: "Connected."),
],
IncludeCharacter
?
[
new LauncherCharacterSnapshot(
"Local ACE",
"testaccount",
"+Acdream",
"0x5000000A",
LaunchMode.GuiSelect,
["Existing.Plugin"],
["/tell someone, ready"],
HasRunningSession: true,
SessionStatus: "Connected."),
]
: [],
HasRunningActivity: true,
ActivityStatus: "Connected."),
]);