acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs
Erik 724ef2d389 fix(chat): CH4 review fixes — allegiance ownership guard, house-abandon confirmation
Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped
TryMatchAllegiance (which only claimed "info"/"hometown") and fell through
the unregistered-tag channel fallback, broadcasting the raw subcommand
text to the Allegiance chat channel (0x02000000). Retail's own
DoAllegiance never reaches DoChannelCommand for an unrecognized
subcommand — it claims the whole verb and prints its own client-local
refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally
and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter
also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership
guard in TryDispatchChannelFallback as defense in depth.

Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation.
Retail runs a real two-stage dialog before Event_AbandonHouse(); ported
both verbatim strings and chained two ShowConfirmation calls.

Should-fixes: a bare unregistered tag with no text now passes through
silently instead of showing a refusal that belongs to a different retail
function; @join/@leave update RuntimeCharacterOptionsState locally (new
SetOptionBit) before the wire push so the Turbine membership gate stops
refusing a just-joined room; @permit accepts multi-word names; @clist/
@on/@off validate shape only and raise WeenieError 0x422 for an unknown
tag; @mr/@pr help text is now the verbatim retail strings; corrected
issue #360, register row TS-68, the campaign doc's B.7 note, and a stale
RetailChannelTagTable comment; filed issue #363 + register row AP-183 for
the deferred error-typing debt.

Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores
the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's
olthoi false-positive, added /g and /rp binding-level conformance pins,
made @index ignore extra arguments, and noted the six removed invented
verbs in ISSUES.md.

Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's
12,190/4/0 — net +26 tests, no removals.

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

286 lines
12 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_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 _));
}