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
210
src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
Normal file
210
src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase I.4: pure-function parse of a chat-input line into the
|
||||||
|
/// <c>(channel, target?, text)</c> triple a <see cref="SendChatCmd"/>
|
||||||
|
/// needs. Ported from holtburger
|
||||||
|
/// <c>references/holtburger/apps/holtburger-cli/src/pages/game/input/commands.rs</c>
|
||||||
|
/// — the alias table around line ~457 and the <c>parse_targeted_chat_command</c>
|
||||||
|
/// / <c>parse_message_only_command</c> helpers near the top of the file.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Edge cases handled (each pinned by a test in
|
||||||
|
/// <c>ChatInputParserTests</c>):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>empty / whitespace-only → <c>null</c></item>
|
||||||
|
/// <item><c>/say</c> with no message → <c>null</c></item>
|
||||||
|
/// <item><c>/t</c> with no target / no message → <c>null</c></item>
|
||||||
|
/// <item><c>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
|
||||||
|
/// <item>unknown <c>/xyz</c> verb → fall through to default channel
|
||||||
|
/// carrying the literal text (matches holtburger
|
||||||
|
/// <c>handle_slash_command</c> default arm at line ~744 — they
|
||||||
|
/// send <c>ClientCommand::Talk(command)</c> with the original
|
||||||
|
/// slash-prefixed string)</item>
|
||||||
|
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
|
||||||
|
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class ChatInputParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of a successful parse. Pass straight into
|
||||||
|
/// <see cref="SendChatCmd"/>'s constructor.
|
||||||
|
/// </summary>
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse <paramref name="raw"/> into a <see cref="ParsedInput"/>
|
||||||
|
/// triple, or <c>null</c> if the input is empty / whitespace /
|
||||||
|
/// missing required arguments (e.g. <c>/t</c> with no target).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="raw">User input. The caller should
|
||||||
|
/// <see cref="string.Trim()"/> before passing.</param>
|
||||||
|
/// <param name="defaultChannel">Channel to use for unprefixed text
|
||||||
|
/// or unrecognised slash commands.</param>
|
||||||
|
/// <param name="lastTellSender">Sender of the most recent incoming
|
||||||
|
/// Tell, used to resolve <c>/r</c> reply target. Null if no Tell
|
||||||
|
/// has arrived this session.</param>
|
||||||
|
public static ParsedInput? Parse(string raw, ChatChannelKind defaultChannel, string? lastTellSender)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
var trimmed = raw.Trim();
|
||||||
|
|
||||||
|
// /say <msg>
|
||||||
|
if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg))
|
||||||
|
return new ParsedInput(ChatChannelKind.Say, null, sayMsg);
|
||||||
|
if (IsBareVerb(trimmed, SayAliases))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// /tell <target> <msg> or /t <target> <msg>
|
||||||
|
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 <msg> / /reply <msg> — 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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match holtburger's <c>parse_targeted_chat_command</c>: 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match holtburger's <c>parse_message_only_command</c>: split on
|
||||||
|
/// first whitespace, alias-match the verb, return the trimmed rest
|
||||||
|
/// as the message. Empty-message → false.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="command"/> is exactly one of the
|
||||||
|
/// alias verbs (no message, no extra whitespace beyond trimming).
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsBareVerb(string command, string[] aliases)
|
||||||
|
{
|
||||||
|
foreach (var alias in aliases)
|
||||||
|
if (command == alias) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="command"/> is "verb arg" with no
|
||||||
|
/// further whitespace — e.g. <c>/t Bestie</c> (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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,37 @@
|
||||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Second real UI panel — shows the tail of the chat log.
|
/// The chat panel. Shows the tail of <see cref="ChatLog"/> + an input
|
||||||
/// Exercises <see cref="IPanelRenderer.Text"/> + <see cref="IPanelRenderer.Separator"/>
|
/// field at the bottom that submits on Enter.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// D.2a scope: show the last <see cref="ChatVM.DefaultDisplayLimit"/>
|
/// Phase I.4 added the input field and slash-command parsing. Supported
|
||||||
/// lines with a separator above the tail and each entry as a single
|
/// prefixes (alias-matched against the verb token, not by string-prefix
|
||||||
/// <c>Text</c> call. No input field (outbound chat already has wire
|
/// — so <c>/general</c> is NOT <c>/g</c>):
|
||||||
/// support via <c>SendChat</c> — a text-input widget on <see cref="IPanelRenderer"/>
|
/// <list type="bullet">
|
||||||
/// lands with the first panel that actually needs one, not here).
|
/// <item><c>/say <msg></c> or no prefix → Say (default)</item>
|
||||||
|
/// <item><c>/t</c> / <c>/tell <name> <msg></c> → whisper</item>
|
||||||
|
/// <item><c>/r</c> / <c>/reply <msg></c> → reply to most recent
|
||||||
|
/// INCOMING Tell (uses <see cref="ChatVM.LastIncomingTellSender"/>;
|
||||||
|
/// drops the message if no Tell has arrived yet)</item>
|
||||||
|
/// <item><c>/g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society,
|
||||||
|
/// /olthoi <msg></c> → corresponding channel</item>
|
||||||
|
/// <item>unknown <c>/xyz hello</c> → Say with the literal text intact
|
||||||
|
/// (matches holtburger fall-through)</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Empty / whitespace-only / target-but-no-message inputs are silently
|
||||||
|
/// dropped — the input field clears and no command goes out.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChatPanel : IPanel
|
public sealed class ChatPanel : IPanel
|
||||||
{
|
{
|
||||||
|
private const int InputBufferMaxLen = 512;
|
||||||
|
|
||||||
private readonly ChatVM _vm;
|
private readonly ChatVM _vm;
|
||||||
|
private string _input = string.Empty;
|
||||||
|
|
||||||
public ChatPanel(ChatVM vm)
|
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();
|
renderer.End();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,19 @@ public sealed class ChatVM
|
||||||
private readonly ChatLog _log;
|
private readonly ChatLog _log;
|
||||||
private readonly int _displayLimit;
|
private readonly int _displayLimit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sender name of the most recent INCOMING Tell. Drives the
|
||||||
|
/// <c>/r</c> reply slash command in <see cref="ChatInputParser"/>.
|
||||||
|
/// Null until the first Tell arrives. Outgoing self-sent Tell
|
||||||
|
/// echoes (which run through <see cref="ChatLog.OnSelfSent"/>) do
|
||||||
|
/// NOT update this — we discriminate by <c>SenderGuid != 0</c>;
|
||||||
|
/// only real inbound tells from <see cref="ChatLog.OnTellReceived"/>
|
||||||
|
/// carry a non-zero guid. Mirrors holtburger
|
||||||
|
/// <c>chat.rs::ChatState::last_incoming_tell_sender</c> (line 74 +
|
||||||
|
/// the assignment at line 152).
|
||||||
|
/// </summary>
|
||||||
|
public string? LastIncomingTellSender { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build a ChatVM bound to a <see cref="ChatLog"/> instance.
|
/// Build a ChatVM bound to a <see cref="ChatLog"/> instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -43,6 +56,20 @@ public sealed class ChatVM
|
||||||
if (displayLimit < 1)
|
if (displayLimit < 1)
|
||||||
throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1");
|
throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1");
|
||||||
_displayLimit = displayLimit;
|
_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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -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