acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs
Erik 724ef2d389 fix(chat): CH4 review fixes — allegiance ownership guard, house-abandon confirmation
Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped
TryMatchAllegiance (which only claimed "info"/"hometown") and fell through
the unregistered-tag channel fallback, broadcasting the raw subcommand
text to the Allegiance chat channel (0x02000000). Retail's own
DoAllegiance never reaches DoChannelCommand for an unrecognized
subcommand — it claims the whole verb and prints its own client-local
refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally
and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter
also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership
guard in TryDispatchChannelFallback as defense in depth.

Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation.
Retail runs a real two-stage dialog before Event_AbandonHouse(); ported
both verbatim strings and chained two ShowConfirmation calls.

Should-fixes: a bare unregistered tag with no text now passes through
silently instead of showing a refusal that belongs to a different retail
function; @join/@leave update RuntimeCharacterOptionsState locally (new
SetOptionBit) before the wire push so the Turbine membership gate stops
refusing a just-joined room; @permit accepts multi-word names; @clist/
@on/@off validate shape only and raise WeenieError 0x422 for an unknown
tag; @mr/@pr help text is now the verbatim retail strings; corrected
issue #360, register row TS-68, the campaign doc's B.7 note, and a stale
RetailChannelTagTable comment; filed issue #363 + register row AP-183 for
the deferred error-typing debt.

Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores
the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's
olthoi false-positive, added /g and /rp binding-level conformance pins,
made @index ignore extra arguments, and noted the six removed invented
verbs in ISSUES.md.

Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's
12,190/4/0 — net +26 tests, no removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:59:35 +02:00

300 lines
13 KiB
C#

using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Campaign CH slice CH4 (2026-08-09): pins acdream's complete chat-command
/// surface against the retail registry enumeration in
/// <c>docs/research/2026-08-09-chat-retail-command-registry.md</c> §2/§4 —
/// 130 verbs registered by <c>InitializeCommands</c> +
/// <c>StartupTurbineChatSystem</c>, plus the 22 unregistered
/// <c>ChannelSystem::GetChannelID</c> fallback tags (152 total).
///
/// <para>
/// Every verb has an explicit <see cref="Status"/>:
/// <list type="bullet">
/// <item><see cref="Status.Implemented"/> — executes locally (typed client
/// command, chat alias, channel send, or local presentation).</item>
/// <item><see cref="Status.HelpOnly"/> — retail registers it with a NULL
/// function pointer (verified byte-level in the registry doc); it can
/// never execute in retail OR acdream. Typing it bare reaches the server
/// as literal text.</item>
/// <item><see cref="Status.ServerPassthrough"/> — a real retail verb
/// acdream has not yet ported (TS-68/TS-69/TS-70, or the deliberate
/// @loadfile product decision); falls through to ACE as literal text.</item>
/// </list>
/// The FIRST test enforces that every verb resolves to the RIGHT status.
/// The SECOND enforces the ownership rule in reverse: nothing in
/// <see cref="RetailClientCommandCatalog"/> or <see cref="ChatInputParser"/>
/// exists that ISN'T in this registry (no invented verbs slip back in).
/// </para>
/// </summary>
public sealed class RetailCommandRegistryConformanceTests
{
private enum Status { Implemented, HelpOnly, ServerPassthrough }
private sealed record Entry(Status Status, params string[] Verbs);
/// <summary>
/// The complete 152-verb registry, one entry per retail handler (or
/// per fallback-tag family), transcribed directly from the
/// command-registry doc's §2 tables. Verb-string counts per section
/// were cross-checked against the doc's own §4 totals (130 + 22 = 152;
/// the Turbine §2.4 net-new count here is exactly 14, matching the
/// doc's "15 added, 14 net-new (a replaced)" note) before this test
/// was written.
/// </summary>
private static readonly Entry[] Registry =
[
// §2.1 Help / group nodes (9)
new(Status.Implemented, "help", "?"),
new(Status.HelpOnly, "commands"),
new(Status.HelpOnly, "allegiances"),
new(Status.HelpOnly, "channels"),
new(Status.HelpOnly, "chatting"),
new(Status.HelpOnly, "death"),
new(Status.HelpOnly, "status"),
new(Status.HelpOnly, "text"),
// §2.2 Local chat routing (31)
new(Status.Implemented, "say", "s"),
new(Status.Implemented, "tell", "t", "send", "whisper", "w"),
new(Status.Implemented, "reply", "r", "rp"),
new(Status.HelpOnly, "mr"),
new(Status.HelpOnly, "pr"),
new(Status.Implemented, "retell", "rt"),
new(Status.Implemented, "chat"),
new(Status.Implemented, "notell"),
new(Status.Implemented, "join"),
new(Status.Implemented, "leave"),
new(Status.Implemented, "index"),
new(Status.Implemented, "clist"),
new(Status.Implemented, "on"),
new(Status.Implemented, "off"),
new(Status.Implemented, "title"),
new(Status.ServerPassthrough, "log"), // TS-69
new(Status.Implemented, "clear"),
new(Status.Implemented, "filter"),
new(Status.Implemented, "unfilter"),
new(Status.Implemented, "messagetypes", "message_types", "msgtypes", "msg_types"),
new(Status.ServerPassthrough, "loadfile"), // deliberate — doc §3 "explicitly do NOT implement"
// §2.3 Chat channels (20)
new(Status.Implemented, "a", "ab"),
new(Status.Implemented, "co-vassals", "covassals", "covassal", "c"),
new(Status.Implemented, "monarch", "m"),
new(Status.Implemented, "patron", "p"),
new(Status.Implemented, "vassals", "vassal", "v"),
new(Status.Implemented, "fellowship", "fellows", "fellow", "f", "group", "g", "party"),
// §2.3 fallback: 22 GetChannelID tags with NO registered verb (22)
new(Status.Implemented, "av", "av1", "advocate", "advocate1"),
new(Status.Implemented, "av2", "advocate2"),
new(Status.Implemented, "av3", "advocate3"),
new(Status.Implemented, "abuse"),
new(Status.Implemented, "ad", "admin"),
new(Status.Implemented, "au", "audit"),
new(Status.Implemented, "sent", "sentinel"),
new(Status.Implemented, "celestialhand", "celhan"),
new(Status.Implemented, "eldrytchweb", "eldweb"),
new(Status.Implemented, "radiantblood", "radblo"),
new(Status.Implemented, "ol"),
// §2.4 Turbine chat, 14 net-new (a already counted in §2.3) (14)
new(Status.Implemented, "guild", "gu"),
new(Status.Implemented, "general", "cg"),
new(Status.Implemented, "trade", "ct"),
new(Status.Implemented, "lfg", "clfg"),
new(Status.Implemented, "roleplay", "crp"),
new(Status.Implemented, "society", "soc"),
new(Status.Implemented, "olthoi", "o"),
// §2.5 Allegiance management (6; "ab" already counted in §2.3)
new(Status.Implemented, "allegiance", "all"),
new(Status.Implemented, "alh", "ah"),
new(Status.ServerPassthrough, "motd"), // TS-68
new(Status.Implemented, "speaker"),
// §2.5b Housing (7)
new(Status.Implemented, "house", "hou"),
new(Status.Implemented, "hor", "hr"),
new(Status.Implemented, "hom", "hoa"),
new(Status.Implemented, "hslist"),
// §2.6 Death / recall / PK (17)
new(Status.Implemented, "lifestone", "lif", "ls"),
new(Status.Implemented, "marketplace", "mar", "mp"),
new(Status.Implemented, "pkarena", "pka"),
new(Status.Implemented, "pklarena", "pla"),
new(Status.Implemented, "pklite", "pkl"),
new(Status.Implemented, "die"),
new(Status.Implemented, "corpse", "cor"),
new(Status.Implemented, "consent"),
new(Status.Implemented, "permit"),
// §2.7 Status / display (8)
new(Status.Implemented, "age"),
new(Status.Implemented, "birth"),
new(Status.ServerPassthrough, "day"), // TS-69 — no sky/time-of-day override hook
new(Status.Implemented, "endurance"),
new(Status.Implemented, "framerate"),
new(Status.Implemented, "loc"),
new(Status.Implemented, "version"),
new(Status.ServerPassthrough, "render"), // TS-69 — no SmartBox equivalent
// §2.8 Interface layout, emotes, social, components (18)
new(Status.Implemented, "saveui"),
new(Status.Implemented, "loadui"),
new(Status.Implemented, "saveautoui"),
new(Status.Implemented, "loadautoui"),
new(Status.Implemented, "lockui"),
new(Status.Implemented, "emote", "e", "em", "me"),
new(Status.Implemented, "emotes"),
new(Status.Implemented, "afk"),
new(Status.Implemented, "friends"),
new(Status.Implemented, "friends_add"),
new(Status.Implemented, "friends_remove"),
new(Status.Implemented, "squelch"),
new(Status.Implemented, "unsquelch"),
new(Status.Implemented, "fillcomps"),
];
private static readonly HashSet<string> CatalogVerbs =
new(RetailClientCommandCatalog.KnownVerbs, StringComparer.OrdinalIgnoreCase);
private static bool IsExecutable(string verb)
{
if (verb is "help" or "?")
return true; // local presentation — not a catalog/parser entry.
if (CatalogVerbs.Contains(verb))
return true;
if (ChatInputParser.IsKnownVerb("/" + verb))
return true;
if (RetailChannelTagTable.IsUnregisteredFallbackTag(verb))
return true;
return false;
}
public static IEnumerable<object[]> AllVerbsWithStatus() =>
Registry.SelectMany(entry => entry.Verbs.Select(verb => new object[] { verb, entry.Status }));
[Theory]
[MemberData(nameof(AllVerbsWithStatus))]
public void EveryRegistryVerb_HasTheCorrectOwnershipStatus(string verb, object statusObj)
{
var status = (Status)statusObj;
bool executable = IsExecutable(verb);
if (status == Status.Implemented)
{
Assert.True(executable,
$"'{verb}' is marked Implemented in the registry but no production surface " +
"(RetailClientCommandCatalog, ChatInputParser, or RetailChannelTagTable) recognizes it.");
}
else
{
Assert.False(executable,
$"'{verb}' is marked {status} (must fall through to server passthrough) but a " +
"production surface claims to execute it locally — that's a real behavior change " +
"the registry doesn't know about yet.");
}
}
[Fact]
public void Registry_EnumeratesExactly152Verbs()
{
int total = Registry.Sum(entry => entry.Verbs.Length);
Assert.Equal(152, total);
}
[Fact]
public void Registry_StatusCountsMatchTheAuditedTotals()
{
Assert.Equal(9, Registry.Where(e => e.Status == Status.HelpOnly).Sum(e => e.Verbs.Length));
Assert.Equal(5, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length));
Assert.Equal(138, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length));
}
[Fact]
public void NoDuplicateVerbsAcrossEntries()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Entry entry in Registry)
{
foreach (string verb in entry.Verbs)
{
Assert.True(seen.Add(verb), $"'{verb}' appears more than once in the registry.");
}
}
}
// ── Ownership-rule enforcement, reverse direction ──────────────────
//
// A verb RetailClientCommandCatalog or ChatInputParser claims to
// execute that ISN'T in the registry above is exactly the "acdream
// invented a verb retail doesn't have" class of bug the doc's §4
// "candidates for removal" list called out (gen, cv, lookingforgroup,
// tr, role, h) — deleted at CH4. This test fails the build the next
// time one slips back in.
[Fact]
public void RetailClientCommandCatalog_HasNoVerbsOutsideTheRegistry()
{
var registryVerbs = new HashSet<string>(
Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase);
foreach (string verb in RetailClientCommandCatalog.KnownVerbs)
{
Assert.True(registryVerbs.Contains(verb),
$"RetailClientCommandCatalog recognizes '{verb}', which is not in the retail " +
"command-registry doc — either it's a genuine retail verb missing from this " +
"test's registry, or it's an invented alias that must be deleted.");
}
}
[Fact]
public void ChatInputParser_HasNoVerbsOutsideTheRegistry()
{
var registryVerbs = new HashSet<string>(
Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase);
foreach (string verbWithSlash in ChatInputParser.KnownVerbs)
{
string verb = verbWithSlash.TrimStart('/');
Assert.True(registryVerbs.Contains(verb),
$"ChatInputParser recognizes '{verbWithSlash}', which is not in the retail " +
"command-registry doc — either it's a genuine retail verb missing from this " +
"test's registry, or it's an invented alias that must be deleted.");
}
}
// ── CH4 REJECT-review nit 13 (2026-08-09) ───────────────────────────
//
// The tests above only prove a verb STRING is recognized somewhere —
// not which channel it actually resolves to. A rebind regression (e.g.
// "/g" quietly reverting to General, the exact Tier-1 #1 bug this
// campaign fixed) would still pass every case above. These two pin the
// real dispatch outcome so a rebind regression fails THIS suite, not
// just a narrower parser-only test.
[Fact]
public void GVerb_BindsToFellowship_NotGeneral()
{
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
"/g hi gang", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel);
}
[Fact]
public void RpVerb_BindsToReply_NotRoleplay()
{
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
"/rp hello back", ChatChannelKind.Say, lastTellSender: "Aunt Agatha");
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Aunt Agatha", parsed!.Value.TargetName);
}
}