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