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