acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
Erik 579cbfb48b fix(chat): block / unknown commands from broadcasting as speech
User reported typing /ls (a command-style request, not chat) gets
echoed by the server as "You say, \"/ls\"". Slash-prefix is a
COMMAND surface, never a chat surface. Filed after the same flow
that produced @help and the welcome-message work.

Behavior change at the ChatPanel submit layer:
- Any /-prefixed input whose verb isn't in our alias tables now
  renders a local "[System] Unknown command: /foo. Type /help for
  the list." line and is NEVER published to the bus. No SendChatCmd,
  no Talk packet. The server never sees /foo.
- Known /-verbs (/say /tell /reply /retell /general /allegiance
  /patron /vassals /monarch /covassals /fellowship /lookingforgroup
  /trade /roleplay /society /olthoi /help /clear /framerate /loc
  and friends) still flow through ChatInputParser.Parse → SendChatCmd
  exactly as before.
- @-prefix unchanged: ACE's CommandManager handles unknown @ verbs
  server-side and replies via SystemChat ("Unknown command: foo")
  per ACE GameActionTalk.cs:21. Our @ -> / normalization for known
  verbs (Phase J Tier 1) and the @-passthrough fallthrough for
  unknown verbs both still apply.

ChatInputParser now exposes:
- IsKnownVerb(string verb): query against the union of every alias
  table. Used by ChatPanel to discriminate "unknown verb" from
  "known verb with bad args".
- GetVerbToken(string command): public alias of the existing
  ExtractVerb so callers can pull the first whitespace token without
  reproducing the helper.

Parse itself is unchanged — its existing fall-through (Say with
literal text) still applies for unknown /-verbs called directly via
the parser, but ChatPanel intercepts before reaching that path so
the fall-through never fires through the live submit pipeline. Tests
that directly call Parse continue to pass; the new ChatPanel-level
tests pin the unknown-command rejection.

19 new tests:
- ChatInputParserTests: 10 IsKnownVerb Theory cases + 4 GetVerbToken
  Theory cases.
- ChatPanelInputTests: 5 Theory cases for Submit_UnknownSlashCommand
  covering /foo, /ls, /mp <path>, /genio, and bare /.

Solution total: 1086 green (243 Core.Net + 183 UI + 660 Core),
0 warnings.

Acceptance: type /ls, /mp /path, /anything-not-known — see local
"[System] Unknown command: /xxx. Type /help for the list of
supported commands." Nothing reaches the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:49:56 +02:00

294 lines
12 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);
}
// -- Channel aliases (single-message) -------------------------------
[Theory]
[InlineData("/g raid time", ChatChannelKind.General, "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")]
[InlineData("/cv covassals only", ChatChannelKind.CoVassals, "covassals only")]
[InlineData("/lfg need 3 more", ChatChannelKind.Lfg, "need 3 more")]
[InlineData("/trade wts gem", ChatChannelKind.Trade, "wts gem")]
[InlineData("/role *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: holtburger fall-through to Talk(literal)
// ("/wave hello" → Talk("/wave hello") + treat-as-Say). See
// chat.rs::handle_slash_command default arm at line ~744.
[Fact]
public void UnknownSlashCommand_FallsBackToDefaultChannelWithLiteralText()
{
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);
}
// -- 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 falls
// through. The Parse-level behaviour for unknown /-verbs is
// still "Say with the literal text" (matches holtburger);
// the ChatPanel layer is what catches unknowns and shows the
// local "Unknown command" line. ChatPanelInputTests cover
// that end-to-end behaviour.
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("/allegiance", true)]
[InlineData("/lookingforgroup", true)]
[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));
}
[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("/allegiance 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("/lookingforgroup hunt invite", ChatChannelKind.Lfg, "hunt invite")]
[InlineData("/roleplay walk-up", ChatChannelKind.Roleplay, "walk-up")]
[InlineData("/rp 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);
}
}