Owner direction 2026-09-07 (same instruction as the sibling unknown- command fix, previous commit): plugin-originated text must land in the chat window instead of retail's ClientLocal (0x1A) SpewBox-only channel. AppAutomationSurface.PostSystemMessage -- the production implementation of IPluginChat.PostSystemMessage that MossTank/VTank- style plugins call -- now passes RetailLogTextType.Default instead of ClientLocal to RuntimeCommunicationState.AddText, so the text reaches the chat transcript via Chat.OnSystemMessage instead of the SpewBox. This matches Decal's own AddChatText behavior for plugin output. IPluginChat.PostSystemMessage's doc comment is updated to describe the new destination instead of the old one. Register row AD-124 (previous commit) already covers this site alongside the sibling unknown-command change. Mutation check: temporarily reverted PostSystemMessage's AddText call back to ClientLocal and confirmed the new AppAutomationSurfaceTests.PostSystemMessage_RoutesToChatLog_NeverSpewBox test fails (Assert.Single() on an empty chat log) before restoring the fix. Also adds ChatVMTests.RecentLines_ShowsPluginSystemMessage_TaggedDefault pinning that a ChatVM bound to the same ChatLog surfaces the line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
229 lines
8.8 KiB
C#
229 lines
8.8 KiB
C#
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests;
|
|
|
|
public sealed class ChatVMTests
|
|
{
|
|
[Fact]
|
|
public void RecentLines_ReturnsEmpty_ForEmptyLog()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
|
|
Assert.Empty(vm.RecentLines());
|
|
}
|
|
|
|
[Fact]
|
|
public void RecentLines_ReturnsAllEntries_WhenBelowLimit()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnLocalSpeech(sender: "Caith", text: "hello", senderGuid: 0x5000_0001u, isRanged: false, logTextType: 0x02u);
|
|
log.OnLocalSpeech(sender: "Regal", text: "world", senderGuid: 0x5000_0002u, isRanged: false, logTextType: 0x02u);
|
|
|
|
var vm = new ChatVM(log, displayLimit: 20);
|
|
var lines = vm.RecentLines();
|
|
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal("Caith says, \"hello\"", lines[0]);
|
|
Assert.Equal("Regal says, \"world\"", lines[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void RecentLines_ReturnsTail_WhenAboveLimit_InOldestFirstOrder()
|
|
{
|
|
var log = new ChatLog();
|
|
for (int i = 0; i < 30; i++)
|
|
log.OnLocalSpeech(sender: "A", text: $"msg{i}", senderGuid: 0x5000_0001u, isRanged: false, logTextType: 0x02u);
|
|
|
|
var vm = new ChatVM(log, displayLimit: 5);
|
|
var lines = vm.RecentLines();
|
|
|
|
// Tail = msg25..msg29 (5 entries, oldest first).
|
|
Assert.Equal(5, lines.Count);
|
|
Assert.Equal("A says, \"msg25\"", lines[0]);
|
|
Assert.Equal("A says, \"msg29\"", lines[4]);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatEntry_LocalSpeech_RetailStyleSays()
|
|
{
|
|
// Retail format: "Name says, \"text\"" for someone else;
|
|
// "You say, \"text\"" for our own /say (sender == "" or "You").
|
|
var incoming = new ChatEntry(ChatKind.LocalSpeech, "Caith", "hello", 0x5000_0001u, 0);
|
|
Assert.Equal("Caith says, \"hello\"", ChatVM.FormatEntry(incoming));
|
|
|
|
var ownEcho = new ChatEntry(ChatKind.LocalSpeech, "", "hi there", 0, 0);
|
|
Assert.Equal("You say, \"hi there\"", ChatVM.FormatEntry(ownEcho));
|
|
|
|
var ownEchoSubst = new ChatEntry(ChatKind.LocalSpeech, "You", "shouted echo", 0, 0);
|
|
Assert.Equal("You say, \"shouted echo\"", ChatVM.FormatEntry(ownEchoSubst));
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatEntry_RangedSpeech_RetailStyleShouts()
|
|
{
|
|
var incoming = new ChatEntry(ChatKind.RangedSpeech, "Caith", "hello", 0x5000_0001u, 0);
|
|
Assert.Equal("Caith shouts, \"hello\"", ChatVM.FormatEntry(incoming));
|
|
|
|
var ownEcho = new ChatEntry(ChatKind.RangedSpeech, "You", "loud", 0, 0);
|
|
Assert.Equal("You shout, \"loud\"", ChatVM.FormatEntry(ownEcho));
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatEntry_Channel_UsesChannelNameWhenPresent()
|
|
{
|
|
// Friendly name takes precedence — "[Trade] Caith says, \"...\""
|
|
var named = new ChatEntry(ChatKind.Channel, "Caith", "g'day", 0x5000_0001u, 7u)
|
|
{
|
|
ChannelName = "Trade",
|
|
};
|
|
Assert.Equal("[Trade] Caith says, \"g'day\"", ChatVM.FormatEntry(named));
|
|
|
|
// Falls back to "ch {id}" when ChannelName isn't set (legacy
|
|
// path / older callers).
|
|
var unnamed = new ChatEntry(ChatKind.Channel, "Caith", "g'day", 0x5000_0001u, 7u);
|
|
Assert.Equal("[ch 7] Caith says, \"g'day\"", ChatVM.FormatEntry(unnamed));
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatEntry_Tell_RetailStyleTells()
|
|
{
|
|
// SenderGuid != 0 -> incoming whisper -> "Regal tells you, ..."
|
|
var incoming = new ChatEntry(ChatKind.Tell, "Regal", "psst", 0x5000_0002u, 0);
|
|
Assert.Equal("Regal tells you, \"psst\"", ChatVM.FormatEntry(incoming));
|
|
|
|
// SenderGuid == 0 -> our own outbound echo -> Sender carries
|
|
// the target name -> "You tell Regal, ..."
|
|
var ownEcho = new ChatEntry(ChatKind.Tell, "Regal", "psst", 0, 0);
|
|
Assert.Equal("You tell Regal, \"psst\"", ChatVM.FormatEntry(ownEcho));
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatEntry_System_NoSenderShown()
|
|
{
|
|
// Campaign CH user-gate round 1 (item B): retail prints system text
|
|
// bare, with no "[System]" prefix.
|
|
var entry = new ChatEntry(ChatKind.System, Sender: "", "Your spell fizzled!", 0, 0);
|
|
Assert.Equal("Your spell fizzled!", ChatVM.FormatEntry(entry));
|
|
}
|
|
|
|
[Fact]
|
|
public void FormatEntry_Popup_Prefixed()
|
|
{
|
|
var entry = new ChatEntry(ChatKind.Popup, Sender: "", "A door stands before you.", 0, 0);
|
|
Assert.Equal("[Popup] A door stands before you.", ChatVM.FormatEntry(entry));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_ThrowsOnNullLog()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new ChatVM(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_ThrowsOnZeroOrNegativeLimit()
|
|
{
|
|
var log = new ChatLog();
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new ChatVM(log, displayLimit: 0));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new ChatVM(log, displayLimit: -1));
|
|
}
|
|
|
|
[Fact]
|
|
public void RecentLines_ReturnsNewLineData_AfterSubsequentAppend()
|
|
{
|
|
// Confirm the VM isn't caching — each call re-snapshots the log.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
|
|
Assert.Empty(vm.RecentLines());
|
|
|
|
log.OnLocalSpeech("Caith", "hello", 0x5000_0001u, false, logTextType: 0x02u);
|
|
var after = vm.RecentLines();
|
|
Assert.Single(after);
|
|
Assert.Equal("Caith says, \"hello\"", after[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShowSystemMessage_UsesDefaultLogTextType()
|
|
{
|
|
// Corrected 2026-08-09, Opus review of 172c6f9a: ShowSystemMessage
|
|
// is ClientCommandController's general-purpose informational sink
|
|
// (/help, /clear, /framerate, /loc, @version, friends list, ...).
|
|
// Retail types the great majority of that command output 0x00
|
|
// Default (green); 0x1A (bright red) is reserved for genuine
|
|
// refusals, which land separately with CH2's SpewBox routing.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
|
|
vm.ShowSystemMessage("Unknown command: foo");
|
|
|
|
var entry = Assert.Single(log.Snapshot());
|
|
Assert.Equal(0x00u, entry.LogTextType);
|
|
}
|
|
|
|
// ── OP4 re-review R1 (2026-08-11): the timestamp prefixes the COMPOSED
|
|
// display line, never the body — '13:05:09 Alice says, "hi"', not
|
|
// 'Alice says, "13:05:09 hi"' (retail fprintf("%ls%ls", ts, text)
|
|
// @0x00563e5b). ─────────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void DisplayTimestamps_PrefixTheComposedLine_NotTheBody(bool timestampsOn)
|
|
{
|
|
var log = new ChatLog { DisplayTimestampsSource = () => timestampsOn };
|
|
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
|
|
var vm = new ChatVM(log);
|
|
|
|
string plain = Assert.Single(vm.RecentLines());
|
|
var detailed = Assert.Single(vm.RecentLinesDetailed());
|
|
|
|
if (timestampsOn)
|
|
{
|
|
// The stamp leads the whole composed line, OUTSIDE the quotes.
|
|
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Alice says, ""hi""$", plain);
|
|
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Alice says, ""hi""$", detailed.Text);
|
|
}
|
|
else
|
|
{
|
|
Assert.Equal("Alice says, \"hi\"", plain);
|
|
Assert.Equal("Alice says, \"hi\"", detailed.Text);
|
|
}
|
|
|
|
// The stored body never carries the stamp in either state.
|
|
Assert.Equal("hi", log.Snapshot()[0].Text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner-directed override 2026-09-07 (register row AD-124): plugin
|
|
/// output (<c>AppAutomationSurface.PostSystemMessage</c>, the
|
|
/// production implementation of <c>IPluginChat.PostSystemMessage</c>)
|
|
/// now funnels into <c>RuntimeCommunicationState.AddText(text,
|
|
/// RetailLogTextType.Default)</c>, which calls
|
|
/// <c>Chat.OnSystemMessage(text, (uint)Default)</c> — the exact call
|
|
/// this test performs directly on the shared <see cref="ChatLog"/>,
|
|
/// matching Decal's own <c>AddChatText</c> behavior for plugin text.
|
|
/// Any <see cref="ChatVM"/> bound to that log (the production chat
|
|
/// window) must show the line; it must never depend on the
|
|
/// <see cref="ChatVM.OnInterfaceText"/> SpewBox seam, which this call
|
|
/// never touches.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RecentLines_ShowsPluginSystemMessage_TaggedDefault()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log, displayLimit: 50);
|
|
|
|
log.OnSystemMessage(
|
|
"MossTank: buffs applied.",
|
|
chatType: (uint)RetailLogTextType.Default);
|
|
|
|
Assert.Equal(
|
|
"MossTank: buffs applied.",
|
|
Assert.Single(vm.RecentLines()));
|
|
Assert.Equal(
|
|
(uint)RetailLogTextType.Default,
|
|
Assert.Single(log.Snapshot()).LogTextType);
|
|
}
|
|
}
|