docs: Campaign LA — pinned launch-contract schema COMMITTED into plan LA1

The LA3 Opus review process note was right: the contract both sides
implement lived only in orchestrator prompts, which is exactly the drift
mode the pin exists to prevent (and it produced the paths-key CRITICAL).
The schema, field rules, probe-mode discriminator, and status vocabulary
are now a binding plan section; amendments change this text first,
implementations second. Ledger: LA3 fix round dispatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 16:04:32 +02:00
parent 0bcc7ba3a3
commit db9ad53c1c
38 changed files with 2397 additions and 40 deletions

View file

@ -0,0 +1,225 @@
using System.Runtime.CompilerServices;
using AcDream.App.Configuration;
namespace AcDream.App.Tests.Configuration;
/// <summary>
/// Campaign LA slice LA1: proves the App config reader accepts the EXACT
/// document the Headless reader also accepts —
/// <c>tests/Fixtures/campaign-la/session-config-shared-fixture.json</c> is
/// parsed by both <see cref="SessionConfigurationLoader"/> here and
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c> in
/// <c>AcDream.Headless.Tests</c>'s twin of this test. This is the
/// pinned-contract acceptance test from
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1: "a SHARED fixture
/// JSON parsed by both test suites proving the two readers accept the
/// identical document." If either reader's DTO shape drifts from the pinned
/// contract, ONE of these two tests fails.
/// </summary>
public sealed class SessionConfigurationSharedFixtureTests
{
[Fact]
public void AppReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
{
(SessionConfiguration configuration, SessionDescriptor session) =
SessionConfigurationLoader.Load(SharedFixturePath());
Assert.Equal(1, configuration.Version);
Assert.Equal("shared-fixture", session.Id);
Assert.Equal("127.0.0.1", session.Endpoint.Host);
Assert.Equal(9000, session.Endpoint.Port);
Assert.Equal("sharedaccount", session.Account);
Assert.Equal("SharedToon", session.Character?.Name);
// App parses the policy field structurally but never consults it —
// the pinned contract's "parsed-and-ignored" clause.
Assert.Equal("idle", session.Policy?.Id);
Assert.Equal(
SessionCredentialProviderKind.Environment,
session.Credential.Provider);
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference);
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
Assert.Equal(
["/tell someone, hi", "/vt start"],
session.LoginCommands);
Assert.Equal(750, session.LoginCommandDelayMs);
Assert.Equal("shared-fixture-status.jsonl", session.StatusFile);
}
[Fact]
public void AbsentLaunchContractFieldsFallBackToPinnedDefaults()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "no-launch-contract-fields",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "X" }
}
]
}
""");
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
Assert.Null(session.Character);
Assert.Null(session.Plugins);
Assert.Null(session.LoginCommands);
Assert.Equal(500, session.LoginCommandDelayMs);
Assert.Null(session.StatusFile);
}
[Fact]
public void MoreThanOneSessionFailsLoadForTheGraphicalHost()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "one",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "A" }
},
{
"id": "two",
"endpoint": { "host": "127.0.0.1", "port": 9001 },
"account": "account2",
"credential": { "provider": "environment", "reference": "B" }
}
]
}
""");
Assert.Throws<SessionConfigurationException>(
() => SessionConfigurationLoader.Load(file.Path));
}
[Fact]
public void EmptyPluginsEntryFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-plugins",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "X" },
"plugins": ["Ok", " "]
}
]
}
""");
Assert.Throws<SessionConfigurationException>(
() => SessionConfigurationLoader.Load(file.Path));
}
[Fact]
public void NegativeLoginCommandDelayFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-delay",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "X" },
"loginCommandDelayMs": -1
}
]
}
""");
Assert.Throws<SessionConfigurationException>(
() => SessionConfigurationLoader.Load(file.Path));
}
[Fact]
public void BlankStatusFileFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-status-file",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "X" },
"statusFile": " "
}
]
}
""");
Assert.Throws<SessionConfigurationException>(
() => SessionConfigurationLoader.Load(file.Path));
}
internal static string SharedFixturePath(
[CallerFilePath] string sourcePath = "") =>
Path.Combine(
FindRepositoryRoot(sourcePath),
"tests",
"Fixtures",
"campaign-la",
"session-config-shared-fixture.json");
private static string FindRepositoryRoot(string sourcePath)
{
string[] starts =
{
Path.GetDirectoryName(sourcePath) ?? string.Empty,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
};
foreach (string start in starts)
{
if (string.IsNullOrEmpty(start))
continue;
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
"Could not find AcDream.slnx above the working or output directory.");
}
private sealed class TemporaryFile : IDisposable
{
private TemporaryFile(string path) => Path = path;
internal string Path { get; }
internal static TemporaryFile Create(string json)
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-app-la1-{Guid.NewGuid():N}.json");
File.WriteAllText(path, json);
return new TemporaryFile(path);
}
public void Dispose() => File.Delete(Path);
}
}