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 <noreply@anthropic.com>
This commit is contained in:
parent
a843b990a8
commit
4d5da2dcd5
1 changed files with 447 additions and 0 deletions
447
tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
Normal file
447
tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// docs/plans/2026-09-07-headless-console.md — the headless interactive
|
||||
/// console. Two layers: <see cref="HeadlessConsoleInputReader"/> /
|
||||
/// <see cref="HeadlessConsoleController"/> tested in isolation (no live
|
||||
/// server, no <see cref="HeadlessSessionHost"/>), then
|
||||
/// <see cref="HeadlessSessionHost.SubmitConsoleLine"/> tested against a real
|
||||
/// host wired to <see cref="FixtureSessionOperations"/> — the same
|
||||
/// no-network fixture pattern <c>HeadlessSessionHostTests</c> already uses
|
||||
/// for <c>LoginCommandSequence</c>, proving the console reuses the EXACT
|
||||
/// same pipeline rather than a second parser.
|
||||
/// </summary>
|
||||
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 ───────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>ConcurrentQueue.Enqueue</c> — there is no dispatch code it could
|
||||
/// execute — so this also structurally proves "never executed on the
|
||||
/// reader thread," not just orders the output.
|
||||
/// </summary>
|
||||
[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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<byte[]>();
|
||||
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<byte[]>();
|
||||
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<byte[]>();
|
||||
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<PluginCommand>();
|
||||
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<string>();
|
||||
|
||||
// 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<byte[]>? 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue