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