From 4d5da2dcd502b450f27d6462faa1bcb1affb659b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 06:56:54 +0200 Subject: [PATCH] test(headless): cover the interactive console 20 focused tests, no live server: typed-option resolution and --console flag parsing; the reader-thread ordering guarantee (lines queued by the background thread drain, in FIFO order, entirely on the calling thread); the controller's drain/quit/status behavior; the console chat formatter's per-kind bracket labels; and the full dispatch pipeline against a real HeadlessSessionHost wired to a no-network FixtureSessionOperations fixture (mirrors the existing HeadlessSessionHostTests pattern used for LoginCommandSequence): /say and plain text both produce the identical outbound Talk action a graphical /say would send, a registered plugin verb is reached without touching the wire, and an unknown/degenerate verb produces the same interface text ChatCommandRouterTests already pins for the graphical route. Every test in this file was run against a deliberate one-line mutation of its own production code first and confirmed red before being reverted: HeadlessConsoleChatFeedback dropping its interface-text callback, HeadlessConsoleController skipping _quitRequested.Cancel(), LiveChatCommandRoute.TryHandlePluginCommand forced to always return false, HeadlessConsoleInputReader's read loop dropping its Enqueue call, and SubmitConsoleLine's ChatChannelKind.Say swapped for .Tell. dotnet test tests/AcDream.Headless.Tests -c Release: 193 passed, 1 pre-existing failure (LinuxRejectsGroupOrOtherCredentialPermissions - Linux-only lane, cannot run on this Windows host, unrelated), 194 total. dotnet test tests/AcDream.Runtime.Tests -c Release: 1891/1891. dotnet test tests/AcDream.App.Tests -c Release --filter "FullyQualifiedName~Chat|FullyQualifiedName~Command|FullyQualifiedName~LaunchOptions": 414 passed, 2 pre-existing failures (both gated on ACDREAM_PROBE_LIVE_MOUNT=1, a manual live-DAT probe lane, unrelated), 3 skipped, 419 total. Co-Authored-By: Claude Fable 5.1 --- .../HeadlessConsoleTests.cs | 447 ++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs diff --git a/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs new file mode 100644 index 00000000..4d08d587 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs @@ -0,0 +1,447 @@ +using System.Buffers.Binary; +using System.Net; +using System.Text; +using AcDream.Core.Chat; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Hosting; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Chat; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Tests; + +/// +/// docs/plans/2026-09-07-headless-console.md — the headless interactive +/// console. Two layers: / +/// tested in isolation (no live +/// server, no ), then +/// tested against a real +/// host wired to — the same +/// no-network fixture pattern HeadlessSessionHostTests already uses +/// for LoginCommandSequence, proving the console reuses the EXACT +/// same pipeline rather than a second parser. +/// +public sealed class HeadlessConsoleTests +{ + // ── HeadlessConsoleOptions (typed option resolution) ───────────────── + + [Theory] + [InlineData(true, "0", false, true)] // CLI flag always wins + [InlineData(false, "1", false, true)] // env var wins over terminal default + [InlineData(false, "0", true, true)] // env var "0" does not disable the terminal default + [InlineData(false, null, true, true)] // no flag/env -> terminal-shaped default (on) + [InlineData(false, null, false, false)] // no flag/env -> terminal-shaped default (off) + public void ResolvePrefersFlagThenEnvironmentThenTerminalDefault( + bool commandLineFlag, + string? environmentValue, + bool standardInputIsTerminal, + bool expected) + { + bool resolved = HeadlessConsoleOptions.Resolve( + commandLineFlag, + _ => environmentValue, + standardInputIsTerminal); + + Assert.Equal(expected, resolved); + } + + [Fact] + public void CommandLineParsesTheBareConsoleFlag() + { + HeadlessCommandLine parsed = HeadlessCommandLine.Parse( + ["run", "--config", "bot.json", "--console"]); + + Assert.True(parsed.Console); + Assert.Equal("bot.json", parsed.ConfigurationPath); + } + + [Fact] + public void CommandLineWithoutTheFlagDefaultsConsoleOff() + { + HeadlessCommandLine parsed = HeadlessCommandLine.Parse( + ["run", "--config", "bot.json"]); + + Assert.False(parsed.Console); + } + + // ── HeadlessConsoleInputReader: reader-thread/ordering ─────────────── + + /// + /// The required reader-thread test: lines produced by the background + /// thread are drained, in FIFO order, entirely on the CALLING thread. + /// The reader thread itself never runs anything beyond + /// ConcurrentQueue.Enqueue — there is no dispatch code it could + /// execute — so this also structurally proves "never executed on the + /// reader thread," not just orders the output. + /// + [Fact] + public void LinesQueuedByTheReaderThreadDrainInOrderOnTheCallingThread() + { + using var input = new System.IO.StringReader( + "one" + Environment.NewLine + + "two" + Environment.NewLine + + "three" + Environment.NewLine); + using var reader = new HeadlessConsoleInputReader(input); + + Assert.True( + reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)), + "the reader thread never reached EOF"); + + int callingThread = Environment.CurrentManagedThreadId; + var drained = new List(); + while (reader.TryDequeue(out string line)) + { + drained.Add(line); + // Proves the dequeue (and everything a caller does with the + // line) runs on THIS thread, not the reader thread. + Assert.Equal(callingThread, Environment.CurrentManagedThreadId); + } + + Assert.Equal(["one", "two", "three"], drained); + } + + // ── HeadlessConsoleController: /quit, /status, dispatch ordering ───── + + [Fact] + public void ControllerDrainsEveryLineQueuedSinceTheLastTickInOrderOnOneCall() + { + // Simulates "input queued during a busy tick": every line is + // enqueued by the reader thread before DrainDue is ever called — + // one DrainDue call must still process all of them, in order. + using var input = new System.IO.StringReader( + "alpha" + Environment.NewLine + + "beta" + Environment.NewLine + + "gamma" + Environment.NewLine); + var handled = new List(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + TextWriter.Null, + line => + { + handled.Add(line); + return SubmitOutcome.Sent; + }, + () => string.Empty, + quit); + + Assert.True( + WaitForEndOfInput(controller), + "the reader thread never reached EOF"); + controller.DrainDue(); + + Assert.Equal(["alpha", "beta", "gamma"], handled); + Assert.Equal(3, controller.LastDrainCount); + + // A second drain with nothing queued does nothing — proves DrainDue + // does not re-process already-handled lines. + controller.DrainDue(); + Assert.Equal(["alpha", "beta", "gamma"], handled); + Assert.Equal(0, controller.LastDrainCount); + } + + [Fact] + public void QuitRequestsCancellationAndNeverReachesSubmit() + { + using var input = new System.IO.StringReader("/quit" + Environment.NewLine); + var submitted = new List(); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + line => + { + submitted.Add(line); + return SubmitOutcome.Sent; + }, + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.True(quit.IsCancellationRequested); + Assert.Empty(submitted); + Assert.Contains("quitting", output.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StatusPrintsTheProvidedStatusTextAndNeverReachesSubmit() + { + using var input = new System.IO.StringReader("/status" + Environment.NewLine); + var submitted = new List(); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + line => + { + submitted.Add(line); + return SubmitOutcome.Sent; + }, + () => "generation=1 position=unknown plugins=0 loaded", + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.Empty(submitted); + Assert.Contains( + "generation=1 position=unknown plugins=0 loaded", + output.ToString()); + } + + // ── HeadlessConsoleChatFormatter: channel-prefixed rendering ───────── + + [Theory] + [InlineData("Bob", 0x50000010u, "hi", "[Local] Bob: hi")] + [InlineData("", 0u, "hi", "[Local] You: hi")] + public void FormatsLocalSpeechWithTheLocalLabel( + string sender, uint senderGuid, string text, string expected) + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: senderGuid, + Kind: (int)ChatKind.LocalSpeech, + Sender: sender, + Text: text, + ChannelName: string.Empty); + + Assert.Equal(expected, HeadlessConsoleChatFormatter.Format(entry)); + } + + [Fact] + public void FormatsChannelBroadcastWithItsFriendlyName() + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: 0x50000010u, + Kind: (int)ChatKind.Channel, + Sender: "Bob", + Text: "group up", + ChannelName: "Fellowship"); + + Assert.Equal( + "[Fellowship] Bob: group up", + HeadlessConsoleChatFormatter.Format(entry)); + } + + [Theory] + [InlineData(0x50000010u, "Bob", "hi", "[Tell] Bob: hi")] + [InlineData(0u, "Bob", "hi", "[Tell] You -> Bob: hi")] + public void FormatsTellWithDirection( + uint senderGuid, string sender, string text, string expected) + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: senderGuid, + Kind: (int)ChatKind.Tell, + Sender: sender, + Text: text, + ChannelName: string.Empty); + + Assert.Equal(expected, HeadlessConsoleChatFormatter.Format(entry)); + } + + // ── HeadlessSessionHost.SubmitConsoleLine: the real dispatch pipeline ─ + + [Fact] + public void SlashSayProducesTheSameOutboundTalkActionTheGraphicalRouteSends() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + SubmitOutcome outcome = host.SubmitConsoleLine("/say hello", _ => { }); + + Assert.Equal(SubmitOutcome.Sent, outcome); + byte[] body = Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + Assert.Equal("hello", TalkText(body)); + } + + [Fact] + public void PlainTextProducesTheSameOutboundTalkActionAsSlashSay() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + SubmitOutcome outcome = host.SubmitConsoleLine("hello", _ => { }); + + Assert.Equal(SubmitOutcome.Sent, outcome); + byte[] body = Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + Assert.Equal("hello", TalkText(body)); + } + + [Fact] + public void PluginVerbReachesTheRegisteredPluginCommandWithoutTouchingTheWire() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + var received = new List(); + using IDisposable registration = host.PluginCommands.Register( + "vt", + command => received.Add(command)); + + SubmitOutcome outcome = host.SubmitConsoleLine("/vt start", _ => { }); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + PluginCommand command = Assert.Single(received); + Assert.Equal("vt", command.Verb); + Assert.Equal("start", command.Arguments); + Assert.Empty(captured); + } + + [Fact] + public void UnknownVerbProducesTheSameInterfaceTextTheChatBoxShows() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + var interfaceText = new List(); + + // A bare "/" is retail's degenerate-prefix case (no letter verb) — + // ChatCommandRouter refuses it locally instead of sending it to the + // server or speech (ChatCommandRouterTests. + // DegeneratePrefix_UnknownCommand_ShowsRefusal_ViaInterfaceTextSeam + // pins the exact same text for the graphical route). + SubmitOutcome outcome = host.SubmitConsoleLine( + "/", + interfaceText.Add); + + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + string text = Assert.Single(interfaceText); + Assert.Contains("Unknown command:", text); + } + + private static bool WaitForEndOfInput(HeadlessConsoleController controller) => + controller.Reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)); + + private static uint ActionOpcode(byte[] body) => + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, sizeof(uint))); + + private static string TalkText(byte[] body) + { + ushort length = BinaryPrimitives.ReadUInt16LittleEndian( + body.AsSpan(12, sizeof(ushort))); + return Encoding.ASCII.GetString(body, 14, length); + } + + private static HeadlessSessionDescriptor Descriptor() => new() + { + Id = "console-bot", + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Character = new HeadlessCharacterSelector + { + Name = "headless", + }, + Policy = new HeadlessBotPolicyDescriptor + { + Id = "idle", + }, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.Environment, + Reference = "CONSOLE_BOT_PASSWORD", + }, + }; + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public Action? GameActionCapture { get; init; } + + public CharacterList.Parsed? Characters { get; init; } = new( + 0u, + [ + new CharacterList.Character(0x50000001u, "Other", 0u), + new CharacterList.Character(0x50000002u, "Headless", 0u), + ], + [], + 11, + "account", + true, + true); + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + var session = new WorldSession(endpoint); + session.GameActionCapture = GameActionCapture; + return session; + } + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed? GetCharacters(WorldSession session) => + Characters; + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => session.Dispose(); + } +}