acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.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

258 lines
11 KiB
C#

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_FallsThroughToServerPassthrough(string input)
{
// Same Tier-1-class fix, applied to the allegiance management
// dispatcher (TS-68): unrecognized subcommands reach ACE.
Assert.False(RetailClientCommandCatalog.TryMatch(input, out _));
}
[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)]
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)]
[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)]
[InlineData("/off nonsense", false)]
public void ChannelArgumentCommands_ResolveTagsAgainstRetailChannelTagTable(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 _));
}