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); } } }