using System.Text.Json;
using AcDream.Headless.Configuration;
namespace AcDream.Headless.Tests;
///
/// Campaign OP slice OP7 (2026-08-11), D8: schema tests for the optional
/// per-session characterOptions block —
/// docs/plans/2026-08-10-options-panel-campaign.md §4 OP7. Exercises
/// directly (rather than
/// through HeadlessEntryPoint'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 ; semantic
/// violations of an already-well-typed value fail with
/// (see
/// 's own doc comment on
/// ValidateCharacterOptions).
///
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? 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(
() => 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? 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);
}
///
/// The pinned v1 contract has one named mode value: "probe".
/// In particular, the enum's underlying numeric zero must not become an
/// accidental second spelling through an enum converter configured to
/// allow integers.
///
[Theory]
[InlineData("\"play\"")]
[InlineData("0")]
public void SessionModeRejectsEveryValueOtherThanTheNamedProbeMode(
string modeJson)
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
$$"""
{
"version": 1,
"sessions": [
{
"id": "unsupported-mode",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"mode": {{modeJson}},
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
}
]
}
""");
Assert.Throws(
() => HeadlessConfigurationLoader.Load(file.Path));
}
///
/// Campaign LA LA2 review fix: the pinned contract is presence-aware.
/// Normal play omits mode and supplies character/policy; probe supplies
/// mode and omits character/policy. JSON null is not another spelling of
/// omission for any of those conditional fields.
///
[Theory]
[InlineData(false, "mode")]
[InlineData(false, "character")]
[InlineData(false, "policy")]
[InlineData(true, "character")]
[InlineData(true, "policy")]
public void ConditionalSessionFieldsRejectExplicitJsonNull(
bool probe,
string nullField)
{
string mode = probe
? "\"mode\":\"probe\","
: nullField == "mode"
? "\"mode\":null,"
: string.Empty;
string character = nullField == "character"
? "\"character\":null,"
: probe
? string.Empty
: "\"character\":{\"index\":0},";
string policy = nullField == "policy"
? "\"policy\":null,"
: probe
? string.Empty
: "\"policy\":{\"id\":\"idle\"},";
using TemporaryConfiguration file = TemporaryConfiguration.Create(
$$"""
{
"version": 1,
"sessions": [
{
"id": "explicit-null",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
{{mode}}
{{character}}
{{policy}}
"credential": { "provider": "environment", "reference": "PASSWORD" }
}
]
}
""");
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
$"'{nullField}'",
exception.Message,
StringComparison.Ordinal);
Assert.Contains(
"cannot be null",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void PresenceAwareValidationKeepsUnmappedMemberRejection()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"PASSWORD",
"\"notAContractField\":true")));
Assert.Throws(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact]
public void PresenceAwareValidationKeepsTypedValueRejection()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "wrong-type",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"mode": { "value": "probe" },
"credential": { "provider": "environment", "reference": "PASSWORD" }
}
]
}
""");
Assert.Throws(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[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);
}
///
/// A play session (mode absent) missing `character` must still fail —
/// the LA2 change moved this requiredness from `[JsonRequired]` (a raw
/// at deserialize time) to
/// 's semantic
/// check (a naming the
/// missing field). Exit-code parity (both map to
/// HeadlessExitCode.ConfigurationError) is proven at
/// HeadlessEntryPointTests; this test pins the loader-level
/// exception type/message.
///
[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);
}
/// Same parity claim as
/// for the
/// `policy` field.
[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(
() => HeadlessConfigurationLoader.Load(badSelector.Path));
Assert.Throws(
() => 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);
}
}
}