acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
Erik 090825e703 feat(chat): Campaign CH slice CH4 — command registry completion
Brings acdream's / and @ command parsing to parity with the complete
retail registry (130 registered verbs + 22 unregistered GetChannelID
fallback tags = 152 client-parsed verbs), per
docs/research/2026-08-09-chat-retail-command-registry.md.

Parser semantics (retail OnChatCommand/DoCommand):
- : and ; rewrite to "@emote <rest>" before dispatch.
- Verb trailing-comma trim ("@f, hi" == "@f hi") applied at every
  verb-lookup site in the catalog and the parser.
- @tell/aliases split the target on the FIRST COMMA, not the first
  whitespace token, so multi-word names work ("@tell Aunt Agatha, hi").
- The 22 unregistered GM/faction channel tags (admin, sentinel,
  celestialhand, ...) now broadcast for real via a new
  RetailChannelTagTable + SendRawChannelCmd bypass, reusing the existing
  BuildChatChannel wire builder.

Binding corrections:
- /g, /group, /party -> Fellowship (0x800), not General.
- /rp -> reply alias (retail's own help text confirms "@r or @rp"), not
  Roleplay; /role (an acdream invention) deleted.
- /allegiance, /all -> the allegiance management command
  (RetailClientCommandCatalog), not a channel verb.
- /house no longer swallows unrecognized subcommands with a local usage
  error; they now correctly fall through to ACE.
- @mr/@pr pinned as permanently non-executable (retail registers them
  with a null function pointer).

New verbs with real local execution: endurance, speaker, title (silent,
AP-182), chat, notell, join, leave, permit, hslist, index, clist, on,
off, alh/ah (+ "@allegiance hometown"/"ho"), "@allegiance info",
"@house abandon"; a missing-alias sweep across pkl/hou/message_types/
msgtypes/msg_types/rt/send/whisper/w/vassal/covassal/co-vassals/c/
fellows/group/party/guild/gu/cg/ct/clfg/crp/soc/o; the non-retail
inventions gen/cv/lookingforgroup/tr/role/h are deleted. New Core.Net
wire builders (IndexChannels, ListChannels, AddChannel, RemoveChannel,
RecallAllegianceHometown, AllegianceInfoRequest, ListAvailableHouses,
AddPlayerPermission, RemovePlayerPermission, AbandonHouse) are all
parameterless or single-field payloads cross-checked against ACE's
GameAction readers, not guessed.

Deferred (filed as #360/#361/#362, register rows TS-68/TS-69/TS-70):
the ~22 remaining allegiance/house subcommands + standalone @motd
(largest single item, needs its own slice per the doc), the three
still-inert pure-local commands (day/log/render), and the inbound
GameEvent responses for the new outbound requests. All correctly fall
through to ACE server-passthrough rather than being silently swallowed
or faking success.

RetailCommandRegistryConformanceTests pins the complete 152-verb
registry against production: every verb resolves through exactly one
production surface if Implemented, through none if HelpOnly/
ServerPassthrough, and two reverse-direction tests fail the build if
RetailClientCommandCatalog or ChatInputParser ever claims a verb
outside this registry again. Final tally: 138 Implemented / 5
ServerPassthrough / 9 HelpOnly = 152.

Release suite: 12,190 passed / 4 skipped / 0 failed (up from CH3's
11,964/4/0).

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

426 lines
18 KiB
C#

using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// TDD coverage for <see cref="ChatInputParser.Parse"/>. Pure function:
/// no fakes, no setup. Each prefix in the holtburger
/// <c>chat.rs</c> alias table gets at least one round-trip test plus
/// every documented edge case.
/// </summary>
public sealed class ChatInputParserTests
{
// -- Default channel (no prefix / explicit /say) --------------------
[Fact]
public void NoPrefix_UsesDefaultChannelLiteral()
{
var parsed = ChatInputParser.Parse("hello world", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("hello world", parsed.Value.Text);
}
[Fact]
public void SayPrefix_StripsTheSlashSay()
{
var parsed = ChatInputParser.Parse("/say hi there", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("hi there", parsed.Value.Text);
}
// -- Tell aliases ----------------------------------------------------
[Fact]
public void TellPrefix_FullForm_ParsesTargetAndMessage()
{
var parsed = ChatInputParser.Parse("/tell Bestie hi there", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("hi there", parsed.Value.Text);
}
[Fact]
public void TellPrefix_ShortForm_ParsesTargetAndMessage()
{
var parsed = ChatInputParser.Parse("/t Bestie hi", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("hi", parsed.Value.Text);
}
[Fact]
public void TellPrefix_MultiWordTarget_ChompsFirstTokenAsTarget()
{
// Holtburger split_once on whitespace: first token is target.
// "/t Sir Lancelot hello" -> target="Sir", text="Lancelot hello".
var parsed = ChatInputParser.Parse("/t Sir Lancelot hello", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Sir", parsed.Value.TargetName);
Assert.Equal("Lancelot hello", parsed.Value.Text);
}
[Theory]
[InlineData("/t Caith, hi", "Caith")]
[InlineData("/t Caith: hi", "Caith")]
[InlineData("/t Caith; hi", "Caith")]
[InlineData("/t Caith. hi", "Caith")]
[InlineData("/t Caith! hi", "Caith")]
[InlineData("/t Caith? hi", "Caith")]
[InlineData("/tell Caith, hi", "Caith")]
public void TellPrefix_StripsTrailingPunctuationFromTarget(string raw, string expectedTarget)
{
// Retail muscle memory: "/t Name, message" — comma is the
// separator. Filed after a 2026-04-25 live-launch session
// where typing "/t je, hello" produced target="je," and the
// server responded with WeenieError 0x052B (CharacterNotAvailable)
// because no character "je," exists. Strip a trailing
// ,;:.!? from the target so both forms work.
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal(expectedTarget, parsed.Value.TargetName);
Assert.Equal("hi", parsed.Value.Text);
}
// -- Reply aliases ---------------------------------------------------
[Fact]
public void ReplyPrefix_UsesLastIncomingTellSenderAsTarget()
{
var parsed = ChatInputParser.Parse("/r back at you", ChatChannelKind.Say, lastTellSender: "Bestie");
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("back at you", parsed.Value.Text);
}
[Fact]
public void ReplyPrefix_NoLastSender_ReturnsNull()
{
var parsed = ChatInputParser.Parse("/r hi", ChatChannelKind.Say, lastTellSender: null);
Assert.Null(parsed);
}
[Fact]
public void RpAlias_IsReply_NotRoleplay()
{
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #2: retail's own
// help text confirms "@reply <text> ... You may also use @r or
// @rp." — a private reply, not a Roleplay broadcast.
var parsed = ChatInputParser.Parse("/rp back at you", ChatChannelKind.Say, lastTellSender: "Bestie");
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("back at you", parsed.Value.Text);
}
[Fact]
public void TellAliases_SendWhisperW_AllRouteAsTell()
{
// Campaign CH slice CH4 (2026-08-09): retail's DoTell registers
// FOUR verb strings — tell/t/send/whisper/w.
foreach (string verb in new[] { "/send", "/whisper", "/w" })
{
var parsed = ChatInputParser.Parse($"{verb} Bestie hi", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("hi", parsed.Value.Text);
}
}
[Fact]
public void RetellAlias_Rt_RoutesLikeRetell()
{
var parsed = ChatInputParser.Parse(
"/rt once more",
ChatChannelKind.Say,
lastTellSender: null,
lastOutgoingTellTarget: "Caith");
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Caith", parsed.Value.TargetName);
Assert.Equal("once more", parsed.Value.Text);
}
[Theory]
[InlineData("/tell Aunt Agatha, hello", "Aunt Agatha", "hello")]
[InlineData("/t Aunt Agatha, hello", "Aunt Agatha", "hello")]
public void TellTarget_SplitsOnFirstComma_NotFirstWhitespace(string raw, string expectedTarget, string expectedText)
{
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #6 (A.3): retail's
// DoTell requires a comma after the name precisely because names
// can be multiple words. Splitting on the first WHITESPACE (the
// pre-CH4 behavior) truncated "Aunt Agatha" to "Aunt".
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal(expectedTarget, parsed.Value.TargetName);
Assert.Equal(expectedText, parsed.Value.Text);
}
// -- Channel aliases (single-message) -------------------------------
[Theory]
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #1: "/g" is
// Fellowship in retail (g/group/party/fellow/fellows/fellowship all
// bind 0x800), NOT General — sending fellowship chatter to General
// was a live correctness bug.
[InlineData("/g raid time", ChatChannelKind.Fellowship, "raid time")]
[InlineData("/f buff up", ChatChannelKind.Fellowship, "buff up")]
[InlineData("/a swearing in", ChatChannelKind.Allegiance, "swearing in")]
[InlineData("/m monarch broadcast", ChatChannelKind.Monarch, "monarch broadcast")]
[InlineData("/p patron only", ChatChannelKind.Patron, "patron only")]
[InlineData("/v vassals only", ChatChannelKind.Vassals, "vassals only")]
// "/cv" (an acdream invention) is DELETED; "/c" is the real retail
// Co-vassals alias (registry doc §2.3).
[InlineData("/c covassals only", ChatChannelKind.CoVassals, "covassals only")]
[InlineData("/lfg need 3 more", ChatChannelKind.Lfg, "need 3 more")]
[InlineData("/trade wts gem", ChatChannelKind.Trade, "wts gem")]
// "/role" (an acdream invention) is DELETED; "/roleplay" is retail's
// real verb (see LongFormAliases_RouteToTheirChannel for the full
// roleplay/reply-alias split).
[InlineData("/roleplay *waves*", ChatChannelKind.Roleplay, "*waves*")]
[InlineData("/society olthoi raid", ChatChannelKind.Society, "olthoi raid")]
[InlineData("/olthoi for the queen", ChatChannelKind.Olthoi, "for the queen")]
public void ChannelPrefixes_RouteToTheirChannel(string raw, ChatChannelKind expectedChannel, string expectedText)
{
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(expectedChannel, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal(expectedText, parsed.Value.Text);
}
// -- Edge cases: empty / whitespace / no-message --------------------
[Fact]
public void Empty_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void WhitespaceOnly_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse(" \t ", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void SayWithNoMessage_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("/say", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/say ", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void TellWithNoTargetNoMessage_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("/t", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/tell", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void TellWithTargetButNoMessage_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("/t Bestie", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/tell Bestie ", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void ReplyWithNoMessage_ReturnsNull_EvenWithLastSender()
{
Assert.Null(ChatInputParser.Parse("/r", ChatChannelKind.Say, lastTellSender: "Bestie"));
Assert.Null(ChatInputParser.Parse("/r ", ChatChannelKind.Say, lastTellSender: "Bestie"));
}
[Fact]
public void ChannelPrefixWithNoMessage_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("/g", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/f ", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/a", ChatChannelKind.Say, lastTellSender: null));
}
// -- Unknown slash command: retail / ≡ @ equivalence. ACE's
// GameActionTalk only intercepts @-prefixed Talk on the wire, so
// the parser rewrites the unknown /-verb to its @ form for the
// server's CommandManager. (Deliberate divergence from holtburger's
// literal fall-through, which would speak the command out loud.)
[Fact]
public void UnknownSlashCommand_IsRewrittenToAtFormForServer()
{
var parsed = ChatInputParser.Parse("/xyz hello", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("@xyz hello", parsed.Value.Text);
}
[Theory]
[InlineData("/ci 629", "@ci 629")] // ACE admin: create inventory item
[InlineData("/tele holtburg", "@tele holtburg")] // ACE admin: teleport
[InlineData("@ci 629", "@ci 629")] // @ form already server-ready — untouched
[InlineData("@acehelp", "@acehelp")]
public void ServerCommandVerbs_ReachTheWireInAtForm(string input, string expectedText)
{
var parsed = ChatInputParser.Parse(input, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Equal(expectedText, parsed.Value.Text);
}
[Fact]
public void SlashWithoutLetterVerb_StaysLiteralAtParseLevel()
{
// "/ hello" and "//shrug" have no letter verb after the slash, so
// the pure Parse function leaves them untouched. In the real
// submit flows they never reach the wire: ChatCommandRouter's
// degenerate-prefix guard refuses them locally (Phase J Tier 4 —
// /-prefixed text must never broadcast as speech).
Assert.Equal("/ hello", ChatInputParser.Parse("/ hello", ChatChannelKind.Say, lastTellSender: null)!.Value.Text);
Assert.Equal("//shrug", ChatInputParser.Parse("//shrug", ChatChannelKind.Say, lastTellSender: null)!.Value.Text);
}
// -- Default-channel parameter is honoured when no prefix is given --
[Fact]
public void NoPrefix_HonoursAlternateDefaultChannel()
{
// If the panel ever defaults to e.g. Fellowship channel input,
// the parser must use that for unprefixed text.
var parsed = ChatInputParser.Parse("hi gang", ChatChannelKind.Fellowship, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("hi gang", parsed.Value.Text);
}
// -- The verb itself must be exact: prefix-of-other-word doesn't count --
[Fact]
public void PrefixSubstring_IsNotAVerbMatch()
{
// Phase J added long-form aliases (/general, /allegiance,
// /patron, etc.). The exact-token rule still applies — a
// verb prefix that ISN'T one of the listed aliases is NOT a
// client verb; it routes to the server in @ form like any
// other unknown verb (retail / ≡ @).
var parsed = ChatInputParser.Parse("/genio public", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Equal("@genio public", parsed.Value.Text);
}
[Theory]
[InlineData("/g", true)]
[InlineData("/say", true)]
[InlineData("/tell", true)]
[InlineData("/retell", true)]
[InlineData("/rt", true)]
[InlineData("/rp", true)] // reply alias now, not Roleplay (Tier 1 fix #2)
[InlineData("/guild", true)]
// Campaign CH slice CH4 (2026-08-09): "/allegiance"/"/lookingforgroup"
// are DELETED from ChatInputParser — "allegiance" is now
// RetailClientCommandCatalog's management command (Tier 1 fix #3);
// "lookingforgroup" was never a retail verb (doc §4 removal list).
[InlineData("/allegiance", false)]
[InlineData("/lookingforgroup", false)]
[InlineData("/role", false)] // acdream invention, deleted
[InlineData("/cv", false)] // acdream invention, deleted
[InlineData("/genio", false)]
[InlineData("/ls", false)]
[InlineData("/foo", false)]
[InlineData("/", false)]
public void IsKnownVerb_ChecksAgainstAliasTables(string verb, bool expected)
{
Assert.Equal(expected, ChatInputParser.IsKnownVerb(verb));
}
[Fact]
public void IsKnownVerb_TrimsTrailingComma()
{
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #5: retail's
// DoCommand right-trims ',' off the verb before lookup — the verb
// TOKEN itself ("/f,", as GetVerbToken would extract from "/f, hi").
Assert.True(ChatInputParser.IsKnownVerb("/f,"));
Assert.True(ChatInputParser.IsKnownVerb("/f"));
}
[Fact]
public void CommaTrimmedVerb_ParsesTheSameAsWithoutComma()
{
// "/f, hi" == "/f hi" end to end through Parse.
var withComma = ChatInputParser.Parse("/f, hi", ChatChannelKind.Say, lastTellSender: null);
var withoutComma = ChatInputParser.Parse("/f hi", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(withComma);
Assert.NotNull(withoutComma);
Assert.Equal(withoutComma!.Value.Channel, withComma!.Value.Channel);
Assert.Equal(withoutComma.Value.Text, withComma.Value.Text);
Assert.Equal(ChatChannelKind.Fellowship, withComma.Value.Channel);
Assert.Equal("hi", withComma.Value.Text);
}
[Theory]
[InlineData("/g hello", "/g")]
[InlineData("/tell Bob hi", "/tell")]
[InlineData("/foo", "/foo")]
[InlineData("/", "/")]
public void GetVerbToken_PullsFirstWhitespaceToken(string command, string expected)
{
Assert.Equal(expected, ChatInputParser.GetVerbToken(command));
}
[Theory]
[InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")]
[InlineData("/guild recall", ChatChannelKind.Allegiance, "recall")]
[InlineData("/patron need help", ChatChannelKind.Patron, "need help")]
[InlineData("/vassals listen up", ChatChannelKind.Vassals, "listen up")]
[InlineData("/monarch heads up", ChatChannelKind.Monarch, "heads up")]
[InlineData("/covassals tax season", ChatChannelKind.CoVassals, "tax season")]
[InlineData("/fellowship buff time", ChatChannelKind.Fellowship, "buff time")]
[InlineData("/fellow buff time", ChatChannelKind.Fellowship, "buff time")]
[InlineData("/fellows buff time", ChatChannelKind.Fellowship, "buff time")]
[InlineData("/group buff time", ChatChannelKind.Fellowship, "buff time")]
[InlineData("/party buff time", ChatChannelKind.Fellowship, "buff time")]
[InlineData("/clfg hunt invite", ChatChannelKind.Lfg, "hunt invite")]
[InlineData("/roleplay walk-up", ChatChannelKind.Roleplay, "walk-up")]
[InlineData("/crp walk-up", ChatChannelKind.Roleplay, "walk-up")]
public void LongFormAliases_RouteToTheirChannel(string raw, ChatChannelKind expected, string text)
{
// Phase J: retail muscle memory uses long forms ("/patron"
// not just "/p"). Filed after a 2026-04-25 live test where
// "/patron hello" fell through as /say with the literal
// slash-prefixed text.
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(expected, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal(text, parsed.Value.Text);
}
}