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:
parent
0bcc7ba3a3
commit
db9ad53c1c
38 changed files with 2397 additions and 40 deletions
177
tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
Normal file
177
tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: pins the exact JSONL status-stream contract both
|
||||
/// the App and Headless hosts write into, and the launcher (a process we
|
||||
/// don't own) reads — see <c>docs/plans/2026-08-14-launcher-campaign.md</c>
|
||||
/// LA1 and <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
|
||||
/// §6.
|
||||
/// </summary>
|
||||
public sealed class SessionStatusWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void EachEventWritesTheExactPinnedShapeInOrder()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create();
|
||||
var writer = new SessionStatusWriter(file.Path);
|
||||
|
||||
writer.Started("s1");
|
||||
writer.Connected("s1");
|
||||
writer.CharacterList(
|
||||
"s1",
|
||||
new LiveSessionRosterReport(
|
||||
"account",
|
||||
11,
|
||||
[
|
||||
new LiveSessionRosterEntry(0x50000001u, "Ready", 0u),
|
||||
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
|
||||
]));
|
||||
writer.EnteredWorld("s1", 0x50000001u, "Ready");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(6, lines.Length);
|
||||
|
||||
JsonElement started = Parse(lines[0]);
|
||||
Assert.Equal(1, started.GetProperty("v").GetInt32());
|
||||
Assert.Equal("started", started.GetProperty("e").GetString());
|
||||
Assert.True(started.TryGetProperty("t", out _));
|
||||
Assert.Equal("s1", started.GetProperty("sessionId").GetString());
|
||||
|
||||
JsonElement connected = Parse(lines[1]);
|
||||
Assert.Equal("connected", connected.GetProperty("e").GetString());
|
||||
Assert.Equal("s1", connected.GetProperty("sessionId").GetString());
|
||||
|
||||
JsonElement characterList = Parse(lines[2]);
|
||||
Assert.Equal("characterList", characterList.GetProperty("e").GetString());
|
||||
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
|
||||
Assert.Equal(11, characterList.GetProperty("slotCount").GetInt32());
|
||||
JsonElement characters = characterList.GetProperty("characters");
|
||||
Assert.Equal(2, characters.GetArrayLength());
|
||||
JsonElement first = characters[0];
|
||||
Assert.Equal(0x50000001u, first.GetProperty("id").GetUInt32());
|
||||
Assert.Equal("Ready", first.GetProperty("name").GetString());
|
||||
Assert.Equal(0u, first.GetProperty("secondsGreyedOut").GetUInt32());
|
||||
|
||||
JsonElement enteredWorld = Parse(lines[3]);
|
||||
Assert.Equal("enteredWorld", enteredWorld.GetProperty("e").GetString());
|
||||
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
|
||||
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
|
||||
|
||||
JsonElement disconnected = Parse(lines[4]);
|
||||
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
|
||||
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
|
||||
|
||||
JsonElement exited = Parse(lines[5]);
|
||||
Assert.Equal("exited", exited.GetProperty("e").GetString());
|
||||
Assert.Equal(0, exited.GetProperty("code").GetInt32());
|
||||
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoOpWriterNeverCreatesAFile()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Reserve();
|
||||
var writer = new SessionStatusWriter(null);
|
||||
|
||||
writer.Started("s1");
|
||||
writer.Connected("s1");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
Assert.False(writer.IsEnabled);
|
||||
Assert.False(File.Exists(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankPathIsTreatedAsAbsent()
|
||||
{
|
||||
var writer = new SessionStatusWriter(" ");
|
||||
|
||||
Assert.False(writer.IsEnabled);
|
||||
// Must not throw even though there is no real path behind it.
|
||||
writer.Started("s1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordNeverAppearsInTheStatusStream()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create();
|
||||
var writer = new SessionStatusWriter(file.Path);
|
||||
|
||||
writer.Started("bot");
|
||||
writer.Connected("bot");
|
||||
writer.CharacterList(
|
||||
"bot",
|
||||
new LiveSessionRosterReport(
|
||||
"account-name",
|
||||
11,
|
||||
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
|
||||
writer.EnteredWorld("bot", 0x50000001u, "Ready");
|
||||
writer.Disconnected("bot", "stopped");
|
||||
writer.Exited("bot", 0, "disposed");
|
||||
|
||||
string contents = File.ReadAllText(file.Path);
|
||||
Assert.DoesNotContain("hunter2", contents, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("password", contents, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileIsOpenedShareReadSoAConcurrentTailerCanReadWhileAppending()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create();
|
||||
var writer = new SessionStatusWriter(file.Path);
|
||||
writer.Started("s1");
|
||||
|
||||
// A concurrent reader (the launcher's tailer) must be able to open
|
||||
// the file for read while the writer holds it — FileShare.Read on
|
||||
// the writer side is what this test is pinning.
|
||||
using FileStream tailer = new(
|
||||
file.Path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite);
|
||||
using var tailerReader = new StreamReader(tailer);
|
||||
string? firstLine = tailerReader.ReadLine();
|
||||
Assert.NotNull(firstLine);
|
||||
Assert.Contains("\"started\"", firstLine);
|
||||
|
||||
// The writer keeps working while the tailer's handle is still open.
|
||||
writer.Connected("s1");
|
||||
string? secondLine = tailerReader.ReadLine();
|
||||
Assert.NotNull(secondLine);
|
||||
Assert.Contains("\"connected\"", secondLine);
|
||||
}
|
||||
|
||||
private static JsonElement Parse(string line) =>
|
||||
JsonDocument.Parse(line).RootElement;
|
||||
|
||||
private sealed class TemporaryFile : IDisposable
|
||||
{
|
||||
private TemporaryFile(string path) => Path = path;
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
internal static TemporaryFile Create()
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-status-{Guid.NewGuid():N}.jsonl");
|
||||
return new TemporaryFile(path);
|
||||
}
|
||||
|
||||
/// <summary>A path that is never actually created — used by the
|
||||
/// no-op test to assert the writer truly never touches disk.</summary>
|
||||
internal static TemporaryFile Reserve() => Create();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(Path))
|
||||
File.Delete(Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue