SHOULD-FIX 1: RetailClientCommandCatalog's ~45 catalog leaf verbs were showing acdream-authored Summary text for /help <verb> instead of retail's own Detail_HelpType(2) text. Byte-swept every Help* handler against the PDB-paired acclient.exe (verified MATCH), confirmed each Detail/Summary branch by reading the actual decompiled if/else shape (address order and string length both proved unreliable alone), and fixed a sweep_weenie_strings.py 800-char truncation bug that silently dropped several longer Detail branches. Resolved every ambiguous CmdHashData-registered verb (hor/hr/hom/hoa/alh/ah/friends_add/ friends_remove/squelch/unsquelch) by reading for Binary Ninja's nullptr-4th-arg decompiler artifact instead of trusting it. Coverage: 42 of 47 distinct catalog Definitions verbatim-extracted, 4 confirmed-null (index/clist/on/off register with a genuinely null help pointer — DoHelp falls to UnknownCommand for these, now reproduced), 1 honest UNVERIFIED (messagetypes builds its text from a runtime enum table, not a static string). ChatCommandRouter now prefers retail Detail text over the catalog summary; RetailCommandHelpTable's class doc no longer overclaims its own scope. SHOULD-FIX 2: extracted the a5a7eb4f-class OnInterfaceText wiring into a testable CreateChatViewModel method and added ComposedChatViewModelWiresOnInterfaceTextToSpewBox, which the prior FakeFactory-based test suite could never exercise. SHOULD-FIX 3: retires register row AP-113. DoLifestone/DoMarketplace print their own 0x1A refusal text (byte-recovered, UTF-16LE) instead of falling through to the generic 0x26 fallback; ChatCommandRouter's comment corrected to state the fallback's real scope. SHOULD-FIX 4: corrected the divergence register's stale AP section header sentence about AP-190's opacity default (refuted bycc582899). NITs: (a) HeadlessStaticStateAudit routes through the injected HeadlessDiagnosticWriter instead of Console.WriteLine; (b) a bounded 300-pump liveness diagnostic on the IsQuiescent conductor gate (no retry, no behavior change); (c) fixed the #365 hydration test's doc comment contradiction against diagnosis §8; (d) the 0x26 fallback dispatches on WeenieErrorMessages' own Type instead of hardcoding ClientLocal. Full Release suite: 12,553 passed / 4 skipped / 0 failed (baseline03404b71: 12,542/4/0; net +11 tests, zero regressions). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
568 lines
23 KiB
C#
568 lines
23 KiB
C#
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
using Xunit;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|
|
|
public class ChatCommandRouterTests
|
|
{
|
|
private sealed class CaptureBus : ICommandBus
|
|
{
|
|
public List<object> Published { get; } = new();
|
|
|
|
public void Publish<T>(T command) where T : notnull
|
|
=> Published.Add(command);
|
|
}
|
|
|
|
private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log, displayLimit: 50);
|
|
return (vm, log, new CaptureBus());
|
|
}
|
|
|
|
/// <summary>
|
|
/// #363: a fixture with <see cref="ChatVM.OnInterfaceText"/> wired to a
|
|
/// capturing list, so tests can assert a 0x1A refusal reached the
|
|
/// SpewBox seam directly instead of only observing the chat-log
|
|
/// null-fallback.
|
|
/// </summary>
|
|
private static (ChatVM vm, ChatLog log, CaptureBus bus, List<string> interfaceTexts) FixtureWithInterfaceSink()
|
|
{
|
|
var log = new ChatLog();
|
|
var interfaceTexts = new List<string>();
|
|
var vm = new ChatVM(log, displayLimit: 50)
|
|
{
|
|
OnInterfaceText = interfaceTexts.Add,
|
|
};
|
|
return (vm, log, new CaptureBus(), interfaceTexts);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlainText_PublishesOnDefaultChannel()
|
|
{
|
|
var (vm, _, bus) = Fixture();
|
|
var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
|
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ChatChannelKind.Say, command.Channel);
|
|
Assert.Equal("hello there", command.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultChannel_IsHonored()
|
|
{
|
|
var (vm, _, bus) = Fixture();
|
|
ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship);
|
|
|
|
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ChatChannelKind.Fellowship, command.Channel);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClearCommand_PublishesTypedRetailCommand()
|
|
{
|
|
var (vm, log, bus) = Fixture();
|
|
log.OnSystemMessage("x", chatType: 0);
|
|
|
|
var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.ClearChat, command.Command);
|
|
Assert.Equal("", command.Arguments);
|
|
Assert.Single(log.Snapshot());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/lifestone")]
|
|
[InlineData("/lif")]
|
|
[InlineData("/ls")]
|
|
[InlineData("@LS")]
|
|
public void LifestoneAliases_PublishTypedClientCommand(string input)
|
|
{
|
|
var (vm, _, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Fellowship);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
|
|
}
|
|
|
|
[Fact]
|
|
public void LifestoneWithArguments_ShowsRetailsBespokeRefusal_ViaInterfaceTextSeam()
|
|
{
|
|
// Consolidated-review round (2026-08-10), SHOULD-FIX 3: retires
|
|
// register row AP-113. DoLifestone prints ITS OWN 0x1A string for
|
|
// bad args and returns 1 -- retail never reaches the generic
|
|
// HandleFailureEvent(0x26) fallback for it. Byte-recovered from the
|
|
// PDB-paired acclient.exe (Binary Ninja mis-attributes the literal
|
|
// to an unrelated vtable-slot symbol).
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/ls now", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Equal(
|
|
"Please see @help lifestone for more information on how to use this command.",
|
|
Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void LifestoneWithArguments_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal()
|
|
{
|
|
// Headless / no-window null-fallback safety: when the App-layer
|
|
// host hasn't wired ChatVM.OnInterfaceText, the refusal must still
|
|
// reach the player instead of being silently dropped.
|
|
var (vm, log, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/ls now", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var entry = Assert.Single(log.Snapshot());
|
|
Assert.Equal(
|
|
"Please see @help lifestone for more information on how to use this command.",
|
|
entry.Text);
|
|
Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void PkArenaWithArguments_ShowsRetailsGenericBadArgsFallback_ViaInterfaceTextSeam()
|
|
{
|
|
// #363 / register row AP-183: a Definition with no bespoke
|
|
// InvalidArgumentsText (PkArena never had one, unlike Lifestone/
|
|
// Marketplace above) falls to retail's own generic bad-args
|
|
// fallback (HandleFailureEvent(0x26), "That is not a valid
|
|
// command.") at 0x1A ClientLocal -- never the acdream-invented
|
|
// "Usage: /pkarena" line this used to synthesize. Carries the
|
|
// generic-fallback coverage the two Lifestone tests above used to
|
|
// own before Lifestone got its own bespoke text.
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/pka now", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Equal("That is not a valid command.", Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void MarketplaceWithArguments_ShowsRetailsBespokeRefusal_ViaInterfaceTextSeam()
|
|
{
|
|
// Same methodology and same vtable-mislabeling artifact as
|
|
// Lifestone above -- see RetailClientCommandCatalog.Marketplace's
|
|
// own citation.
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/mar now", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Equal(
|
|
"Please see @help marketplace for more information on how to use this command.",
|
|
Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void PkLiteAlias_ResolvesAsClientHandled_NotTheServerTextPath()
|
|
{
|
|
var (vm, _, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit("@pklite", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.EnterPkLite, command.Command);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownSlashVerb_RoutesThroughExplicitServerCommand()
|
|
{
|
|
var (vm, log, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit(
|
|
"/notacommand", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
|
var command = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal("@notacommand", command.Text);
|
|
Assert.DoesNotContain(log.Snapshot(), entry => entry.Text.Contains("Unknown command"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/ci 629 5")]
|
|
[InlineData("@ci 629 5")]
|
|
public void ServerCommandWithArgs_PublishesCanonicalAtForm_EvenOnChannelDefault(
|
|
string input)
|
|
{
|
|
var (vm, _, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit(
|
|
input, vm, bus, ChatChannelKind.Fellowship);
|
|
|
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
|
var command = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal("@ci 629 5", command.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyInput_DoesNothing()
|
|
{
|
|
var (vm, _, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.Empty, outcome);
|
|
Assert.Empty(bus.Published);
|
|
}
|
|
|
|
// ── Campaign CH slice CH4 (2026-08-09) ──────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(":waves")]
|
|
[InlineData(";waves")]
|
|
public void EmotePrefix_RewritesToAtEmote(string raw)
|
|
{
|
|
var (vm, _, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit(raw, vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.Emote, command.Command);
|
|
Assert.Equal("waves", command.Arguments);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnregisteredChannelTag_PublishesRawChannelBroadcast()
|
|
{
|
|
var (vm, _, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/admin server is misbehaving", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
|
var command = Assert.IsType<SendRawChannelCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(0x00000002u, command.ChannelId);
|
|
Assert.Equal("server is misbehaving", command.Text);
|
|
}
|
|
|
|
[Fact]
|
|
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)
|
|
{
|
|
// #363 / register row AP-183: retail types this refusal 0x1A
|
|
// (ClientLocal / SpewBox-only) — routed through the interface-text
|
|
// seam, not the chat log.
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published); // no SendRawChannelCmd, no SendServerCommandCmd
|
|
Assert.Equal(
|
|
"Please see @help Allegiance for more information on how to use this command.",
|
|
Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisteredChannelVerb_NeverReachesTheRawFallback()
|
|
{
|
|
// "/f" is a KNOWN ChatInputParser verb (Fellowship) — it must ride
|
|
// the normal SendChatCmd/self-echo path, not the raw fallback.
|
|
var (vm, _, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/f hi gang", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
|
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ChatChannelKind.Fellowship, command.Channel);
|
|
}
|
|
|
|
[Fact]
|
|
public void HelpVerb_ShowsCatalogHelpText()
|
|
{
|
|
var (vm, log, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/help lifestone", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("Returns you to the last lifestone"));
|
|
}
|
|
|
|
// Campaign CH user-gate round 3 (2026-08-10), finding (c): a resolved
|
|
// verb prints retail's DoHelp SHAPE, not just its content — the SAME
|
|
// HelpPrefixNote entry every /help path prints, then a SECOND entry
|
|
// that is ForMoreInformationPrefix concatenated directly onto the
|
|
// verb's own detail text (no blank line, no separate third entry).
|
|
[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);
|
|
var entries = log.Snapshot();
|
|
Assert.Equal(2, entries.Length);
|
|
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
|
|
Assert.Equal(RetailCommandHelpTable.ForMoreInformationPrefix + expected, entries[1].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void HelpVerb_UnknownVerb_ShowsRetailUnknownCommandText_ViaInterfaceTextSeam()
|
|
{
|
|
// Campaign CH user-gate round 3 (2026-08-10): retail's own DoHelp
|
|
// fallback text is "Unknown command" (swept verbatim), not an
|
|
// acdream-invented "No help available" message. Retail types this
|
|
// 0x1A (ClientLocal / SpewBox-only). Issue #363/#367: now routed
|
|
// through the interface-text seam as ONE entry (no HelpPrefixNote
|
|
// wrapper — DoHelp's fallback bypasses the two-entry shape
|
|
// entirely), not the chat scroll.
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Equal(RetailCommandHelpTable.UnknownCommand, Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void HelpVerb_UnknownVerb_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal()
|
|
{
|
|
var (vm, log, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var entry = Assert.Single(log.Snapshot());
|
|
Assert.Equal(RetailCommandHelpTable.UnknownCommand, entry.Text);
|
|
Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void HelpBare_ShowsRetailTwoEntryShape()
|
|
{
|
|
// Campaign CH user-gate round 3 (2026-08-10), finding (b): bare
|
|
// /help must emit retail's real two-entry sequence (Note, then the
|
|
// 13-item "Available help:" listing) — not the previous
|
|
// acdream-invented single-blob cheat sheet.
|
|
var (vm, log, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/help", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var entries = log.Snapshot();
|
|
Assert.Equal(2, entries.Length);
|
|
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
|
|
Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text);
|
|
}
|
|
|
|
// ── Issue #363 / register row AP-183 (2026-08-10) ───────────────────
|
|
// Retail's 0x1A (ClientLocal / SpewBox-only) command-refusal call
|
|
// sites now route through ChatVM.OnInterfaceText instead of the chat
|
|
// scroll. Each site below is pinned two ways: with the seam wired
|
|
// (reaches the SpewBox sink, log stays empty) and with it unwired
|
|
// (safe null-fallback into chat, tagged ClientLocal).
|
|
|
|
[Theory]
|
|
[InlineData("/clist")]
|
|
[InlineData("/clist a b")]
|
|
[InlineData("/on")]
|
|
[InlineData("/off nonsense extra")]
|
|
public void ChannelListOnOff_BadArgumentShape_ShowsRetailRefusal_ViaInterfaceTextSeam(string input)
|
|
{
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Equal("Please specify the channel name.", Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void HouseAvailableList_BadHouseType_ShowsRetailRefusal_ViaInterfaceTextSeam()
|
|
{
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/hslist nonsense", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Equal(
|
|
"Please see @help hslist for more information on how to use this command",
|
|
Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/g")]
|
|
[InlineData("/f")]
|
|
[InlineData("/fellowship")]
|
|
[InlineData("/a")]
|
|
[InlineData("/ab")]
|
|
[InlineData("/m")]
|
|
[InlineData("/p")]
|
|
[InlineData("/v")]
|
|
[InlineData("/c")]
|
|
public void BareRegisteredChannelVerb_ShowsDoStupidChannelHackRefusal_ViaInterfaceTextSeam(string input)
|
|
{
|
|
// Retail ClientCommunicationSystem::DoStupidChannelHack
|
|
// @0x0057B144 — a registered legacy-channel verb with no message
|
|
// refuses locally instead of silently dropping the line.
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Equal("You must specify the text you wish to say!", Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/lfg")]
|
|
[InlineData("/trade")]
|
|
[InlineData("/general")]
|
|
[InlineData("/roleplay")]
|
|
[InlineData("/society")]
|
|
[InlineData("/olthoi")]
|
|
public void BareTurbineOnlyChannelVerb_NeverShowsDoStupidChannelHackRefusal(string input)
|
|
{
|
|
// The seven Turbine-only channels are never DoStupidChannelHack's
|
|
// clients (command-registry doc §2.3) — a bare verb here is
|
|
// silently dropped, matching the pre-#363 behavior for these
|
|
// specific channels (unchanged by this fix).
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.Dropped, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Empty(interfaceTexts);
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Reply_WithMessage_NoLastTeller_ShowsDoReplyRefusal_ViaInterfaceTextSeam()
|
|
{
|
|
// Retail ClientCommunicationSystem::DoReply @0x00577910 —
|
|
// gmCCommunicationSystem::GetLastTeller() == 0 branch.
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/r hi there", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Equal("Someone must @tell you first!", Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Reply_WithMessage_NoLastTeller_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal()
|
|
{
|
|
var (vm, log, bus) = Fixture();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/reply hi there", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var entry = Assert.Single(log.Snapshot());
|
|
Assert.Equal("Someone must @tell you first!", entry.Text);
|
|
Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reply_WithMessage_WithLastTeller_StillSendsNormally()
|
|
{
|
|
// Sanity: the new missing-last-teller predicate must not shadow
|
|
// the ordinary reply path once a Tell has arrived.
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042, logTextType: 0x03u);
|
|
|
|
var outcome = ChatCommandRouter.Submit("/r hi there", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
|
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal("Bestie", command.TargetName);
|
|
Assert.Empty(interfaceTexts);
|
|
}
|
|
|
|
[Fact]
|
|
public void DegeneratePrefix_UnknownCommand_ShowsRefusal_ViaInterfaceTextSeam()
|
|
{
|
|
// "/" alone (no letter verb) — the pre-existing "Unknown command:
|
|
// {verb}." refusal, now also routed through the interface-text
|
|
// seam (issue #367).
|
|
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
|
|
Assert.Empty(bus.Published);
|
|
Assert.Contains("Unknown command:", Assert.Single(interfaceTexts));
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void EnduranceCommand_ValidArguments_NeverTouchesTheInterfaceTextSeam()
|
|
{
|
|
// AP-183 sanity check: DoEndurance/DoSpeaker/DoTitle are ALREADY
|
|
// correct at 0x00 (informational) — their actual output text is
|
|
// produced by ClientCommandController (App layer), not
|
|
// ChatCommandRouter, so this pins that a valid catalog dispatch
|
|
// never emits through the new 0x1A seam at all.
|
|
var (vm, _, bus, interfaceTexts) = FixtureWithInterfaceSink();
|
|
|
|
var outcome = ChatCommandRouter.Submit("/endurance", vm, bus, ChatChannelKind.Say);
|
|
|
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.Endurance, command.Command);
|
|
Assert.Empty(interfaceTexts);
|
|
}
|
|
}
|