acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
Erik f14296c75f feat(ui): #17 ChatPanel input field + slash commands + reply-to-last-tell
ChatPanel gains an Enter-to-submit input field via the I.1
InputTextSubmit widget. Submitted text routes through ChatInputParser
to a SendChatCmd published on ctx.Commands; LiveCommandBus (I.3)
handles the wire send + ChatLog echo.

Recognised prefixes (ported from holtburger commands.rs):

  /say msg or no prefix  -> Say
  /t Name msg or /tell   -> Tell  (first whitespace token = target)
  /r msg                 -> Tell  (target = LastIncomingTellSender)
  /g msg                 -> General
  /f msg                 -> Fellowship
  /a msg                 -> Allegiance
  /m msg                 -> Monarch
  /p msg                 -> Patron
  /v msg                 -> Vassals
  /cv msg                -> CoVassals
  /lfg msg               -> Lfg
  /trade msg             -> Trade
  /role msg              -> Roleplay
  /society msg           -> Society
  /olthoi msg            -> Olthoi

Edge cases: empty / whitespace / cmd-without-message / /r without
prior tell -> null (no-op). Unknown /xyz prefix -> Say with literal
text (matches holtburger's Talk(command) default arm).

ChatVM.LastIncomingTellSender populated only on incoming Tell entries;
discriminated by SenderGuid != 0 (OnSelfSent echoes always carry 0).

32 new tests:
- ChatInputParserTests: 22 covering every prefix + edge case
- ChatVMLastTellSenderTests: 6 covering capture + skip rules
- ChatPanelInputTests: 6 using FakePanelRenderer + recording
  ICommandBus to assert publish behaviour

UI.Abstractions.Tests: 60 -> 92. Solution total: 934 green.

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

212 lines
8.3 KiB
C#

using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// TDD coverage for <see cref="ChatInputParser.Parse"/>. Pure function:
/// no fakes, no setup. Each prefix in the holtburger
/// <c>chat.rs</c> alias table gets at least one round-trip test plus
/// every documented edge case.
/// </summary>
public sealed class ChatInputParserTests
{
// -- Default channel (no prefix / explicit /say) --------------------
[Fact]
public void NoPrefix_UsesDefaultChannelLiteral()
{
var parsed = ChatInputParser.Parse("hello world", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("hello world", parsed.Value.Text);
}
[Fact]
public void SayPrefix_StripsTheSlashSay()
{
var parsed = ChatInputParser.Parse("/say hi there", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("hi there", parsed.Value.Text);
}
// -- Tell aliases ----------------------------------------------------
[Fact]
public void TellPrefix_FullForm_ParsesTargetAndMessage()
{
var parsed = ChatInputParser.Parse("/tell Bestie hi there", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("hi there", parsed.Value.Text);
}
[Fact]
public void TellPrefix_ShortForm_ParsesTargetAndMessage()
{
var parsed = ChatInputParser.Parse("/t Bestie hi", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("hi", parsed.Value.Text);
}
[Fact]
public void TellPrefix_MultiWordTarget_ChompsFirstTokenAsTarget()
{
// Holtburger split_once on whitespace: first token is target.
// "/t Sir Lancelot hello" -> target="Sir", text="Lancelot hello".
var parsed = ChatInputParser.Parse("/t Sir Lancelot hello", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Sir", parsed.Value.TargetName);
Assert.Equal("Lancelot hello", parsed.Value.Text);
}
// -- Reply aliases ---------------------------------------------------
[Fact]
public void ReplyPrefix_UsesLastIncomingTellSenderAsTarget()
{
var parsed = ChatInputParser.Parse("/r back at you", ChatChannelKind.Say, lastTellSender: "Bestie");
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("back at you", parsed.Value.Text);
}
[Fact]
public void ReplyPrefix_NoLastSender_ReturnsNull()
{
var parsed = ChatInputParser.Parse("/r hi", ChatChannelKind.Say, lastTellSender: null);
Assert.Null(parsed);
}
// -- Channel aliases (single-message) -------------------------------
[Theory]
[InlineData("/g raid time", ChatChannelKind.General, "raid time")]
[InlineData("/f buff up", ChatChannelKind.Fellowship, "buff up")]
[InlineData("/a swearing in", ChatChannelKind.Allegiance, "swearing in")]
[InlineData("/m monarch broadcast", ChatChannelKind.Monarch, "monarch broadcast")]
[InlineData("/p patron only", ChatChannelKind.Patron, "patron only")]
[InlineData("/v vassals only", ChatChannelKind.Vassals, "vassals only")]
[InlineData("/cv covassals only", ChatChannelKind.CoVassals, "covassals only")]
[InlineData("/lfg need 3 more", ChatChannelKind.Lfg, "need 3 more")]
[InlineData("/trade wts gem", ChatChannelKind.Trade, "wts gem")]
[InlineData("/role *waves*", ChatChannelKind.Roleplay, "*waves*")]
[InlineData("/society olthoi raid", ChatChannelKind.Society, "olthoi raid")]
[InlineData("/olthoi for the queen", ChatChannelKind.Olthoi, "for the queen")]
public void ChannelPrefixes_RouteToTheirChannel(string raw, ChatChannelKind expectedChannel, string expectedText)
{
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(expectedChannel, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal(expectedText, parsed.Value.Text);
}
// -- Edge cases: empty / whitespace / no-message --------------------
[Fact]
public void Empty_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void WhitespaceOnly_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse(" \t ", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void SayWithNoMessage_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("/say", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/say ", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void TellWithNoTargetNoMessage_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("/t", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/tell", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void TellWithTargetButNoMessage_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("/t Bestie", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/tell Bestie ", ChatChannelKind.Say, lastTellSender: null));
}
[Fact]
public void ReplyWithNoMessage_ReturnsNull_EvenWithLastSender()
{
Assert.Null(ChatInputParser.Parse("/r", ChatChannelKind.Say, lastTellSender: "Bestie"));
Assert.Null(ChatInputParser.Parse("/r ", ChatChannelKind.Say, lastTellSender: "Bestie"));
}
[Fact]
public void ChannelPrefixWithNoMessage_ReturnsNull()
{
Assert.Null(ChatInputParser.Parse("/g", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/f ", ChatChannelKind.Say, lastTellSender: null));
Assert.Null(ChatInputParser.Parse("/a", ChatChannelKind.Say, lastTellSender: null));
}
// -- Unknown slash command: holtburger fall-through to Talk(literal)
// ("/wave hello" → Talk("/wave hello") + treat-as-Say). See
// chat.rs::handle_slash_command default arm at line ~744.
[Fact]
public void UnknownSlashCommand_FallsBackToDefaultChannelWithLiteralText()
{
var parsed = ChatInputParser.Parse("/xyz hello", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("/xyz hello", parsed.Value.Text);
}
// -- Default-channel parameter is honoured when no prefix is given --
[Fact]
public void NoPrefix_HonoursAlternateDefaultChannel()
{
// If the panel ever defaults to e.g. Fellowship channel input,
// the parser must use that for unprefixed text.
var parsed = ChatInputParser.Parse("hi gang", ChatChannelKind.Fellowship, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("hi gang", parsed.Value.Text);
}
// -- The verb itself must be exact: prefix-of-other-word doesn't count --
[Fact]
public void PrefixSubstring_IsNotAVerbMatch()
{
// "/general" (no leading "/g " token) is NOT /g; it's just text.
// Must not be misclassified as /g + "eneral".
var parsed = ChatInputParser.Parse("/general public", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Equal("/general public", parsed.Value.Text);
}
}