using AcDream.Headless.Configuration; using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Orchestration; using AcDream.Launcher.Core.Profiles; namespace AcDream.Headless.Tests; /// /// The launcher spawns the headless host; the headless host decides what a /// valid command line is. Nothing connected those two facts, and they drifted: /// the launcher passed --config <path> while /// reads arguments[0] as the /// command and accepts only validate or run. Every /// launcher-started headless session and every character refresh therefore died /// instantly with "Invalid command. Run --help for usage." — visible only as an /// exit code in a status file, which is why it survived a whole campaign. /// /// These tests are the missing connection: they take the argument vector /// the launcher will really use and hand it to the parser the host will really /// use. The two cannot drift again without failing here. /// public sealed class LauncherHeadlessCommandLineContractTests : IDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "acdream-headless-cli-contract", Guid.NewGuid().ToString("N")); public LauncherHeadlessCommandLineContractTests() { // The spec builders refuse to name an executable that is not there, so // the contract can only be exercised against files that exist. Directory.CreateDirectory(AppDirectory); string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; File.WriteAllText(Path.Combine(AppDirectory, "AcDream.App" + suffix), "stub"); File.WriteAllText(Path.Combine(AppDirectory, "acdream-headless" + suffix), "stub"); } public void Dispose() { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } } [Fact] public void ProbeArgumentsParseAsAHeadlessRunCommand() { LauncherProcessSpec spec = ExecutableSet().CreateProbeSpec(ConfigPath); HeadlessCommandLine parsed = HeadlessCommandLine.Parse(spec.Arguments); Assert.Equal("run", parsed.Command); Assert.Equal(ConfigPath, parsed.ConfigurationPath); } [Fact] public void HeadlessPlayArgumentsParseAsAHeadlessRunCommand() { LauncherProcessSpec spec = ExecutableSet() .CreatePlaySpec(LaunchMode.Headless, ConfigPath); HeadlessCommandLine parsed = HeadlessCommandLine.Parse(spec.Arguments); Assert.Equal("run", parsed.Command); Assert.Equal(ConfigPath, parsed.ConfigurationPath); } /// /// The graphical host is a DIFFERENT program with a different grammar — it /// takes a bare --session-config and has no command word. Copying /// that shape onto the headless call is exactly the mistake this file /// exists to prevent, so pin the difference rather than leaving it implied. /// [Theory] [InlineData(LaunchMode.Gui)] [InlineData(LaunchMode.GuiSelect)] public void GraphicalArgumentsAreNotAHeadlessCommandLine(LaunchMode mode) { LauncherProcessSpec spec = ExecutableSet().CreatePlaySpec(mode, ConfigPath); Assert.Equal(["--session-config", ConfigPath], spec.Arguments); Assert.Throws( () => HeadlessCommandLine.Parse(spec.Arguments)); } private string AppDirectory => Path.Combine(_root, "app"); private string ConfigPath => Path.Combine(_root, "session.json"); private LauncherExecutableSet ExecutableSet() => LauncherExecutableSet.FromDirectory(AppDirectory); }