HeadlessConsoleRenderer dimmed every line uniformly, so chat and interface text (player-visible content) read the same washed-out weight as scheduling/session-status noise like "entered world" or "command rejected: ...". Only lifecycle, command, and portal lines are scheduling noise; chat and interface text now print at the terminal's default weight. ChatAndInterfaceTextPrintAtDefaultWeightNeverDimmed was shown to fail against the prior dim-everything WriteLine (mutation: dim parameter not yet threaded through, every call still unconditionally wrapped in the ANSI dim/reset pair) -- the chat line and interface text both carried the dim escape sequence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
854 lines
32 KiB
C#
854 lines
32 KiB
C#
using System.Buffers.Binary;
|
|
using System.Diagnostics;
|
|
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.Headless.Platform;
|
|
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, even over env "0"
|
|
[InlineData(false, "1", false, true)] // env var "1" wins over terminal default
|
|
[InlineData(false, "yes", false, true)] // any non-"0" env value enables (RETAIL_CLOSE_DEGRADES/RETAIL_UI idiom)
|
|
// S1 fix (2026-09-07 review round): ACDREAM_HEADLESS_CONSOLE=0 must
|
|
// disable the console even when stdin IS a real terminal — the earlier
|
|
// `== "1"` test let "0" silently fall through to the terminal-shaped
|
|
// default instead of acting as the documented A/B off-switch.
|
|
[InlineData(false, "0", true, false)] // env var "0" disables even when stdin is a terminal
|
|
[InlineData(false, "0", false, false)] // env var "0" disables when stdin is redirected too
|
|
[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.ConsoleEnabled);
|
|
Assert.Equal("bot.json", parsed.ConfigurationPath);
|
|
}
|
|
|
|
[Fact]
|
|
public void CommandLineWithoutTheFlagDefaultsConsoleOff()
|
|
{
|
|
HeadlessCommandLine parsed = HeadlessCommandLine.Parse(
|
|
["run", "--config", "bot.json"]);
|
|
|
|
Assert.False(parsed.ConsoleEnabled);
|
|
}
|
|
|
|
/// <summary>
|
|
/// N3: validate mode rejects <c>--console</c> outright (rather than
|
|
/// silently ignoring it) — validate never starts a session, so there is
|
|
/// nothing for the console to attach to.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ValidateModeRejectsTheConsoleFlag()
|
|
{
|
|
Assert.Throws<HeadlessCommandLineException>(() =>
|
|
HeadlessCommandLine.Parse(
|
|
["validate", "--config", "bot.json", "--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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// S2 (2026-09-07 review round): makes the "never runs on the reader
|
|
/// thread" pin FALSIFIABLE rather than merely structurally argued. The
|
|
/// prior test proves ordering but infers "never the reader thread" from
|
|
/// the reader loop's own code having nothing to dispatch — this test
|
|
/// records the ACTUAL thread id <see cref="TextReader.ReadLine"/> ran on
|
|
/// (via <see cref="ThreadIdRecordingTextReader"/>) and asserts, from
|
|
/// inside the controller's own submit callback, that the executing
|
|
/// thread is neither that reader thread nor any other unexpected
|
|
/// thread — it must be exactly the thread that called
|
|
/// <see cref="HeadlessConsoleController.DrainDue"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SubmitRunsOnTheDrainCallersThreadNeverTheReaderThread()
|
|
{
|
|
using var fixture = new ThreadIdRecordingTextReader(
|
|
new System.IO.StringReader("hello" + Environment.NewLine));
|
|
int? observedSubmitThreadId = null;
|
|
using var quit = new CancellationTokenSource();
|
|
using var controller = new HeadlessConsoleController(
|
|
fixture,
|
|
TextWriter.Null,
|
|
line =>
|
|
{
|
|
observedSubmitThreadId = Environment.CurrentManagedThreadId;
|
|
return SubmitOutcome.Sent;
|
|
},
|
|
() => string.Empty,
|
|
quit);
|
|
|
|
Assert.True(WaitForEndOfInput(controller));
|
|
int drainCallerThreadId = Environment.CurrentManagedThreadId;
|
|
controller.DrainDue();
|
|
|
|
Assert.NotNull(fixture.ReadLineThreadId);
|
|
Assert.NotNull(observedSubmitThreadId);
|
|
Assert.NotEqual(fixture.ReadLineThreadId, observedSubmitThreadId);
|
|
Assert.Equal(drainCallerThreadId, observedSubmitThreadId);
|
|
}
|
|
|
|
// ── 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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// S4: <see cref="SubmitOutcome.UnknownCommand"/> and
|
|
/// <see cref="SubmitOutcome.Dropped"/> get a visible console line —
|
|
/// matching <c>LoginCommandSequence.DrainDue</c>'s own reporting for the
|
|
/// same two outcomes — instead of silently doing nothing.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(SubmitOutcome.UnknownCommand)]
|
|
[InlineData(SubmitOutcome.Dropped)]
|
|
public void UnknownOrDroppedOutcomePrintsAVisibleLine(SubmitOutcome outcome)
|
|
{
|
|
using var input = new System.IO.StringReader("garbage" + Environment.NewLine);
|
|
var output = new StringWriter();
|
|
using var quit = new CancellationTokenSource();
|
|
using var controller = new HeadlessConsoleController(
|
|
input,
|
|
output,
|
|
_ => outcome,
|
|
() => string.Empty,
|
|
quit);
|
|
|
|
Assert.True(WaitForEndOfInput(controller));
|
|
controller.DrainDue();
|
|
|
|
Assert.Contains("garbage", output.ToString());
|
|
Assert.Contains(outcome.ToString(), output.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// S4: a throwing submit callback (a console typo hitting a downstream
|
|
/// bug in a plugin verb handler, say) never escapes <c>DrainDue</c> —
|
|
/// it must never reach the scheduler's per-session quarantine catch and
|
|
/// fault the whole session over one bad console line. Mirrors
|
|
/// <c>LoginCommandSequence.DrainDue</c>'s own try/catch.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SubmitFailurePrintsALineAndNeverEscapesDrainDue()
|
|
{
|
|
using var input = new System.IO.StringReader("boom" + Environment.NewLine);
|
|
var output = new StringWriter();
|
|
using var quit = new CancellationTokenSource();
|
|
using var controller = new HeadlessConsoleController(
|
|
input,
|
|
output,
|
|
_ => throw new InvalidOperationException("fixture failure"),
|
|
() => string.Empty,
|
|
quit);
|
|
|
|
Assert.True(WaitForEndOfInput(controller));
|
|
controller.DrainDue();
|
|
|
|
Assert.Contains("fixture failure", 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));
|
|
}
|
|
|
|
// ── HeadlessConsoleRenderer: N5 dim-weight rules ─────────────────────
|
|
|
|
/// <summary>
|
|
/// N5: chat and interface text are player-visible content, not
|
|
/// scheduling noise — they must print at the terminal's default weight,
|
|
/// never dimmed, even when color is enabled.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChatAndInterfaceTextPrintAtDefaultWeightNeverDimmed()
|
|
{
|
|
var output = new StringWriter();
|
|
var renderer = new HeadlessConsoleRenderer(output, useColor: true);
|
|
var entry = new RuntimeChatEntry(
|
|
Revision: 1,
|
|
SenderGuid: 0x50000010u,
|
|
Kind: (int)ChatKind.LocalSpeech,
|
|
Sender: "Bob",
|
|
Text: "hi",
|
|
ChannelName: string.Empty);
|
|
|
|
renderer.OnChat(new RuntimeChatDelta(default, entry));
|
|
renderer.WriteInterfaceText("Unknown command: /x");
|
|
|
|
string text = output.ToString();
|
|
Assert.DoesNotContain("[2m", text);
|
|
Assert.Contains("[Local] Bob: hi", text);
|
|
Assert.Contains("Unknown command: /x", text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// N5: lifecycle, command, and portal lines are scheduling/session-
|
|
/// status noise, not player-visible content — dimmed when color is
|
|
/// enabled.
|
|
/// </summary>
|
|
[Fact]
|
|
public void LifecycleCommandAndPortalLinesAreDimmedWhenColorIsEnabled()
|
|
{
|
|
var output = new StringWriter();
|
|
var renderer = new HeadlessConsoleRenderer(output, useColor: true);
|
|
|
|
renderer.OnLifecycle(new RuntimeLifecycleDelta(
|
|
default, RuntimeLifecycleState.Starting, RuntimeLifecycleState.InWorld));
|
|
renderer.OnCommand(new RuntimeCommandDelta(
|
|
default, RuntimeCommandDomain.Chat, 0, RuntimeCommandStatus.Rejected, Text: "boom"));
|
|
renderer.OnPortal(new RuntimePortalDelta(
|
|
default,
|
|
new RuntimePortalSnapshot(
|
|
Generation: 1,
|
|
RuntimePortalKind.Portal,
|
|
Readiness: new RuntimeDestinationReadiness(
|
|
1, 0x12345678u, false, false, 0, true, true, true),
|
|
Materialized: true,
|
|
Completed: false,
|
|
Cancelled: false,
|
|
WorldViewportObserved: true,
|
|
WorldSimulationAvailable: true,
|
|
InvariantFailureCount: 0,
|
|
WaitCueShown: false,
|
|
PortalMaterializationCount: 1)));
|
|
|
|
string[] lines = output.ToString()
|
|
.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
|
Assert.Equal(3, lines.Length);
|
|
Assert.All(lines, line => Assert.Contains("[2m", line));
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// S5 rework: <c>SubmitConsoleLine</c> no longer takes a per-call
|
|
/// interface-text callback — retail's transient interface text
|
|
/// (<c>ClientLocal</c>) lands in the shared
|
|
/// <see cref="AcDream.Core.Chat.SpewBoxState"/> exactly like every other
|
|
/// producer of that text (see <c>RuntimeCommunicationState.AddText</c>),
|
|
/// and the console's own per-tick pump polls it — proven directly here
|
|
/// against the real <see cref="AcDream.Core.Chat.SpewBoxState"/> rather
|
|
/// than a decorator only this call site could see.
|
|
/// </summary>
|
|
[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);
|
|
|
|
// 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("/");
|
|
|
|
Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
|
|
SpewBoxState spewBox = host.Runtime.CommunicationOwner.SpewBox;
|
|
spewBox.Tick(host.Runtime.Clock.SimulationTimeSeconds);
|
|
SpewBoxEntry entry = Assert.Single(spewBox.Snapshot());
|
|
Assert.Contains("Unknown command:", entry.Text);
|
|
}
|
|
|
|
// ── HeadlessConsoleSpewBoxPump: server/plugin-driven interface text ──
|
|
|
|
/// <summary>
|
|
/// S5: the pump must surface interface text that never went through the
|
|
/// console at all — a stand-in for a server- or plugin-driven
|
|
/// <c>ClientLocal</c> write reaching <c>RuntimeCommunicationState.AddText</c>
|
|
/// directly, exactly the case the deleted per-call
|
|
/// <c>HeadlessConsoleChatFeedback</c> decorator could never see (it only
|
|
/// ever wrapped THIS console's own <c>SubmitConsoleLine</c> feedback).
|
|
/// </summary>
|
|
[Fact]
|
|
public void PumpPrintsInterfaceTextNotOriginatingFromTheConsole()
|
|
{
|
|
var spewBox = new SpewBoxState();
|
|
var printed = new List<string>();
|
|
double now = 0d;
|
|
var pump = new HeadlessConsoleSpewBoxPump(spewBox, () => now, printed.Add);
|
|
|
|
// Simulates a plugin's own Log/interface-text write, or a server-
|
|
// driven refusal — never called HeadlessConsoleController.Handle or
|
|
// HeadlessSessionHost.SubmitConsoleLine.
|
|
spewBox.Enqueue("[vt] navigation route loaded");
|
|
|
|
pump.Pump();
|
|
|
|
Assert.Equal(["[vt] navigation route loaded"], printed);
|
|
|
|
// A second pump with nothing new enqueued must not reprint the
|
|
// still-visible entry.
|
|
now += 0.1d;
|
|
pump.Pump();
|
|
Assert.Equal(["[vt] navigation route loaded"], printed);
|
|
}
|
|
|
|
// ── HeadlessProcessHost: end-to-end console wiring ───────────────────
|
|
|
|
/// <summary>
|
|
/// S3: an end-to-end proof that a console line, read from a plain
|
|
/// <see cref="StringReader"/>, reaches the real session's
|
|
/// <c>SubmitConsoleLine</c> pipeline through the actual
|
|
/// <see cref="HeadlessProcessHost"/> wiring (background reader thread →
|
|
/// per-tick <c>ConsolePump</c> → <c>ChatCommandRouter.Submit</c> → the
|
|
/// wire), and that <c>/quit</c> ends <see cref="HeadlessProcessHost.RunAsync"/>
|
|
/// through the SAME graceful path an external cancellation takes —
|
|
/// <see cref="HeadlessExitCode.Success"/>, not an error code.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConsoleLineReachesTheSessionAndQuitEndsTheProcessGracefully()
|
|
{
|
|
var captured = new List<byte[]>();
|
|
var operations = new FixtureSessionOperations
|
|
{
|
|
GameActionCapture = body => captured.Add(body),
|
|
};
|
|
var configuration = new HeadlessConfiguration
|
|
{
|
|
Version = 1,
|
|
Sessions = [Descriptor()],
|
|
};
|
|
using var diagnostics = new StringWriter();
|
|
using var input = new System.IO.StringReader(
|
|
"hello" + Environment.NewLine + "/quit" + Environment.NewLine);
|
|
using var host = new HeadlessProcessHost(
|
|
configuration,
|
|
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
|
|
input,
|
|
diagnostics,
|
|
operations,
|
|
new FakeTimeProvider(),
|
|
directCredentials: new HeadlessDirectCredentials("account", "password"),
|
|
consoleEnabled: true);
|
|
|
|
HeadlessExitCode exitCode = await host.RunAsync(CancellationToken.None)
|
|
.WaitAsync(TimeSpan.FromSeconds(10));
|
|
|
|
Assert.Equal(HeadlessExitCode.Success, exitCode);
|
|
byte[] body = Assert.Single(captured);
|
|
Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body));
|
|
Assert.Equal("hello", TalkText(body));
|
|
}
|
|
|
|
/// <summary>
|
|
/// S6: <c>standardOutputIsTerminal</c> is threaded in as a constructor
|
|
/// parameter, not read from the real <c>System.Console</c> inside
|
|
/// <see cref="HeadlessProcessHost"/> — proven by flipping only the
|
|
/// parameter (this test process's OWN stdout is redirected by the test
|
|
/// host either way) and observing the renderer's dim-vs-plain choice
|
|
/// follow it.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(true, true)]
|
|
[InlineData(false, false)]
|
|
public async Task StandardOutputIsTerminalParameterControlsColorNotTheRealConsole(
|
|
bool standardOutputIsTerminal, bool expectDimmed)
|
|
{
|
|
var operations = new FixtureSessionOperations();
|
|
var configuration = new HeadlessConfiguration
|
|
{
|
|
Version = 1,
|
|
Sessions = [Descriptor()],
|
|
};
|
|
using var diagnostics = new StringWriter();
|
|
using var input = new System.IO.StringReader("/quit" + Environment.NewLine);
|
|
using var host = new HeadlessProcessHost(
|
|
configuration,
|
|
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
|
|
input,
|
|
diagnostics,
|
|
operations,
|
|
new FakeTimeProvider(),
|
|
directCredentials: new HeadlessDirectCredentials("account", "password"),
|
|
consoleEnabled: true,
|
|
standardOutputIsTerminal: standardOutputIsTerminal);
|
|
|
|
await host.RunAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
|
|
|
|
string text = diagnostics.ToString();
|
|
Assert.Contains("entered world", text);
|
|
Assert.Equal(expectDimmed, text.Contains("[2m"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// S7: a multi-session process with <c>--console</c> must tell the
|
|
/// operator why the console never attached (the launcher's multi-session
|
|
/// mode is a legitimate, common configuration) instead of silently
|
|
/// doing nothing — see <c>HeadlessDiagnosticWriter.Message</c>'s "console"
|
|
/// category.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TwoSessionsWithConsoleFlagReportsSingleSessionOnly()
|
|
{
|
|
// StandardInput credentials (one line per session, consumed by
|
|
// HeadlessCredentialResolver BEFORE the console reader thread ever
|
|
// starts — see HeadlessProcessHost's own constructor comment) avoid
|
|
// needing an ACE-shaped Environment credential just to reach the
|
|
// console-wiring branch this test targets.
|
|
HeadlessSessionDescriptor StandardInputDescriptor(string id) =>
|
|
Descriptor() with
|
|
{
|
|
Id = id,
|
|
Credential = new HeadlessCredentialReference
|
|
{
|
|
Provider = HeadlessCredentialProviderKind.StandardInput,
|
|
Reference = "fixture",
|
|
},
|
|
};
|
|
var configuration = new HeadlessConfiguration
|
|
{
|
|
Version = 1,
|
|
Sessions =
|
|
[
|
|
StandardInputDescriptor("one"),
|
|
StandardInputDescriptor("two"),
|
|
],
|
|
};
|
|
var operations = new FixtureSessionOperations();
|
|
using var diagnostics = new StringWriter();
|
|
using var input = new System.IO.StringReader(
|
|
"password-one" + Environment.NewLine
|
|
+ "password-two" + Environment.NewLine);
|
|
using var host = new HeadlessProcessHost(
|
|
configuration,
|
|
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
|
|
input,
|
|
diagnostics,
|
|
operations,
|
|
new FakeTimeProvider(),
|
|
directCredentials: null,
|
|
consoleEnabled: true);
|
|
|
|
Assert.Contains("single-session only", diagnostics.ToString());
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// S2: wraps a real <see cref="TextReader"/> and records the managed
|
|
/// thread id every <see cref="ReadLine"/> call actually ran on — the
|
|
/// background reader thread's own id, since only
|
|
/// <see cref="HeadlessConsoleInputReader"/> ever calls it.
|
|
/// </summary>
|
|
private sealed class ThreadIdRecordingTextReader(TextReader inner)
|
|
: TextReader
|
|
{
|
|
internal int? ReadLineThreadId { get; private set; }
|
|
|
|
public override string? ReadLine()
|
|
{
|
|
ReadLineThreadId = Environment.CurrentManagedThreadId;
|
|
return inner.ReadLine();
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
inner.Dispose();
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// S3/S6/S7: a real-clock <see cref="TimeProvider"/>, distinct from
|
|
/// <see cref="TimeProvider.System"/>, for a <see cref="HeadlessProcessHost"/>
|
|
/// integration test that runs the actual scheduler loop on its own
|
|
/// dedicated thread. Real elapsed time (not a manually-stepped fake) is
|
|
/// deliberate here: <see cref="HeadlessProcessHost.RunAsync"/> owns its
|
|
/// own background thread, and stepping a manual clock from the test
|
|
/// thread while that thread's scheduler loop waits on a
|
|
/// <see cref="ITimer"/> armed from the SAME provider would race the two
|
|
/// threads for no benefit — the default 15 ms turn period already makes
|
|
/// these tests fast.
|
|
/// </summary>
|
|
private sealed class FakeTimeProvider : TimeProvider
|
|
{
|
|
public override long GetTimestamp() => Stopwatch.GetTimestamp();
|
|
|
|
public override long TimestampFrequency => Stopwatch.Frequency;
|
|
}
|
|
}
|