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>
This commit is contained in:
parent
8e6e5a0b61
commit
f14296c75f
6 changed files with 710 additions and 10 deletions
|
|
@ -0,0 +1,212 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
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_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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.4: <see cref="ChatVM.LastIncomingTellSender"/> tracks the
|
||||
/// sender of the most recent INCOMING Tell so the chat panel can route
|
||||
/// <c>/r</c> replies. Outgoing self-sent Tell echoes (which run through
|
||||
/// <see cref="ChatLog.OnSelfSent"/> with <c>SenderGuid = 0</c>) must not
|
||||
/// pollute the field.
|
||||
/// </summary>
|
||||
public sealed class ChatVMLastTellSenderTests
|
||||
{
|
||||
[Fact]
|
||||
public void LastIncomingTellSender_StartsNull()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
Assert.Null(vm.LastIncomingTellSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastIncomingTellSender_PopulatedFromOnTellReceived()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
log.OnTellReceived(sender: "Bestie", text: "ping", senderGuid: 0x5000_00AAu);
|
||||
|
||||
Assert.Equal("Bestie", vm.LastIncomingTellSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastIncomingTellSender_UpdatedToMostRecentIncomingTell()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
log.OnTellReceived("Bestie", "ping", 0x5000_00AAu);
|
||||
log.OnTellReceived("Regal", "yo", 0x5000_00BBu);
|
||||
|
||||
Assert.Equal("Regal", vm.LastIncomingTellSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastIncomingTellSender_IgnoresSelfSentEcho()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
// /r reply echo: SenderGuid = 0 (no real GUID for ourselves).
|
||||
// Must NOT clobber the captured sender.
|
||||
log.OnTellReceived("Bestie", "ping", 0x5000_00AAu);
|
||||
log.OnSelfSent(ChatKind.Tell, "back at you", targetOrChannel: "Bestie");
|
||||
|
||||
Assert.Equal("Bestie", vm.LastIncomingTellSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastIncomingTellSender_IgnoresLocalSpeech()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
log.OnLocalSpeech("Caith", "hello", 0x5000_00CCu, isRanged: false);
|
||||
|
||||
Assert.Null(vm.LastIncomingTellSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastIncomingTellSender_IgnoresChannelBroadcast()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
log.OnChannelBroadcast(channelId: 7, sender: "Caith", text: "raid time");
|
||||
|
||||
Assert.Null(vm.LastIncomingTellSender);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue