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>
This commit is contained in:
parent
090825e703
commit
724ef2d389
17 changed files with 853 additions and 85 deletions
|
|
@ -300,6 +300,124 @@ public sealed class ClientCommandControllerTests
|
|||
Assert.Equal(["Component list cleared.", "You need an open vendor."], messages);
|
||||
}
|
||||
|
||||
// ── CH4 REJECT-review Blocker 2 (2026-08-09) ────────────────────────
|
||||
// "@house abandon" must run retail's real two-stage confirmation
|
||||
// (DoHouse's abandon branch @0x00580D58 → HouseAbandonDialogCallback_
|
||||
// First @0x00580E1A → HouseAbandonDialogCallback_Second @0x0057BE90,
|
||||
// the ONLY Event_AbandonHouse() call site @0x0057BF01) before sending
|
||||
// 0x021F — previously it sent immediately with no confirmation at all.
|
||||
|
||||
[Fact]
|
||||
public void HouseAbandon_BothStagesAccepted_ShowsBothPromptsThenSendsExactlyOnce()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
||||
"confirm:Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!",
|
||||
"houseabandon",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HouseAbandon_DeclineFirstStage_ShowsOnlyOnePromptAndNeverSends()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls, confirmationResponses: new Queue<bool>([false]));
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
||||
],
|
||||
calls);
|
||||
Assert.DoesNotContain("houseabandon", calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HouseAbandon_DeclineSecondStage_ShowsBothPromptsAndNeverSends()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls, confirmationResponses: new Queue<bool>([true, false]));
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
||||
"confirm:Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!",
|
||||
],
|
||||
calls);
|
||||
Assert.DoesNotContain("houseabandon", calls);
|
||||
}
|
||||
|
||||
// ── CH4 REJECT-review SHOULD-FIX 5 (2026-08-09) ─────────────────────
|
||||
// "@permit add/remove <multi-word name>" joins every token after the
|
||||
// mode word into the name (retail's JoinArgsAsName).
|
||||
|
||||
[Fact]
|
||||
public void Permit_MultiWordName_JoinsTheRemainderIntoOneName()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var controller = NewController(calls);
|
||||
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Permit, "add Aunt Agatha"));
|
||||
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Permit, "remove Lord Gnarly Beard"));
|
||||
|
||||
Assert.Equal(
|
||||
["permitadd:Aunt Agatha", "permitremove:Lord Gnarly Beard"],
|
||||
calls);
|
||||
}
|
||||
|
||||
// ── CH4 REJECT-review SHOULD-FIX 6 (2026-08-09) ─────────────────────
|
||||
// @clist/@on/@off with an unresolvable (but single-token) tag raises
|
||||
// retail's WeenieError 0x422 ("That channel doesn't exist.") instead
|
||||
// of silently doing nothing.
|
||||
|
||||
[Fact]
|
||||
public void ChannelArgumentCommands_UnknownTag_ShowsWeenieError422WithoutSending()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var errors = new List<uint>();
|
||||
var controller = NewController(calls, errors);
|
||||
|
||||
Execute(ClientCommandId.ListChannel, "nonsense");
|
||||
Execute(ClientCommandId.OnChannel, "nonsense");
|
||||
Execute(ClientCommandId.OffChannel, "nonsense");
|
||||
|
||||
Assert.Empty(calls);
|
||||
Assert.Equal([0x0422u, 0x0422u, 0x0422u], errors);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChannelArgumentCommands_KnownTag_SendsWithoutError()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var errors = new List<uint>();
|
||||
var controller = NewController(calls, errors);
|
||||
|
||||
Execute(ClientCommandId.ListChannel, "fellowship");
|
||||
Execute(ClientCommandId.OnChannel, "admin");
|
||||
Execute(ClientCommandId.OffChannel, "sentinel");
|
||||
|
||||
Assert.Empty(errors);
|
||||
Assert.Equal(
|
||||
["clist:2048", "on:2", "off:512"],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownCommandId_FailsAtApplicationBoundary()
|
||||
{
|
||||
|
|
@ -319,7 +437,13 @@ public sealed class ClientCommandControllerTests
|
|||
FriendsState? friends = null,
|
||||
SquelchState? squelch = null,
|
||||
string? lastTeller = null,
|
||||
bool vendorOpen = false)
|
||||
bool vendorOpen = false,
|
||||
// CH4 REJECT-review Blocker 2 (2026-08-09): lets a test drive a
|
||||
// specific accept/decline sequence through consecutive
|
||||
// ShowConfirmation calls (e.g. house-abandon's two-stage prompt).
|
||||
// Defaults to "always accept" so every pre-existing single-stage
|
||||
// test (Die, etc.) keeps its original behavior unchanged.
|
||||
Queue<bool>? confirmationResponses = null)
|
||||
{
|
||||
calls ??= [];
|
||||
errors ??= [];
|
||||
|
|
@ -347,7 +471,10 @@ public sealed class ClientCommandControllerTests
|
|||
(message, completed) =>
|
||||
{
|
||||
calls.Add($"confirm:{message}");
|
||||
completed(true);
|
||||
bool accepted = confirmationResponses is { Count: > 0 }
|
||||
? confirmationResponses.Dequeue()
|
||||
: true;
|
||||
completed(accepted);
|
||||
},
|
||||
() => calls.Add("suicide"),
|
||||
all => calls.Add("clear:" + all),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Player;
|
||||
|
|
@ -231,6 +232,60 @@ public sealed class RuntimeCharacterStateTests
|
|||
Assert.Equal(-1, state.MovementSkills.JumpSkill);
|
||||
}
|
||||
|
||||
// ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(CharacterOptionId.ListenToGeneralChat, PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)]
|
||||
[InlineData(CharacterOptionId.ListenToTradeChat, PlayerDescriptionParser.CharacterOptions2.HearTradeChat)]
|
||||
[InlineData(CharacterOptionId.ListenToLFGChat, PlayerDescriptionParser.CharacterOptions2.HearLFGChat)]
|
||||
[InlineData(CharacterOptionId.ListenToRoleplayChat, PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)]
|
||||
[InlineData(CharacterOptionId.ListenToSocietyChat, PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)]
|
||||
public void SetOptionBit_Options2Ids_ToggleOnlyTheirOwnBit(
|
||||
CharacterOptionId optionId, PlayerDescriptionParser.CharacterOptions2 bit)
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(options.Options1, 0u); // every Hear*Chat bit off
|
||||
|
||||
options.SetOptionBit((uint)optionId, true);
|
||||
Assert.Equal((uint)bit, options.Options2 & (uint)bit);
|
||||
Assert.Equal(RuntimeCharacterOptionsState.DefaultOptions1, options.Options1);
|
||||
|
||||
options.SetOptionBit((uint)optionId, false);
|
||||
Assert.Equal(0u, options.Options2 & (uint)bit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetOptionBit_AllegianceId_TogglesOptions1NotOptions2()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(0u, options.Options2); // HearAllegianceChat off
|
||||
|
||||
options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, true);
|
||||
Assert.Equal(
|
||||
(uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat,
|
||||
options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat);
|
||||
|
||||
options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, false);
|
||||
Assert.Equal(
|
||||
0u,
|
||||
options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetOptionBit_UnrecognizedId_IsANoOp()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
uint before1 = options.Options1;
|
||||
uint before2 = options.Options2;
|
||||
long beforeRevision = options.Revision;
|
||||
|
||||
options.SetOptionBit(0xFFFFu, true);
|
||||
|
||||
Assert.Equal(before1, options.Options1);
|
||||
Assert.Equal(before2, options.Options2);
|
||||
Assert.Equal(beforeRevision, options.Revision);
|
||||
}
|
||||
|
||||
// ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ───
|
||||
// ── run/jump skill (pseudocode doc §9) ─────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,51 @@ public sealed class TurbineChatMembershipGateTests
|
|||
Assert.Equal(expectedType, refusal.Value.Type);
|
||||
}
|
||||
|
||||
// ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ────────────────────
|
||||
// @join/@leave must update RuntimeCharacterOptionsState locally so
|
||||
// this SAME-SESSION gate stops refusing without waiting on a fresh
|
||||
// PlayerDescription (retail's PlayerModule::SetHear*Chat family
|
||||
// writes the bit locally FIRST, then notifies).
|
||||
|
||||
[Fact]
|
||||
public void JoinChannel_SetOptionBit_FlipsGateToAllowed_WithoutFreshPlayerDescription()
|
||||
{
|
||||
TurbineChatState turbine = ReceivedRooms();
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(options.Options1, 0u); // every Hear*Chat bit off — starts refused
|
||||
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.NotListening,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
|
||||
|
||||
options.SetOptionBit((uint)CharacterOptionId.ListenToGeneralChat, true);
|
||||
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.Allowed,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveChannel_SetOptionBit_FlipsGateToNotListening()
|
||||
{
|
||||
TurbineChatState turbine = ReceivedRooms();
|
||||
var options = new RuntimeCharacterOptionsState(); // General on by default
|
||||
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.Allowed,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
|
||||
|
||||
options.SetOptionBit((uint)CharacterOptionId.ListenToGeneralChat, false);
|
||||
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.NotListening,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
|
||||
}
|
||||
|
||||
private static TurbineChatState ReceivedRooms(
|
||||
uint allegianceRoom = 0x10u,
|
||||
uint generalRoom = 0x11u,
|
||||
|
|
|
|||
|
|
@ -171,15 +171,44 @@ public class ChatCommandRouterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void UnregisteredChannelTag_WithNoText_ShowsRetailRefusal_AndPublishesNothing()
|
||||
public void UnregisteredChannelTag_WithNoText_PassesThroughToServer()
|
||||
{
|
||||
// CH4 REJECT-review SHOULD-FIX 3 (2026-08-09): retail's
|
||||
// DoChannelCommand @0x005774A7 returns 0 SILENTLY on argc<=0 for an
|
||||
// UNREGISTERED tag; DoCommand's own final fallback then sends the
|
||||
// raw @-line to the server via Event_Talk. "You must specify the
|
||||
// text you wish to say!" belongs to DoStupidChannelHack
|
||||
// @0x0057B144, which only runs for REGISTERED channel verbs — it
|
||||
// must never appear for one of the 22 unregistered fallback tags.
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/sentinel", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||||
var command = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal("@sentinel", command.Text);
|
||||
Assert.DoesNotContain(log.Snapshot(), entry => entry.Text.Contains("You must specify the text"));
|
||||
}
|
||||
|
||||
// ── CH4 REJECT-review Blocker 1 (2026-08-09) ────────────────────────
|
||||
// "@allegiance <sub>" must never broadcast to the Allegiance channel
|
||||
// or reach the server for an unrecognized subcommand — retail's own
|
||||
// DoAllegiance claims the entire verb unconditionally and shows its
|
||||
// own client-local refusal.
|
||||
|
||||
[Theory]
|
||||
[InlineData("/allegiance boot Bob")]
|
||||
[InlineData("/all boot Bob")]
|
||||
public void AllegianceUnrecognizedSubcommand_ShowsRetailRefusal_NeverBroadcastsOrSends(string input)
|
||||
{
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Contains(log.Snapshot(), entry => entry.Text == "You must specify the text you wish to say!");
|
||||
Assert.Empty(bus.Published); // no SendRawChannelCmd, no SendServerCommandCmd
|
||||
Assert.Contains(log.Snapshot(), entry =>
|
||||
entry.Text == "Please see @help Allegiance for more information on how to use this command.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -208,6 +237,23 @@ public class ChatCommandRouterTests
|
|||
Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("Returns you to the last lifestone"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/help mr", "@mr <text> - Sends the text to the last person who used @m to send you a message. This only works for monarchs.")]
|
||||
[InlineData("/help pr", "@pr <text> - Sends the text to the last vassal who used @p to send you a message.")]
|
||||
public void HelpVerb_MrPr_ShowsVerbatimRetailText(string input, string expected)
|
||||
{
|
||||
// CH4 REJECT-review SHOULD-FIX 7 (2026-08-09): these were
|
||||
// previously fabricated acdream summaries; now the verbatim retail
|
||||
// strings from data_7daa08/data_7daa80 (the double space before
|
||||
// "you" is confirmed byte-level, not a typo).
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Contains(log.Snapshot(), entry => entry.Text == expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelpVerb_UnknownVerb_ShowsFallbackMessage()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -123,11 +123,23 @@ public sealed class RetailClientCommandCatalogTests
|
|||
[InlineData("/allegiance motd")]
|
||||
[InlineData("/allegiance")]
|
||||
[InlineData("/all officer add 2 Bob")]
|
||||
public void UnsupportedAllegianceSubcommand_FallsThroughToServerPassthrough(string input)
|
||||
public void UnsupportedAllegianceSubcommand_ShowsRetailRefusal_ClientSide(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 _));
|
||||
// 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]
|
||||
|
|
@ -155,6 +167,9 @@ public sealed class RetailClientCommandCatalogTests
|
|||
[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));
|
||||
|
|
@ -201,6 +216,11 @@ public sealed class RetailClientCommandCatalogTests
|
|||
[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)
|
||||
|
|
@ -222,8 +242,16 @@ public sealed class RetailClientCommandCatalogTests
|
|||
[Theory]
|
||||
[InlineData("/clist fellowship", true)]
|
||||
[InlineData("/on admin", true)]
|
||||
[InlineData("/off nonsense", false)]
|
||||
public void ChannelArgumentCommands_ResolveTagsAgainstRetailChannelTagTable(string input, bool expectedValid)
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
|
@ -266,4 +267,34 @@ public sealed class RetailCommandRegistryConformanceTests
|
|||
"test's registry, or it's an invented alias that must be deleted.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── CH4 REJECT-review nit 13 (2026-08-09) ───────────────────────────
|
||||
//
|
||||
// The tests above only prove a verb STRING is recognized somewhere —
|
||||
// not which channel it actually resolves to. A rebind regression (e.g.
|
||||
// "/g" quietly reverting to General, the exact Tier-1 #1 bug this
|
||||
// campaign fixed) would still pass every case above. These two pin the
|
||||
// real dispatch outcome so a rebind regression fails THIS suite, not
|
||||
// just a narrower parser-only test.
|
||||
|
||||
[Fact]
|
||||
public void GVerb_BindsToFellowship_NotGeneral()
|
||||
{
|
||||
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
|
||||
"/g hi gang", ChatChannelKind.Say, lastTellSender: null);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RpVerb_BindsToReply_NotRoleplay()
|
||||
{
|
||||
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
|
||||
"/rp hello back", ChatChannelKind.Say, lastTellSender: "Aunt Agatha");
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
||||
Assert.Equal("Aunt Agatha", parsed!.Value.TargetName);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue