Agent was stopped for token budget. Landed here: probe flag through LiveSessionConnectOptions + the StartCore short-circuit, the mode field with JsonRequired-to-semantic-validation move, host exit-code mapping, and 34 passing tests including 3 new probe tests (agent last reported green before the stop). NOT DONE: the idle-policy unit tests (next step), full-suite verification, and the WSL run. Build/test state UNVERIFIED at this commit. Next session: finish idle policy tests, run Runtime+Headless Release suites Windows and WSL, then dispatch the Opus dual-lens review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
455 lines
16 KiB
C#
455 lines
16 KiB
C#
using System.Text.Json;
|
|
using AcDream.Headless.Configuration;
|
|
|
|
namespace AcDream.Headless.Tests;
|
|
|
|
/// <summary>
|
|
/// Campaign OP slice OP7 (2026-08-11), D8: schema tests for the optional
|
|
/// per-session <c>characterOptions</c> block —
|
|
/// docs/plans/2026-08-10-options-panel-campaign.md §4 OP7. Exercises
|
|
/// <see cref="HeadlessConfigurationLoader.Load"/> directly (rather than
|
|
/// through <c>HeadlessEntryPoint</c>'s CLI wrapper) so assertions can pin the
|
|
/// exact exception type/message for the semantic "unknown name" rejection,
|
|
/// matching the loader's own established split: type-shape violations
|
|
/// (missing required field, wrong JSON value kind) fail during
|
|
/// deserialization itself with a raw <see cref="JsonException"/>; semantic
|
|
/// violations of an already-well-typed value fail with
|
|
/// <see cref="HeadlessConfigurationException"/> (see
|
|
/// <see cref="HeadlessConfigurationLoader"/>'s own doc comment on
|
|
/// <c>ValidateCharacterOptions</c>).
|
|
/// </summary>
|
|
public sealed class HeadlessConfigurationLoaderTests
|
|
{
|
|
[Fact]
|
|
public void ValidCharacterOptionsBlockParsesExactDeclaredNames()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session(
|
|
"bot",
|
|
"BOT_PASSWORD",
|
|
"""
|
|
"characterOptions":{
|
|
"IgnoreAllegianceRequests":true,
|
|
"ListenToTradeChat":false,
|
|
"AutoTarget":true
|
|
}
|
|
""")));
|
|
|
|
HeadlessConfiguration configuration =
|
|
HeadlessConfigurationLoader.Load(file.Path);
|
|
|
|
Dictionary<string, bool>? declared =
|
|
Assert.Single(configuration.Sessions)!.CharacterOptions;
|
|
Assert.NotNull(declared);
|
|
Assert.Equal(3, declared!.Count);
|
|
Assert.True(declared["IgnoreAllegianceRequests"]);
|
|
Assert.False(declared["ListenToTradeChat"]);
|
|
Assert.True(declared["AutoTarget"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownOptionNameFailsLoadNamingTheOffendingKey()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session(
|
|
"bot",
|
|
"BOT_PASSWORD",
|
|
"\"characterOptions\":{\"NotARealOption\":true}")));
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains(
|
|
"NotARealOption",
|
|
exception.Message,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void PresentationOnlyTierThreeOptionNameFailsLoad()
|
|
{
|
|
// ShowHelm is a REAL CharacterOptionId member (0x2F) but is
|
|
// deliberately outside the tier-1+2 bot-declarable subset (research
|
|
// doc §5.2 tier 3) — excluded by construction, not merely absent
|
|
// from an allow-list that forgot it.
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session(
|
|
"bot",
|
|
"BOT_PASSWORD",
|
|
"\"characterOptions\":{\"ShowHelm\":true}")));
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains(
|
|
"ShowHelm",
|
|
exception.Message,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void NumericKeyFailsLoadInsteadOfAliasingIntoAnAllowedId()
|
|
{
|
|
// SF-1 (OP7 review fix, 2026-08-11): Enum.TryParse on a non-[Flags]
|
|
// enum accepts a decimal numeric string — "15" parsed to 0x0F
|
|
// (FellowshipShareXP), which IS allow-listed, so the old
|
|
// parsed-value check let a key that is not an enum-member spelling
|
|
// at all silently pass. Validation must reject the STRING.
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session(
|
|
"bot",
|
|
"BOT_PASSWORD",
|
|
"\"characterOptions\":{\"15\":true}")));
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains("15", exception.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void CommaCombinedKeyFailsLoadInsteadOfOrCombiningIntoAnAllowedId()
|
|
{
|
|
// SF-1 companion case: Enum.TryParse OR-combines a comma-separated
|
|
// member list — "ToggleRun,AutoTarget" (0x0A | 0x0D) parsed to
|
|
// 0x0F (FellowshipShareXP), again allow-listed, so a config that
|
|
// reads as two movement options would have silently set fellowship
|
|
// XP sharing instead.
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session(
|
|
"bot",
|
|
"BOT_PASSWORD",
|
|
"\"characterOptions\":{\"ToggleRun,AutoTarget\":true}")));
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains(
|
|
"ToggleRun,AutoTarget",
|
|
exception.Message,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void BothFellowshipExclusionOptionsTrueFailsLoadNamingBothKeys()
|
|
{
|
|
// SF-2 (OP7 review fix, 2026-08-11): retail's own OnChanged mutual
|
|
// exclusion means IgnoreFellowshipRequests and
|
|
// FellowshipAutoAcceptRequests can never both be true at once —
|
|
// turning one on always clears the other. A config declaring both
|
|
// true would have the seeder oscillate (re-send) on every connect
|
|
// forever with neither value ever actually honoured. Reject it at
|
|
// load instead.
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session(
|
|
"bot",
|
|
"BOT_PASSWORD",
|
|
"\"characterOptions\":{\"IgnoreFellowshipRequests\":true,"
|
|
+ "\"FellowshipAutoAcceptRequests\":true}")));
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains(
|
|
"IgnoreFellowshipRequests",
|
|
exception.Message,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains(
|
|
"FellowshipAutoAcceptRequests",
|
|
exception.Message,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonBoolValueFailsLoad()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session(
|
|
"bot",
|
|
"BOT_PASSWORD",
|
|
"\"characterOptions\":{\"IgnoreAllegianceRequests\":\"yes\"}")));
|
|
|
|
Assert.ThrowsAny<Exception>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
}
|
|
|
|
[Fact]
|
|
public void AbsentCharacterOptionsBlockIsANoOp()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session("bot", "BOT_PASSWORD")));
|
|
|
|
HeadlessConfiguration configuration =
|
|
HeadlessConfigurationLoader.Load(file.Path);
|
|
|
|
Assert.Null(Assert.Single(configuration.Sessions)!.CharacterOptions);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyCharacterOptionsBlockIsANoOp()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
ConfigurationWith(Session(
|
|
"bot",
|
|
"BOT_PASSWORD",
|
|
"\"characterOptions\":{}")));
|
|
|
|
HeadlessConfiguration configuration =
|
|
HeadlessConfigurationLoader.Load(file.Path);
|
|
|
|
Dictionary<string, bool>? declared =
|
|
Assert.Single(configuration.Sessions)!.CharacterOptions;
|
|
Assert.NotNull(declared);
|
|
Assert.Empty(declared!);
|
|
}
|
|
|
|
// ── Campaign LA slice LA2: probe-mode `mode` field shape validation ──
|
|
|
|
[Fact]
|
|
public void ProbeSessionOmittingCharacterAndPolicyLoads()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
"""
|
|
{
|
|
"version": 1,
|
|
"sessions": [
|
|
{
|
|
"id": "probe-session",
|
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
|
"account": "account",
|
|
"mode": "probe",
|
|
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
HeadlessConfiguration configuration =
|
|
HeadlessConfigurationLoader.Load(file.Path);
|
|
|
|
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
|
Assert.Null(session.Character);
|
|
Assert.Null(session.Policy);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
"""
|
|
{
|
|
"version": 1,
|
|
"sessions": [
|
|
{
|
|
"id": "probe-with-character",
|
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
|
"account": "account",
|
|
"mode": "probe",
|
|
"character": { "index": 0 },
|
|
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
|
|
Assert.Contains("character", exception.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProbeSessionDeclaringPolicyFailsLoadNamingTheField()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
"""
|
|
{
|
|
"version": 1,
|
|
"sessions": [
|
|
{
|
|
"id": "probe-with-policy",
|
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
|
"account": "account",
|
|
"mode": "probe",
|
|
"policy": { "id": "idle" },
|
|
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
|
|
Assert.Contains("policy", exception.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A play session (mode absent) missing `character` must still fail —
|
|
/// the LA2 change moved this requiredness from `[JsonRequired]` (a raw
|
|
/// <see cref="System.Text.Json.JsonException"/> at deserialize time) to
|
|
/// <see cref="HeadlessConfigurationLoader.ValidateSession"/>'s semantic
|
|
/// check (a <see cref="HeadlessConfigurationException"/> naming the
|
|
/// missing field). Exit-code parity (both map to
|
|
/// <c>HeadlessExitCode.ConfigurationError</c>) is proven at
|
|
/// <c>HeadlessEntryPointTests</c>; this test pins the loader-level
|
|
/// exception type/message.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PlaySessionMissingCharacterStillFailsLoad()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
"""
|
|
{
|
|
"version": 1,
|
|
"sessions": [
|
|
{
|
|
"id": "play-missing-character",
|
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
|
"account": "account",
|
|
"policy": { "id": "idle" },
|
|
"credential": { "provider": "environment", "reference": "PLAY_PASSWORD" }
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains(
|
|
"requires a character selector",
|
|
exception.Message,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>Same parity claim as
|
|
/// <see cref="PlaySessionMissingCharacterStillFailsLoad"/> for the
|
|
/// `policy` field.</summary>
|
|
[Fact]
|
|
public void PlaySessionMissingPolicyStillFailsLoad()
|
|
{
|
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
|
"""
|
|
{
|
|
"version": 1,
|
|
"sessions": [
|
|
{
|
|
"id": "play-missing-policy",
|
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
|
"account": "account",
|
|
"character": { "index": 0 },
|
|
"credential": { "provider": "environment", "reference": "PLAY_PASSWORD" }
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
HeadlessConfigurationException exception = Assert.Throws<
|
|
HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
|
|
|
Assert.Contains(
|
|
"requires a non-empty policy id",
|
|
exception.Message,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlaySessionKeepsTodaysStrictCharacterSelectorAndPolicyValidation()
|
|
{
|
|
// Unrelated to `mode` — proves the LA2 refactor of ValidateSession
|
|
// did not loosen the existing selector-shape/policy-id checks for
|
|
// ordinary play sessions (mode absent).
|
|
using TemporaryConfiguration badSelector = TemporaryConfiguration.Create(
|
|
"""
|
|
{
|
|
"version": 1,
|
|
"sessions": [
|
|
{
|
|
"id": "bad-selector",
|
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
|
"account": "account",
|
|
"character": { "index": 0, "name": "Two" },
|
|
"policy": { "id": "idle" },
|
|
"credential": { "provider": "environment", "reference": "A" }
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
using TemporaryConfiguration blankPolicy = TemporaryConfiguration.Create(
|
|
"""
|
|
{
|
|
"version": 1,
|
|
"sessions": [
|
|
{
|
|
"id": "blank-policy",
|
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
|
"account": "account",
|
|
"character": { "index": 0 },
|
|
"policy": { "id": "" },
|
|
"credential": { "provider": "environment", "reference": "B" }
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
Assert.Throws<HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(badSelector.Path));
|
|
Assert.Throws<HeadlessConfigurationException>(
|
|
() => HeadlessConfigurationLoader.Load(blankPolicy.Path));
|
|
}
|
|
|
|
private static string ConfigurationWith(params string[] sessions) =>
|
|
$$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}""";
|
|
|
|
private static string Session(
|
|
string id,
|
|
string credentialReference,
|
|
string? extraTopLevelField = null)
|
|
{
|
|
string suffix = extraTopLevelField is null
|
|
? string.Empty
|
|
: $",{extraTopLevelField}";
|
|
return $"{{\"id\":\"{id}\",\"endpoint\":{{\"host\":\"127.0.0.1\",\"port\":9000}},"
|
|
+ "\"account\":\"account\",\"character\":{\"index\":0},"
|
|
+ "\"policy\":{\"id\":\"idle\"},\"credential\":"
|
|
+ $"{{\"provider\":\"environment\",\"reference\":\"{credentialReference}\"}}"
|
|
+ suffix
|
|
+ "}";
|
|
}
|
|
|
|
private sealed class TemporaryConfiguration : IDisposable
|
|
{
|
|
private TemporaryConfiguration(string path)
|
|
{
|
|
Path = path;
|
|
}
|
|
|
|
internal string Path { get; }
|
|
|
|
internal static TemporaryConfiguration Create(string json)
|
|
{
|
|
string path = System.IO.Path.Combine(
|
|
System.IO.Path.GetTempPath(),
|
|
$"acdream-headless-op7-{Guid.NewGuid():N}.json");
|
|
File.WriteAllText(path, json);
|
|
return new TemporaryConfiguration(path);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
File.Delete(Path);
|
|
}
|
|
}
|
|
}
|