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