Three post-launch fixes from the 2026-04-25 live verify session.
1. WeenieError display bug. Many ACE WeenieError / WeenieErrorWithString
codes are *informational*, not error-level — the user saw cryptic
"WeenieError 0x051B: General" / "WeenieError 0x051D" at login, but
those decode as "You have entered the General channel." and
"Turbine Chat is enabled." per ACE WeenieError(WithString).cs
templates. New static helper Core/Chat/WeenieErrorMessages.cs maps
~30 high-frequency codes to retail-faithful templates with `_`
placeholder substitution. ChatLog.OnWeenieError now routes through
Format(); unknown codes still fall back to "WeenieError 0xNNNN[: param]"
so nothing is silently lost. New codes can be added in 30 seconds
when the user reports one.
2. Tell target eats trailing punctuation. Retail muscle memory is
"/t Name, message" — comma is the separator. Our split-on-whitespace
pulled "Name," (with comma) as the target, server returned 0x052B
"That person is not available now." because no such character.
ChatInputParser.TryParseTargeted now strips a trailing ,;:.!? from
the target token so "/t Caith, hi" and "/t Caith hi" both work.
Added 7 Theory cases covering each separator + the long-form alias.
3. TurbineChat routing diagnostics. The user's ACE login showed the
"TurbineChatIsEnabled" + "YouHaveEnteredThe_Channel" notifications
for General/Trade/LFG, confirming TurbineChat IS active server-side.
But outbound /g /trade /lfg might still fall back to legacy
ChatChannel (which the server then rejects). Added diagnostic
Console.WriteLines so the next launch shows:
- "chat: SetTurbineChatChannels parsed enabled=true general=0x... ..."
(when ACE sends the 0x0295 channel-id table)
- "chat: outbound TurbineChat General room=0x... cookie=0x... len=N"
(when SendChatCmd routes a Turbine kind through 0xF7DE)
- "chat: outbound legacy ChatChannel Fellowship id=0x... len=N"
(when SendChatCmd uses the legacy 0x0147 path)
- "chat: SendChatCmd kind=General dropped (turbine.Enabled=false no legacy id)"
(when neither path can dispatch — usually means ACE didn't send
0x0295 yet and the kind is Turbine-only)
Sets up Bug 3 (proper outbound TurbineChat for /g /trade /lfg) for
a follow-up commit once the next live trace shows the actual flow.
18 new tests:
- WeenieErrorMessagesTests: 11 covering known templates + fallback.
- ChatInputParserTests: +7 Theory cases for trailing-punctuation strip.
Solution total: 1007 green (114 UI + 650 Core + 243 Core.Net), 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
236 lines
9.4 KiB
C#
236 lines
9.4 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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/t Caith, hi", "Caith")]
|
|
[InlineData("/t Caith: hi", "Caith")]
|
|
[InlineData("/t Caith; hi", "Caith")]
|
|
[InlineData("/t Caith. hi", "Caith")]
|
|
[InlineData("/t Caith! hi", "Caith")]
|
|
[InlineData("/t Caith? hi", "Caith")]
|
|
[InlineData("/tell Caith, hi", "Caith")]
|
|
public void TellPrefix_StripsTrailingPunctuationFromTarget(string raw, string expectedTarget)
|
|
{
|
|
// Retail muscle memory: "/t Name, message" — comma is the
|
|
// separator. Filed after a 2026-04-25 live-launch session
|
|
// where typing "/t je, hello" produced target="je," and the
|
|
// server responded with WeenieError 0x052B (CharacterNotAvailable)
|
|
// because no character "je," exists. Strip a trailing
|
|
// ,;:.!? from the target so both forms work.
|
|
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
|
Assert.Equal(expectedTarget, parsed.Value.TargetName);
|
|
Assert.Equal("hi", 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);
|
|
}
|
|
}
|