Retail treats / and @ interchangeably for commands, but two client-side
layers broke the / spelling for server commands:
- ChatCommandRouter refused ANY unknown /verb with a local "Unknown
command" guess — so /tele, /ci, /acehelp never reached ACE even
though ACE supports them. The guard is gone; the server is now the
single authority on what's a valid command (ACE replies "Unknown
command: x" itself).
- ChatInputParser passed unknown /verbs through as literal speech.
ACE's GameActionTalk only intercepts the @ form on the wire (the /
acceptance in CommandManager is the server CONSOLE path), so the
parser now rewrites unknown /xyz -> @xyz.
Both command pass-throughs (@ and rewritten /) now also force the Say
channel: GameActionTalk (0x0015) is the only wire action ACE parses
commands on — previously a command typed with a chat channel active
would broadcast as channel speech.
Phase J Tier 4 ("/-text must never broadcast as speech") still holds:
letter-verbed input goes out as an @command (never speech), and
command-shaped-but-verbless input ("/", "//shrug") is refused locally
by a narrow router guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
319 lines
14 KiB
C#
319 lines
14 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: 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("/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);
|
|
}
|
|
}
|