acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
Erik 579cbfb48b fix(chat): block / unknown commands from broadcasting as speech
User reported typing /ls (a command-style request, not chat) gets
echoed by the server as "You say, \"/ls\"". Slash-prefix is a
COMMAND surface, never a chat surface. Filed after the same flow
that produced @help and the welcome-message work.

Behavior change at the ChatPanel submit layer:
- Any /-prefixed input whose verb isn't in our alias tables now
  renders a local "[System] Unknown command: /foo. Type /help for
  the list." line and is NEVER published to the bus. No SendChatCmd,
  no Talk packet. The server never sees /foo.
- Known /-verbs (/say /tell /reply /retell /general /allegiance
  /patron /vassals /monarch /covassals /fellowship /lookingforgroup
  /trade /roleplay /society /olthoi /help /clear /framerate /loc
  and friends) still flow through ChatInputParser.Parse → SendChatCmd
  exactly as before.
- @-prefix unchanged: ACE's CommandManager handles unknown @ verbs
  server-side and replies via SystemChat ("Unknown command: foo")
  per ACE GameActionTalk.cs:21. Our @ -> / normalization for known
  verbs (Phase J Tier 1) and the @-passthrough fallthrough for
  unknown verbs both still apply.

ChatInputParser now exposes:
- IsKnownVerb(string verb): query against the union of every alias
  table. Used by ChatPanel to discriminate "unknown verb" from
  "known verb with bad args".
- GetVerbToken(string command): public alias of the existing
  ExtractVerb so callers can pull the first whitespace token without
  reproducing the helper.

Parse itself is unchanged — its existing fall-through (Say with
literal text) still applies for unknown /-verbs called directly via
the parser, but ChatPanel intercepts before reaching that path so
the fall-through never fires through the live submit pipeline. Tests
that directly call Parse continue to pass; the new ChatPanel-level
tests pin the unknown-command rejection.

19 new tests:
- ChatInputParserTests: 10 IsKnownVerb Theory cases + 4 GetVerbToken
  Theory cases.
- ChatPanelInputTests: 5 Theory cases for Submit_UnknownSlashCommand
  covering /foo, /ls, /mp <path>, /genio, and bare /.

Solution total: 1086 green (243 Core.Net + 183 UI + 660 Core),
0 warnings.

Acceptance: type /ls, /mp /path, /anything-not-known — see local
"[System] Unknown command: /xxx. Type /help for the list of
supported commands." Nothing reaches the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:49:56 +02:00

309 lines
10 KiB
C#

using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Phase I.4: when the user submits text via the chat input field, the
/// panel must publish a <see cref="SendChatCmd"/> to the command bus.
/// We exercise the full Render path with the <see cref="FakePanelRenderer"/>
/// pre-loading a "submitted" string and a recording bus capturing the
/// resulting command.
/// </summary>
public sealed class ChatPanelInputTests
{
private sealed class RecordingBus : ICommandBus
{
public List<object> Published { get; } = new();
public void Publish<T>(T command) where T : notnull => Published.Add(command);
}
[Fact]
public void Submit_HelpCommand_RendersLocalHelpAndDoesNotPublish()
{
// Phase J follow-up: client-side commands (/help, /?, /h) are
// intercepted before the parser. They render a local cheat-sheet
// via ChatLog.OnSystemMessage and do NOT round-trip the server
// — that's what prevented the "Unknown command: help" duplicate
// ACE was firing back.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/help",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entry = Assert.Single(log.Snapshot());
Assert.Equal(ChatKind.System, entry.Kind);
// Help text mentions / and @ equivalence and points at @acehelp
// for the server's full command list.
Assert.Contains("/tell", entry.Text);
Assert.Contains("@acehelp", entry.Text);
}
[Theory]
[InlineData("/?")]
[InlineData("/h")]
[InlineData("/HELP")]
public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw)
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = raw,
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
Assert.Single(log.Snapshot());
}
[Fact]
public void Submit_FramerateCommand_PrintsFpsAndDoesNotPublish()
{
var log = new ChatLog();
var vm = new ChatVM(log) { FpsProvider = () => 60f };
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/framerate",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entry = Assert.Single(log.Snapshot());
Assert.Contains("60.0 FPS", entry.Text);
}
[Fact]
public void Submit_LocCommand_PrintsPositionAndDoesNotPublish()
{
var log = new ChatLog();
var vm = new ChatVM(log)
{
PositionProvider = () => new System.Numerics.Vector3(10f, 20f, 30f),
};
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "@loc",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entry = Assert.Single(log.Snapshot());
Assert.Contains("(10.0, 20.0, 30.0)", entry.Text);
}
[Theory]
[InlineData("/foo")]
[InlineData("/ls")]
[InlineData("/mp /tools/script.py")]
[InlineData("/genio public")]
[InlineData("/")]
public void Submit_UnknownSlashCommand_ShowsUnknownAndDoesNotPublish(string raw)
{
// Phase J Tier 4: /-prefixed text is NEVER broadcast as plain
// speech. Filed after a 2026-04-25 trace where typing /ls (a
// command-style request the user wanted) was getting echoed by
// the server as "You say, \"/ls\"". Now we intercept and show
// a local "Unknown command" line; nothing goes on the wire.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = raw,
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entry = Assert.Single(log.Snapshot());
Assert.Equal(ChatKind.System, entry.Kind);
Assert.Contains("Unknown command", entry.Text);
Assert.Contains("/help", entry.Text);
}
[Fact]
public void Submit_AtAcehelp_PassesThroughToSayWithAtIntact()
{
// Unknown @-verb falls through to the default channel with the
// literal "@acehelp" text intact so ACE's CommandManager
// intercepts it server-side. We DO publish a SendChatCmd here —
// the publish is what carries the message to the server.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "@acehelp",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
Assert.Equal("@acehelp", sendCmd.Text);
}
[Fact]
public void Submit_ClearCommand_DrainsLog_AndDoesNotPublish()
{
var log = new ChatLog();
log.OnSystemMessage("seed line", chatType: 0);
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/clear",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
Assert.Empty(log.Snapshot());
}
[Fact]
public void Submit_PlainText_PublishesSayCommand()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "hello world",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var cmd = Assert.Single(bus.Published);
var sendCmd = Assert.IsType<SendChatCmd>(cmd);
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
Assert.Null(sendCmd.TargetName);
Assert.Equal("hello world", sendCmd.Text);
}
[Fact]
public void Submit_TellSlashCommand_PublishesTellCommand()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/t Bestie ping",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel);
Assert.Equal("Bestie", sendCmd.TargetName);
Assert.Equal("ping", sendCmd.Text);
}
[Fact]
public void Submit_ReplySlashCommand_UsesLastIncomingTellSender()
{
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnTellReceived("Bestie", "ping", senderGuid: 0x5000_00AAu);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = "/r back at you",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel);
Assert.Equal("Bestie", sendCmd.TargetName);
Assert.Equal("back at you", sendCmd.Text);
}
[Fact]
public void Submit_EmptyOrWhitespace_PublishesNothing()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = " ",
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
}
[Fact]
public void NoSubmit_PublishesNothing()
{
// Most frames: user is typing or idle; submitted == null.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = null,
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
}
[Fact]
public void Render_AlwaysCallsInputTextSubmit_ToShowTheField()
{
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = null,
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Contains(renderer.Calls, c => c.Method == "InputTextSubmit");
}
}