From f14296c75f2d515df6dbb59da6fe90819f30a1cf Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 19:44:04 +0200 Subject: [PATCH] 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) --- .../Panels/Chat/ChatInputParser.cs | 210 +++++++++++++++++ .../Panels/Chat/ChatPanel.cs | 50 ++++- .../Panels/Chat/ChatVM.cs | 27 +++ .../Panels/Chat/ChatInputParserTests.cs | 212 ++++++++++++++++++ .../Panels/Chat/ChatPanelInputTests.cs | 139 ++++++++++++ .../Panels/Chat/ChatVMLastTellSenderTests.cs | 82 +++++++ 6 files changed, 710 insertions(+), 10 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMLastTellSenderTests.cs diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs new file mode 100644 index 0000000..1df2bab --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -0,0 +1,210 @@ +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// +/// Phase I.4: pure-function parse of a chat-input line into the +/// (channel, target?, text) triple a +/// needs. Ported from holtburger +/// references/holtburger/apps/holtburger-cli/src/pages/game/input/commands.rs +/// — the alias table around line ~457 and the parse_targeted_chat_command +/// / parse_message_only_command helpers near the top of the file. +/// +/// +/// Edge cases handled (each pinned by a test in +/// ChatInputParserTests): +/// +/// empty / whitespace-only → null +/// /say with no message → null +/// /t with no target / no message → null +/// /r with no lastTellSendernull +/// unknown /xyz verb → fall through to default channel +/// carrying the literal text (matches holtburger +/// handle_slash_command default arm at line ~744 — they +/// send ClientCommand::Talk(command) with the original +/// slash-prefixed string) +/// multi-word /t target: first whitespace token is target, +/// rest is message (matches Rust split_once semantics) +/// +/// +/// +public static class ChatInputParser +{ + /// + /// Outcome of a successful parse. Pass straight into + /// 's constructor. + /// + public readonly record struct ParsedInput( + ChatChannelKind Channel, + string? TargetName, + string Text); + + // Alias tables. Order matters only for error messages — verb + // matching is exact-token, not prefix. + private static readonly string[] SayAliases = { "/say" }; + private static readonly string[] TellAliases = { "/tell", "/t" }; + private static readonly string[] ReplyAliases = { "/reply", "/r" }; + + // Channel aliases. Each maps a single verb token to a channel kind. + // The same list drives both the verb test and the prefix-strip. + private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs = + { + ("/g", ChatChannelKind.General), + ("/f", ChatChannelKind.Fellowship), + ("/a", ChatChannelKind.Allegiance), + ("/m", ChatChannelKind.Monarch), + ("/p", ChatChannelKind.Patron), + ("/v", ChatChannelKind.Vassals), + ("/cv", ChatChannelKind.CoVassals), + ("/lfg", ChatChannelKind.Lfg), + ("/trade", ChatChannelKind.Trade), + ("/role", ChatChannelKind.Roleplay), + ("/society", ChatChannelKind.Society), + ("/olthoi", ChatChannelKind.Olthoi), + }; + + /// + /// Parse into a + /// triple, or null if the input is empty / whitespace / + /// missing required arguments (e.g. /t with no target). + /// + /// User input. The caller should + /// before passing. + /// Channel to use for unprefixed text + /// or unrecognised slash commands. + /// Sender of the most recent incoming + /// Tell, used to resolve /r reply target. Null if no Tell + /// has arrived this session. + public static ParsedInput? Parse(string raw, ChatChannelKind defaultChannel, string? lastTellSender) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var trimmed = raw.Trim(); + + // /say + if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg)) + return new ParsedInput(ChatChannelKind.Say, null, sayMsg); + if (IsBareVerb(trimmed, SayAliases)) + return null; + + // /tell or /t + if (TryParseTargeted(trimmed, TellAliases, out var tellTarget, out var tellMsg)) + return new ParsedInput(ChatChannelKind.Tell, tellTarget, tellMsg); + if (IsBareVerb(trimmed, TellAliases) || IsVerbWithSingleToken(trimmed, TellAliases)) + return null; + + // /r / /reply — needs prior incoming tell. + if (TryParseMessageOnly(trimmed, ReplyAliases, out var replyMsg)) + { + if (string.IsNullOrEmpty(lastTellSender)) return null; + return new ParsedInput(ChatChannelKind.Tell, lastTellSender, replyMsg); + } + if (IsBareVerb(trimmed, ReplyAliases)) + return null; + + // Channel verbs (/g, /f, /a, ...). + foreach (var (verb, channel) in ChannelVerbs) + { + if (TryParseMessageOnly(trimmed, new[] { verb }, out var msg)) + return new ParsedInput(channel, null, msg); + if (IsBareVerb(trimmed, new[] { verb })) + return null; + } + + // Unknown slash command: holtburger falls back to Talk(literal). + // We mirror that — emit on the default channel with the + // slash-prefixed text intact so the server treats it as speech. + // No special-case for known-but-unimplemented verbs; the user's + // text round-trips so their intent isn't silently dropped. + return new ParsedInput(defaultChannel, null, trimmed); + } + + // ── helpers ────────────────────────────────────────────────────── + + /// + /// Match holtburger's parse_targeted_chat_command: split on + /// first whitespace into verb / rest, check verb against aliases, + /// then split rest into target / message. Returns false if either + /// the verb is wrong or target / message is empty. + /// + private static bool TryParseTargeted(string command, string[] aliases, out string target, out string message) + { + target = string.Empty; + message = string.Empty; + + int firstWs = IndexOfWhitespace(command); + if (firstWs < 0) return false; + + var verb = command.Substring(0, firstWs); + if (!ContainsExact(aliases, verb)) return false; + + var rest = command.Substring(firstWs + 1).TrimStart(); + if (rest.Length == 0) return false; + + int targetEnd = IndexOfWhitespace(rest); + if (targetEnd < 0) return false; // target only, no message + + target = rest.Substring(0, targetEnd); + message = rest.Substring(targetEnd + 1).TrimStart(); + if (target.Length == 0 || message.Length == 0) return false; + return true; + } + + /// + /// Match holtburger's parse_message_only_command: split on + /// first whitespace, alias-match the verb, return the trimmed rest + /// as the message. Empty-message → false. + /// + private static bool TryParseMessageOnly(string command, string[] aliases, out string message) + { + message = string.Empty; + int firstWs = IndexOfWhitespace(command); + if (firstWs < 0) return false; + + var verb = command.Substring(0, firstWs); + if (!ContainsExact(aliases, verb)) return false; + + message = command.Substring(firstWs + 1).TrimStart(); + return message.Length > 0; + } + + /// + /// True when is exactly one of the + /// alias verbs (no message, no extra whitespace beyond trimming). + /// + private static bool IsBareVerb(string command, string[] aliases) + { + foreach (var alias in aliases) + if (command == alias) return true; + return false; + } + + /// + /// True when is "verb arg" with no + /// further whitespace — e.g. /t Bestie (target but no + /// message). Used to short-circuit the "fall through to default + /// channel" path so a half-typed tell doesn't get sent as Say. + /// + private static bool IsVerbWithSingleToken(string command, string[] aliases) + { + int firstWs = IndexOfWhitespace(command); + if (firstWs < 0) return false; + var verb = command.Substring(0, firstWs); + if (!ContainsExact(aliases, verb)) return false; + + var rest = command.Substring(firstWs + 1).TrimStart(); + if (rest.Length == 0) return true; + return IndexOfWhitespace(rest) < 0; + } + + private static int IndexOfWhitespace(string s) + { + for (int i = 0; i < s.Length; i++) + if (char.IsWhiteSpace(s[i])) return i; + return -1; + } + + private static bool ContainsExact(string[] aliases, string verb) + { + for (int i = 0; i < aliases.Length; i++) + if (aliases[i] == verb) return true; + return false; + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index 3ca9c6a..4e89c4e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -1,23 +1,37 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// -/// Second real UI panel — shows the tail of the chat log. -/// Exercises + -/// on a non-trivial render pattern (N lines, not a single widget) — -/// proving the D.2a abstraction contract holds for more than the vitals -/// HUD before we grow the panel catalog further. +/// The chat panel. Shows the tail of + an input +/// field at the bottom that submits on Enter. /// /// -/// D.2a scope: show the last -/// lines with a separator above the tail and each entry as a single -/// Text call. No input field (outbound chat already has wire -/// support via SendChat — a text-input widget on -/// lands with the first panel that actually needs one, not here). +/// Phase I.4 added the input field and slash-command parsing. Supported +/// prefixes (alias-matched against the verb token, not by string-prefix +/// — so /general is NOT /g): +/// +/// /say <msg> or no prefix → Say (default) +/// /t / /tell <name> <msg> → whisper +/// /r / /reply <msg> → reply to most recent +/// INCOMING Tell (uses ; +/// drops the message if no Tell has arrived yet) +/// /g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society, +/// /olthoi <msg> → corresponding channel +/// unknown /xyz hello → Say with the literal text intact +/// (matches holtburger fall-through) +/// +/// +/// +/// +/// Empty / whitespace-only / target-but-no-message inputs are silently +/// dropped — the input field clears and no command goes out. /// /// public sealed class ChatPanel : IPanel { + private const int InputBufferMaxLen = 512; + private readonly ChatVM _vm; + private string _input = string.Empty; public ChatPanel(ChatVM vm) { @@ -59,6 +73,22 @@ public sealed class ChatPanel : IPanel } } + // Phase I.4: input field. Backend implementation clears _input + // on submit per the IPanelRenderer contract. + renderer.Separator(); + if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) + && submitted is not null) + { + var parsed = ChatInputParser.Parse(submitted.Trim(), ChatChannelKind.Say, _vm.LastIncomingTellSender); + if (parsed is { } p) + { + ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + } + // Defensive: if the backend ever forgot to clear on submit, + // do it here. Cheap; no harm if already empty. + _input = string.Empty; + } + renderer.End(); } } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 636d2a6..6c506fa 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -28,6 +28,19 @@ public sealed class ChatVM private readonly ChatLog _log; private readonly int _displayLimit; + /// + /// Sender name of the most recent INCOMING Tell. Drives the + /// /r reply slash command in . + /// Null until the first Tell arrives. Outgoing self-sent Tell + /// echoes (which run through ) do + /// NOT update this — we discriminate by SenderGuid != 0; + /// only real inbound tells from + /// carry a non-zero guid. Mirrors holtburger + /// chat.rs::ChatState::last_incoming_tell_sender (line 74 + + /// the assignment at line 152). + /// + public string? LastIncomingTellSender { get; private set; } + /// /// Build a ChatVM bound to a instance. /// @@ -43,6 +56,20 @@ public sealed class ChatVM if (displayLimit < 1) throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1"); _displayLimit = displayLimit; + _log.EntryAppended += OnEntryAppended; + } + + private void OnEntryAppended(ChatEntry entry) + { + // Only INCOMING tells update the reply target. Self-sent + // echoes from OnSelfSent always carry SenderGuid == 0 (we + // never know our own guid here), so guid == 0 is a safe + // discriminator for "skip this one". An incoming tell from + // a player always carries the sender's real guid. + if (entry.Kind != ChatKind.Tell) return; + if (entry.SenderGuid == 0) return; + if (string.IsNullOrEmpty(entry.Sender)) return; + LastIncomingTellSender = entry.Sender; } /// diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs new file mode 100644 index 0000000..0ca465e --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs @@ -0,0 +1,212 @@ +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// TDD coverage for . Pure function: +/// no fakes, no setup. Each prefix in the holtburger +/// chat.rs alias table gets at least one round-trip test plus +/// every documented edge case. +/// +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); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs new file mode 100644 index 0000000..8e32f71 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -0,0 +1,139 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Phase I.4: when the user submits text via the chat input field, the +/// panel must publish a to the command bus. +/// We exercise the full Render path with the +/// pre-loading a "submitted" string and a recording bus capturing the +/// resulting command. +/// +public sealed class ChatPanelInputTests +{ + private sealed class RecordingBus : ICommandBus + { + public List Published { get; } = new(); + public void Publish(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(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(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(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"); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMLastTellSenderTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMLastTellSenderTests.cs new file mode 100644 index 0000000..f2a8134 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMLastTellSenderTests.cs @@ -0,0 +1,82 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Phase I.4: tracks the +/// sender of the most recent INCOMING Tell so the chat panel can route +/// /r replies. Outgoing self-sent Tell echoes (which run through +/// with SenderGuid = 0) must not +/// pollute the field. +/// +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); + } +}