using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.UI.Abstractions.Tests.Panels.Chat; public sealed class RetailClientCommandCatalogTests { [Theory] [InlineData("/lifestone")] [InlineData("@lifestone")] [InlineData("/lif")] [InlineData("@LIF")] [InlineData("/ls")] [InlineData("@LS")] public void RetailAliases_ResolveCaseInsensitively(string input) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(ClientCommandId.LifestoneRecall, match.Command); Assert.True(match.HasValidArguments); Assert.Empty(match.Arguments); } [Theory] [InlineData("/marketplace", ClientCommandId.MarketplaceRecall)] [InlineData("@MAR", ClientCommandId.MarketplaceRecall)] [InlineData("/mp", ClientCommandId.MarketplaceRecall)] [InlineData("/pkarena", ClientCommandId.PkArenaRecall)] [InlineData("/pka", ClientCommandId.PkArenaRecall)] [InlineData("/pklarena", ClientCommandId.PkLiteArenaRecall)] [InlineData("/pla", ClientCommandId.PkLiteArenaRecall)] [InlineData("/pklite", ClientCommandId.EnterPkLite)] [InlineData("@pklite", ClientCommandId.EnterPkLite)] [InlineData("/hor", ClientCommandId.HouseRecall)] [InlineData("/hr", ClientCommandId.HouseRecall)] [InlineData("/hom", ClientCommandId.MansionRecall)] [InlineData("/hoa", ClientCommandId.MansionRecall)] [InlineData("/age", ClientCommandId.QueryAge)] [InlineData("/birth", ClientCommandId.QueryBirth)] [InlineData("/framerate", ClientCommandId.ToggleFrameRate)] [InlineData("/lockui", ClientCommandId.ToggleUiLock)] [InlineData("/version", ClientCommandId.ShowVersion)] [InlineData("/loc", ClientCommandId.ShowLocation)] [InlineData("/corpse", ClientCommandId.ShowLastCorpseLocation)] [InlineData("/cor", ClientCommandId.ShowLastCorpseLocation)] public void AdditionalRetailAliases_Resolve(string input, ClientCommandId expected) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expected, match.Command); Assert.True(match.HasValidArguments); } [Theory] [InlineData("/clear all", ClientCommandId.ClearChat)] [InlineData("/saveui hunt", ClientCommandId.SaveUi)] [InlineData("/loadui hunt", ClientCommandId.LoadUi)] [InlineData("/saveautoui", ClientCommandId.SaveAutoUi)] [InlineData("/loadautoui", ClientCommandId.LoadAutoUi)] [InlineData("/afk msg lunch", ClientCommandId.Away)] [InlineData("/consent who", ClientCommandId.Consent)] [InlineData("/e waves", ClientCommandId.Emote)] [InlineData("/em waves", ClientCommandId.Emote)] [InlineData("/emote waves", ClientCommandId.Emote)] [InlineData("/me waves", ClientCommandId.Emote)] [InlineData("/emotes", ClientCommandId.ListEmotes)] [InlineData("/friends online", ClientCommandId.Friends)] [InlineData("/friends_add Alice", ClientCommandId.FriendsAdd)] [InlineData("/friends_remove Alice", ClientCommandId.FriendsRemove)] [InlineData("/squelch -tell Alice", ClientCommandId.Squelch)] [InlineData("/unsquelch Alice", ClientCommandId.Unsquelch)] [InlineData("/filter -combat", ClientCommandId.Filter)] [InlineData("/unfilter -combat", ClientCommandId.Unfilter)] [InlineData("/messagetypes", ClientCommandId.ListMessageTypes)] [InlineData("/fillcomps scarabs 500", ClientCommandId.FillComponents)] public void CommandFamilies_ResolveToTypedClientCommands( string input, ClientCommandId expected) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expected, match.Command); Assert.True(match.HasValidArguments); } [Theory] [InlineData("/house recall", ClientCommandId.HouseRecall)] [InlineData("@house mansion_recall", ClientCommandId.MansionRecall)] [InlineData("/house alleg_recall", ClientCommandId.MansionRecall)] public void HouseRecallSubcommands_Resolve(string input, ClientCommandId expected) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expected, match.Command); Assert.True(match.HasValidArguments); } [Theory] [InlineData("/house open")] [InlineData("/house close")] [InlineData("/house guest add Bob")] [InlineData("/house storage add Bob")] [InlineData("/house nope")] [InlineData("/house available")] public void UnsupportedHouseSubcommand_FallsThroughToServerPassthrough(string input) { // Campaign CH slice CH4 (2026-08-09), Tier 1 fix #4: unrecognized // house subcommands must reach ACE (TS-68), not be swallowed // locally with a wrong usage message. Assert.False(RetailClientCommandCatalog.TryMatch(input, out _)); } [Theory] [InlineData("/house abandon", ClientCommandId.HouseAbandon)] [InlineData("/house re", ClientCommandId.HouseRecall)] [InlineData("/house ma", ClientCommandId.MansionRecall)] [InlineData("/hou recall", ClientCommandId.HouseRecall)] public void HouseAliasesAndShortcuts_Resolve(string input, ClientCommandId expected) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expected, match.Command); Assert.True(match.HasValidArguments); } [Theory] [InlineData("/allegiance boot Bob")] [InlineData("/allegiance ban add Bob")] [InlineData("/allegiance motd")] [InlineData("/allegiance")] [InlineData("/all officer add 2 Bob")] public void UnsupportedAllegianceSubcommand_ShowsRetailRefusal_ClientSide(string input) { // CH4 REJECT-review Blocker 1 (2026-08-09): unlike @house (whose // unrecognized subcommands correctly reach ACE, see the test // above), retail's own DoAllegiance NEVER falls through to // DoChannelCommand/the server for an unrecognized subcommand — it // claims the whole verb unconditionally and prints its own // client-local refusal (label_57da4b, 0x0057DA4B). The earlier // "falls through to server passthrough" behavior here was itself // the bug: an unmatched subcommand used to escape all the way to // the unregistered-tag channel fallback and broadcast to the // Allegiance chat channel. Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.False(match.HasValidArguments); Assert.Equal( "Please see @help Allegiance for more information on how to use this command.", match.InvalidArgumentsText); } [Theory] [InlineData("/allegiance hometown", ClientCommandId.AllegianceHometown, "")] [InlineData("/allegiance ho", ClientCommandId.AllegianceHometown, "")] [InlineData("/alh", ClientCommandId.AllegianceHometown, "")] [InlineData("/ah", ClientCommandId.AllegianceHometown, "")] [InlineData("/allegiance info", ClientCommandId.AllegianceInfo, "")] [InlineData("/allegiance info Bob", ClientCommandId.AllegianceInfo, "Bob")] [InlineData("/all info Bob", ClientCommandId.AllegianceInfo, "Bob")] public void AllegianceImplementedSubcommands_Resolve( string input, ClientCommandId expected, string expectedArguments) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expected, match.Command); Assert.True(match.HasValidArguments); Assert.Equal(expectedArguments, match.Arguments); } [Theory] [InlineData("/pkl", ClientCommandId.EnterPkLite)] [InlineData("/message_types", ClientCommandId.ListMessageTypes)] [InlineData("/msgtypes", ClientCommandId.ListMessageTypes)] [InlineData("/msg_types", ClientCommandId.ListMessageTypes)] [InlineData("/endurance", ClientCommandId.Endurance)] [InlineData("/speaker", ClientCommandId.Speaker)] [InlineData("/index", ClientCommandId.IndexChannels)] // CH4 REJECT-review nit 14 (2026-08-09): DoChannelIndex ignores argc — // "@index foo" sends the same request as bare "@index". [InlineData("/index foo", ClientCommandId.IndexChannels)] public void MissingAliasesSweep_Resolve(string input, ClientCommandId expected) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expected, match.Command); Assert.True(match.HasValidArguments); } [Theory] [InlineData("/chat on")] [InlineData("/chat off")] [InlineData("/notell on")] [InlineData("/notell off")] public void ChatNoTellToggle_ValidArguments_Resolve(string input) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.True(match.HasValidArguments); } [Fact] public void ChatToggle_InvalidArgument_IsRejected() { Assert.True(RetailClientCommandCatalog.TryMatch("/chat maybe", out var match)); Assert.False(match.HasValidArguments); } [Theory] [InlineData("/join allegiance")] [InlineData("/join general")] [InlineData("/leave society")] [InlineData("/leave soc")] public void JoinLeave_ValidTags_Resolve(string input) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.True(match.HasValidArguments); } [Fact] public void JoinLeave_InvalidTag_IsRejected() { Assert.True(RetailClientCommandCatalog.TryMatch("/join nonsense", out var match)); Assert.False(match.HasValidArguments); } [Theory] [InlineData("/permit add Bob", true)] [InlineData("/permit remove Bob", true)] // CH4 REJECT-review SHOULD-FIX 5 (2026-08-09): retail's DoPermit joins // every token after the mode word into the name (JoinArgsAsName), so a // multi-word character name is a VALID shape, not a rejected one. [InlineData("/permit add Aunt Agatha", true)] [InlineData("/permit remove Lord Gnarly Beard", true)] [InlineData("/permit add", false)] [InlineData("/permit maybe Bob", false)] public void Permit_ArgumentShape(string input, bool expectedValid) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expectedValid, match.HasValidArguments); } [Theory] [InlineData("/hslist Cottage", true)] [InlineData("/hslist mansion", true)] [InlineData("/hslist nonsense", false)] public void HouseAvailableList_ArgumentShape(string input, bool expectedValid) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expectedValid, match.HasValidArguments); } [Theory] [InlineData("/clist fellowship", true)] [InlineData("/on admin", true)] // CH4 REJECT-review SHOULD-FIX 6 (2026-08-09): the catalog only // validates argument SHAPE (retail's argc != 1 check) — a resolved-but- // UNKNOWN single-token tag is now a VALID shape that reaches // ClientCommandController, which raises WeenieError 0x422 ("That // channel doesn't exist.") instead of the catalog silently rejecting // it with the wrong "Please specify the channel name." usage line. [InlineData("/off nonsense", true)] [InlineData("/clist", false)] [InlineData("/on fellowship extra", false)] public void ChannelArgumentCommands_RequireExactlyOneToken(string input, bool expectedValid) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expectedValid, match.HasValidArguments); } [Fact] public void MrPr_AreNeverExecutable() { // Retail registers @mr/@pr with a NULL function pointer — they // must never resolve as client-owned commands (Tier B.9). Assert.False(RetailClientCommandCatalog.TryMatch("/mr", out _)); Assert.False(RetailClientCommandCatalog.TryMatch("/pr", out _)); Assert.False(RetailClientCommandCatalog.TryMatch("/mr hello", out _)); Assert.False(RetailClientCommandCatalog.TryMatch("/pr hello", out _)); } [Fact] public void LifestoneArgument_IsRecognizedButInvalid() { Assert.True(RetailClientCommandCatalog.TryMatch("/ls now", out var match)); Assert.Equal("now", match.Arguments); Assert.False(match.HasValidArguments); Assert.Equal("/lifestone", match.Usage); } [Theory] [InlineData("/ci 629")] [InlineData("@acehelp")] [InlineData("ordinary speech")] public void NonClientCommands_DoNotMatch(string input) => Assert.False(RetailClientCommandCatalog.TryMatch(input, out _)); }