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
|
|
@ -0,0 +1,148 @@
|
|||
using AcDream.App;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: round-trip tests for
|
||||
/// <see cref="RuntimeOptions.FromSessionConfig"/> — the overlay that turns a
|
||||
/// parsed <see cref="SessionConfiguration"/>/<see cref="SessionDescriptor"/>
|
||||
/// into the same typed bundle the env-var dev flow produces.
|
||||
/// </summary>
|
||||
public sealed class RuntimeOptionsSessionConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void SessionConfigOverridesLiveSettingsAndCarriesAllFiveNewFields()
|
||||
{
|
||||
var config = new SessionConfiguration { Version = 1 };
|
||||
var session = new SessionDescriptor
|
||||
{
|
||||
Id = "gui-session",
|
||||
Endpoint = new SessionEndpointDescriptor
|
||||
{
|
||||
Host = "192.168.1.50",
|
||||
Port = 9123,
|
||||
},
|
||||
Account = "guiaccount",
|
||||
Character = new SessionCharacterSelectorDescriptor { Name = "GuiToon" },
|
||||
Credential = new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "IGNORED",
|
||||
},
|
||||
Plugins = ["PluginA", "PluginB"],
|
||||
LoginCommands = ["/tell x, hi"],
|
||||
LoginCommandDelayMs = 900,
|
||||
StatusFile = "status.jsonl",
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
|
||||
"D:\\dat",
|
||||
_ => null,
|
||||
"session.json",
|
||||
config,
|
||||
session,
|
||||
"resolved-password");
|
||||
|
||||
Assert.True(options.LiveMode);
|
||||
Assert.Equal("192.168.1.50", options.LiveHost);
|
||||
Assert.Equal(9123, options.LivePort);
|
||||
Assert.Equal("guiaccount", options.LiveUser);
|
||||
Assert.Equal("resolved-password", options.LivePass);
|
||||
Assert.Equal("session.json", options.SessionConfigPath);
|
||||
Assert.Equal("gui-session", options.SessionId);
|
||||
Assert.Equal(
|
||||
new LiveSessionCharacterSelector(null, null, "GuiToon"),
|
||||
options.LiveCharacterSelector);
|
||||
Assert.Equal("status.jsonl", options.StatusFilePath);
|
||||
Assert.Equal(["PluginA", "PluginB"], options.Plugins);
|
||||
Assert.Equal(["/tell x, hi"], options.LoginCommands);
|
||||
Assert.Equal(900, options.LoginCommandDelayMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentCharacterSelectorLeavesFirstAvailableFallbackInEffect()
|
||||
{
|
||||
var config = new SessionConfiguration { Version = 1 };
|
||||
var session = new SessionDescriptor
|
||||
{
|
||||
Id = "no-selector",
|
||||
Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 },
|
||||
Account = "account",
|
||||
Credential = new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "X",
|
||||
},
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
|
||||
"D:\\dat",
|
||||
_ => null,
|
||||
"session.json",
|
||||
config,
|
||||
session,
|
||||
"password");
|
||||
|
||||
Assert.Null(options.LiveCharacterSelector);
|
||||
Assert.Null(options.Plugins);
|
||||
Assert.Empty(options.LoginCommands);
|
||||
Assert.Equal(500, options.LoginCommandDelayMs);
|
||||
Assert.Null(options.StatusFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessContentOverridesDatDirectoryAndPreparedAssetPath()
|
||||
{
|
||||
var config = new SessionConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Process = new SessionProcessSettings
|
||||
{
|
||||
Content = new SessionContentDescriptor
|
||||
{
|
||||
DatDirectory = "D:\\configured-dats",
|
||||
PreparedAssetPath = "D:\\configured-dats\\acdream.pak",
|
||||
},
|
||||
},
|
||||
};
|
||||
var session = new SessionDescriptor
|
||||
{
|
||||
Id = "content-session",
|
||||
Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 },
|
||||
Account = "account",
|
||||
Credential = new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "X",
|
||||
},
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
|
||||
"D:\\configured-dats",
|
||||
_ => null,
|
||||
"session.json",
|
||||
config,
|
||||
session,
|
||||
"password");
|
||||
|
||||
Assert.Equal(
|
||||
"D:\\configured-dats\\acdream.pak",
|
||||
options.PreparedAssetPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentFlowLeavesEveryNewFieldAtItsNothingConfiguredDefault()
|
||||
{
|
||||
RuntimeOptions options = RuntimeOptions.Parse("D:\\dat", _ => null);
|
||||
|
||||
Assert.Null(options.SessionConfigPath);
|
||||
Assert.Null(options.SessionId);
|
||||
Assert.Null(options.LiveCharacterSelector);
|
||||
Assert.Null(options.StatusFilePath);
|
||||
Assert.Null(options.Plugins);
|
||||
Assert.Empty(options.LoginCommands);
|
||||
Assert.Equal(500, options.LoginCommandDelayMs);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Credentials;
|
||||
|
||||
namespace AcDream.App.Tests.Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: <see cref="AppCredentialResolver"/> is a minimal
|
||||
/// port of <c>AcDream.Headless.Credentials.HeadlessCredentialResolver</c>
|
||||
/// scoped to the App session-config credential shape — see that file's own
|
||||
/// doc for why it is an independent port rather than a shared reference.
|
||||
/// Mirrors <c>HeadlessCredentialResolverTests</c>'s coverage.
|
||||
/// </summary>
|
||||
public sealed class AppCredentialResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnvironmentSecretIsRedactedAndErasable()
|
||||
{
|
||||
const string variable = "ACDREAM_LA1_TEST_ENV_SECRET";
|
||||
const string secretValue = "test-secret-value";
|
||||
Environment.SetEnvironmentVariable(variable, secretValue);
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
TextReader.Null,
|
||||
Environment.CurrentDirectory,
|
||||
isLinux: false);
|
||||
|
||||
AppCredentialSecret secret = resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = variable,
|
||||
});
|
||||
|
||||
Assert.Equal(secretValue, secret.Reveal());
|
||||
Assert.DoesNotContain(secretValue, secret.ToString());
|
||||
secret.Dispose();
|
||||
Assert.True(secret.IsDisposed);
|
||||
Assert.Throws<ObjectDisposedException>(secret.Reveal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardInputConsumesOneSecretWithoutEchoingIt()
|
||||
{
|
||||
const string secretValue = "stdin-secret";
|
||||
var resolver = new AppCredentialResolver(
|
||||
new StringReader(secretValue + Environment.NewLine),
|
||||
Environment.CurrentDirectory,
|
||||
isLinux: false);
|
||||
|
||||
using AppCredentialSecret secret = resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.StandardInput,
|
||||
Reference = "session-stdin",
|
||||
});
|
||||
|
||||
Assert.Equal(secretValue, secret.Reveal());
|
||||
Assert.DoesNotContain(secretValue, secret.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CredentialFileIsResolvedRelativeToConfiguredDirectory()
|
||||
{
|
||||
string directory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-app-credentials-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "session.pass");
|
||||
File.WriteAllText(path, "file-secret" + Environment.NewLine);
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
TextReader.Null,
|
||||
directory,
|
||||
isLinux: false);
|
||||
|
||||
using AppCredentialSecret secret = resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.File,
|
||||
Reference = "session.pass",
|
||||
});
|
||||
|
||||
Assert.Equal("file-secret", secret.Reveal());
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingSecretErrorNeverContainsAnotherSecret()
|
||||
{
|
||||
const string variable = "ACDREAM_LA1_TEST_OTHER_SECRET";
|
||||
const string unrelatedSecret = "must-not-leak";
|
||||
Environment.SetEnvironmentVariable(variable, unrelatedSecret);
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
new StringReader(string.Empty),
|
||||
Environment.CurrentDirectory,
|
||||
isLinux: false);
|
||||
|
||||
AppCredentialException error =
|
||||
Assert.Throws<AppCredentialException>(() =>
|
||||
resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "ACDREAM_LA1_TEST_DOES_NOT_EXIST",
|
||||
}));
|
||||
|
||||
Assert.DoesNotContain(unrelatedSecret, error.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinuxRejectsGroupOrOtherCredentialPermissions()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
return;
|
||||
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-app-credential-{Guid.NewGuid():N}");
|
||||
File.WriteAllText(path, "linux-secret");
|
||||
File.SetUnixFileMode(
|
||||
path,
|
||||
UnixFileMode.UserRead | UnixFileMode.GroupRead);
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
TextReader.Null,
|
||||
Path.GetDirectoryName(path)!,
|
||||
isLinux: true);
|
||||
|
||||
Assert.Throws<AppCredentialException>(() =>
|
||||
resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.File,
|
||||
Reference = Path.GetFileName(path),
|
||||
}));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ public sealed class LiveSessionShutdownIntegrationTests
|
|||
RuntimeGenerationToken retiringGeneration) { }
|
||||
public void ReportConnecting(string host, int port, string user) { }
|
||||
public void ReportConnected() { }
|
||||
public void ReportRoster(LiveSessionRosterReport roster) { }
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
|
||||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { }
|
||||
public void DetachSession(WorldSession session) { }
|
||||
|
|
|
|||
|
|
@ -917,7 +917,9 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
new LiveSessionConnectOptions(
|
||||
true,
|
||||
"127.0.0.1",
|
||||
|
|
|
|||
|
|
@ -222,7 +222,9 @@ public sealed class HeadlessSessionEventRouteRetryPendingTests
|
|||
new LiveSessionEnteredWorldBindings(
|
||||
_ => { }, () => { }, () => { }, _ => { }, () => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Immutable;
|
|||
using System.Net;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -61,6 +62,95 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.DoesNotContain("AcDream.App", diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: proves the status-event writer fires the
|
||||
/// pinned lifecycle vocabulary — started/connected/characterList/
|
||||
/// enteredWorld/disconnected/exited — in order, from a real
|
||||
/// <see cref="HeadlessSessionHost"/> start+dispose cycle, and that the
|
||||
/// roster surfaced matches <see cref="FixtureSessionOperations.GetCharacters"/>
|
||||
/// exactly (before selection has happened — the roster is reported for
|
||||
/// BOTH candidates, not just the selected one).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StatusFileReceivesThePinnedLifecycleEventsInOrder()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(statusFile: statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult started = host.Start();
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
|
||||
host.Dispose();
|
||||
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
string[] eventNames = lines
|
||||
.Select(line => JsonDocument.Parse(line)
|
||||
.RootElement.GetProperty("e").GetString()!)
|
||||
.ToArray();
|
||||
Assert.Equal(
|
||||
[
|
||||
"started", "connected", "characterList", "enteredWorld",
|
||||
"disconnected", "exited",
|
||||
],
|
||||
eventNames);
|
||||
|
||||
using JsonDocument characterListDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "characterList")]);
|
||||
JsonElement characterList = characterListDoc.RootElement;
|
||||
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
|
||||
Assert.Equal(2, characterList.GetProperty("characters").GetArrayLength());
|
||||
|
||||
using JsonDocument enteredWorldDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "enteredWorld")]);
|
||||
Assert.Equal(
|
||||
0x50000002u,
|
||||
enteredWorldDoc.RootElement.GetProperty("characterId").GetUInt32());
|
||||
|
||||
using JsonDocument exitedDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "exited")]);
|
||||
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
|
||||
|
||||
string contents = File.ReadAllText(statusPath);
|
||||
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentStatusFileConstructsANoOpWriter()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult started = host.Start();
|
||||
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
|
||||
// No exception, and (implicitly) no file was ever touched — the
|
||||
// writer is a permanent no-op with no configured path.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
||||
{
|
||||
|
|
@ -1937,7 +2027,9 @@ public sealed class HeadlessSessionHostTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }));
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }));
|
||||
}
|
||||
|
||||
private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations
|
||||
|
|
@ -1967,7 +2059,8 @@ public sealed class HeadlessSessionHostTests
|
|||
HeadlessCredentialProviderKind provider =
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
string credentialReference = "BOT_PASSWORD",
|
||||
Dictionary<string, bool>? characterOptions = null) => new()
|
||||
Dictionary<string, bool>? characterOptions = null,
|
||||
string? statusFile = null) => new()
|
||||
{
|
||||
Id = "bot",
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
|
|
@ -1990,6 +2083,7 @@ public sealed class HeadlessSessionHostTests
|
|||
Reference = credentialReference,
|
||||
},
|
||||
CharacterOptions = characterOptions,
|
||||
StatusFile = statusFile,
|
||||
};
|
||||
|
||||
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using AcDream.Headless.Configuration;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: proves the Headless config reader accepts the
|
||||
/// EXACT document the App reader also accepts —
|
||||
/// <c>tests/Fixtures/campaign-la/session-config-shared-fixture.json</c> is
|
||||
/// parsed by both <see cref="HeadlessConfigurationLoader"/> here and
|
||||
/// <c>AcDream.App.Configuration.SessionConfigurationLoader</c> in
|
||||
/// <c>AcDream.App.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 HeadlessReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
|
||||
{
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(SharedFixturePath());
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
||||
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);
|
||||
Assert.Equal("idle", session.Policy.Id);
|
||||
Assert.Equal(
|
||||
HeadlessCredentialProviderKind.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()
|
||||
{
|
||||
// Every LA1 field is optional; a document that omits all five must
|
||||
// still load, with loginCommandDelayMs defaulting to the pinned
|
||||
// 500 ms and the rest defaulting to "nothing configured".
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "no-launch-contract-fields",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(file.Path);
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
||||
Assert.Null(session.Plugins);
|
||||
Assert.Null(session.LoginCommands);
|
||||
Assert.Equal(500, session.LoginCommandDelayMs);
|
||||
Assert.Null(session.StatusFile);
|
||||
}
|
||||
|
||||
[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",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"plugins": ["Ok", " "]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.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",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"loginCommandDelayMs": -1
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.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",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"statusFile": " "
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.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-headless-la1-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, json);
|
||||
return new TemporaryFile(path);
|
||||
}
|
||||
|
||||
public void Dispose() => File.Delete(Path);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
||||
var trace = new RuntimeTraceRecorder();
|
||||
|
|
@ -752,7 +754,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
||||
_ = adapter.Session.Start(runtime.Generation);
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ public sealed class LiveSessionControllerTests
|
|||
public Action? OnReset { get; set; }
|
||||
public Action? OnConnecting { get; set; }
|
||||
public Action? OnConnected { get; set; }
|
||||
public Action? OnRoster { get; set; }
|
||||
public Action? OnSelected { get; set; }
|
||||
public Action? OnActivate { get; set; }
|
||||
public Action? OnEntered { get; set; }
|
||||
|
|
@ -146,6 +147,7 @@ public sealed class LiveSessionControllerTests
|
|||
public bool ThrowOnBind { get; set; }
|
||||
public bool ThrowOnConnecting { get; set; }
|
||||
public bool ThrowOnConnected { get; set; }
|
||||
public bool ThrowOnRoster { get; set; }
|
||||
public bool ThrowOnSelected { get; set; }
|
||||
public bool ThrowOnActivate { get; set; }
|
||||
public bool ThrowOnEntered { get; set; }
|
||||
|
|
@ -158,6 +160,7 @@ public sealed class LiveSessionControllerTests
|
|||
public List<TestCommandBus> CommandBuses { get; } = [];
|
||||
public List<LiveSessionCharacterSelection> Selections { get; } = [];
|
||||
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
|
||||
public List<LiveSessionRosterReport> Rosters { get; } = [];
|
||||
|
||||
public LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
|
|
@ -231,6 +234,15 @@ public sealed class LiveSessionControllerTests
|
|||
throw new InvalidOperationException("connected failure");
|
||||
}
|
||||
|
||||
public void ReportRoster(LiveSessionRosterReport roster)
|
||||
{
|
||||
calls.Add("roster");
|
||||
Rosters.Add(roster);
|
||||
OnRoster?.Invoke();
|
||||
if (ThrowOnRoster)
|
||||
throw new InvalidOperationException("roster failure");
|
||||
}
|
||||
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
|
||||
{
|
||||
calls.Add("selected");
|
||||
|
|
@ -290,7 +302,7 @@ public sealed class LiveSessionControllerTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"reset", "resolve", "create", "bind", "report-connecting",
|
||||
"connect", "report-connected", "selected", "enter:1",
|
||||
"connect", "report-connected", "roster", "selected", "enter:1",
|
||||
"activate", "entered",
|
||||
],
|
||||
calls);
|
||||
|
|
@ -302,6 +314,32 @@ public sealed class LiveSessionControllerTests
|
|||
Assert.True(host.CommandBuses[0].Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_ReportsRosterFromCharacterListBeforeSelection()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||
LiveSessionRosterReport roster = Assert.Single(host.Rosters);
|
||||
Assert.Equal("Canonical", roster.AccountName);
|
||||
Assert.Equal(11, roster.SlotCount);
|
||||
Assert.Equal(
|
||||
[
|
||||
new LiveSessionRosterEntry(0x50000001u, "Grey", 10u),
|
||||
new LiveSessionRosterEntry(0x50000002u, "Ready", 0u),
|
||||
],
|
||||
roster.Entries);
|
||||
// "roster" must land strictly before "selected" — the launcher's
|
||||
// char-select screen (LA7/LA8) will read the roster before any
|
||||
// selection has been made.
|
||||
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
|
||||
{
|
||||
|
|
@ -488,7 +526,7 @@ public sealed class LiveSessionControllerTests
|
|||
[
|
||||
"deactivate", "detach-events", "dispose-session", "detach-session",
|
||||
"reset", "resolve", "create", "bind", "report-connecting",
|
||||
"connect", "report-connected", "selected", "enter:1",
|
||||
"connect", "report-connected", "roster", "selected", "enter:1",
|
||||
"activate", "entered",
|
||||
],
|
||||
calls);
|
||||
|
|
@ -742,6 +780,7 @@ public sealed class LiveSessionControllerTests
|
|||
[InlineData("connecting")]
|
||||
[InlineData("connected")]
|
||||
[InlineData("characters")]
|
||||
[InlineData("roster")]
|
||||
[InlineData("selected")]
|
||||
[InlineData("activate")]
|
||||
[InlineData("entered")]
|
||||
|
|
@ -755,6 +794,7 @@ public sealed class LiveSessionControllerTests
|
|||
case "connecting": host.ThrowOnConnecting = true; break;
|
||||
case "connected": host.ThrowOnConnected = true; break;
|
||||
case "characters": operations.ThrowOnCharacters = true; break;
|
||||
case "roster": host.ThrowOnRoster = true; break;
|
||||
case "selected": host.ThrowOnSelected = true; break;
|
||||
case "activate": host.ThrowOnActivate = true; break;
|
||||
case "entered": host.ThrowOnEntered = true; break;
|
||||
|
|
|
|||
|
|
@ -35,12 +35,13 @@ public sealed class LiveSessionHostTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"reset", "resolve", "create", "events", "attach-events", "commands",
|
||||
"connecting", "connect", "connected",
|
||||
"connecting", "connect", "connected", "roster:Canonical",
|
||||
"player:1342177282", "vitals:1342177282",
|
||||
"chat:1342177282", "persistent:1342177282",
|
||||
"vanish:1342177282", "clear-combat", "enter:1",
|
||||
"activate", "active:Ready", "restore-layout",
|
||||
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
|
||||
"character-entered:1342177282",
|
||||
],
|
||||
calls);
|
||||
Assert.Same(controller.CurrentSession, host.CurrentSession);
|
||||
|
|
@ -236,7 +237,10 @@ public sealed class LiveSessionHostTests
|
|||
name => calls.Add($"load-settings:{name}"),
|
||||
() => calls.Add("arm-auto-entry")),
|
||||
Connecting: (_, _, _) => calls.Add("connecting"),
|
||||
Connected: () => calls.Add("connected")));
|
||||
Connected: () => calls.Add("connected"),
|
||||
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
|
||||
CharacterEntered: selection =>
|
||||
calls.Add($"character-entered:{selection.CharacterId}")));
|
||||
|
||||
private static LiveSessionConnectOptions LiveOptions(
|
||||
bool live = true,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ public sealed class LiveSessionLifecycleHostTests
|
|||
host.ResetSessionState(RuntimeGenerationToken.Initial);
|
||||
host.ReportConnecting("host", 9000, "user");
|
||||
host.ReportConnected();
|
||||
host.ReportRoster(new LiveSessionRosterReport("account", 11, []));
|
||||
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
|
||||
host.ApplySelectedCharacter(selection);
|
||||
binding.ActivateCommands();
|
||||
|
|
@ -31,8 +32,8 @@ public sealed class LiveSessionLifecycleHostTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"bind", "reset", "connecting:host:9000:user",
|
||||
"connected", "selected:toon", "activate", "entered:toon",
|
||||
"deactivate", "detach-events", "bind",
|
||||
"connected", "roster:account", "selected:toon", "activate",
|
||||
"entered:toon", "deactivate", "detach-events", "bind",
|
||||
],
|
||||
calls);
|
||||
replacement.Dispose();
|
||||
|
|
@ -71,6 +72,7 @@ public sealed class LiveSessionLifecycleHostTests
|
|||
Connecting: (host, port, user) =>
|
||||
calls.Add($"connecting:{host}:{port}:{user}"),
|
||||
Connected: () => calls.Add("connected"),
|
||||
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
|
||||
Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
|
||||
Entered: selection => calls.Add($"entered:{selection.CharacterName}")));
|
||||
|
||||
|
|
|
|||
|
|
@ -2342,7 +2342,9 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
|
|
|
|||
|
|
@ -1049,7 +1049,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ public sealed class RuntimeLiveSessionNoWindowTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => calls.Add("connecting"),
|
||||
() => calls.Add("connected")),
|
||||
() => calls.Add("connected"),
|
||||
_ => calls.Add("roster"),
|
||||
selection => calls.Add($"character-entered:{selection.CharacterId}")),
|
||||
new LiveSessionConnectOptions(
|
||||
true,
|
||||
"127.0.0.1",
|
||||
|
|
@ -61,10 +63,12 @@ public sealed class RuntimeLiveSessionNoWindowTests
|
|||
"connect",
|
||||
"connected",
|
||||
"characters",
|
||||
"roster",
|
||||
"player:1342177281",
|
||||
"enter:0",
|
||||
"activate-commands",
|
||||
"entered:Runtime",
|
||||
"character-entered:1342177281",
|
||||
"deactivate-commands",
|
||||
"detach-events",
|
||||
"dispose-session",
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +72,9 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
|
|||
host,
|
||||
port,
|
||||
connectingUser),
|
||||
_operations.RecordConnected),
|
||||
_operations.RecordConnected,
|
||||
_operations.RecordRoster,
|
||||
_operations.RecordCharacterEntered),
|
||||
new LiveSessionConnectOptions(
|
||||
true,
|
||||
"127.0.0.1",
|
||||
|
|
@ -693,6 +695,13 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
|
|||
Trace.Add($"connecting:{host}:{port}:{user}");
|
||||
|
||||
public void RecordConnected() => Trace.Add("connected");
|
||||
|
||||
public void RecordRoster(LiveSessionRosterReport roster) =>
|
||||
Trace.Add($"roster:{roster.AccountName}");
|
||||
|
||||
public void RecordCharacterEntered(
|
||||
LiveSessionCharacterSelection selection) =>
|
||||
Trace.Add($"character-entered:{selection.CharacterId}");
|
||||
}
|
||||
|
||||
private sealed class FixtureTransport : IWorldSessionTransport
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "shared-fixture",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "sharedaccount",
|
||||
"character": { "name": "SharedToon" },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": {
|
||||
"provider": "environment",
|
||||
"reference": "SHARED_FIXTURE_PASSWORD"
|
||||
},
|
||||
"plugins": ["ExamplePlugin", "AnotherPlugin"],
|
||||
"loginCommands": ["/tell someone, hi", "/vt start"],
|
||||
"loginCommandDelayMs": 750,
|
||||
"statusFile": "shared-fixture-status.jsonl"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue