acdream/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs
Erik 172c6f9aa3 feat(chat): Campaign CH slice CH1 — retail LogTextType color table
Retail colors chat lines by the 34-value wire LogTextType (ACE's
ChatMessageType), NOT by acdream's synthetic 9-value ChatKind. The old
ChatWindowController.RetailChatColor(ChatKind) collapsed distinct retail
colors onto one bucket per ChatKind — e.g. every Channel line rendered
colorLightBlue (Magic's slot) when retail's actual palette spans five
different colors across the Turbine rooms and legacy allegiance family.

Ports ChatInterface::BuildChatColorLookupTable @0x004F31C0 verbatim
(RetailChatColorTable, all 34 RGBA floats read from the PDB-paired
binary's .data section) and threads a new ChatEntry.LogTextType field
through every ingestion site to the correct retail wire value:
HearSpeech/Tell pass the wire chatType through verbatim; Emote/SoulEmote
hard-code 0x0C; the Tell self-echo hard-codes 0x04; legacy ChatChannel
broadcasts derive their type from the channel bit via the new
LegacyChannelChatType helper (ported from the decompiled
Handle_Communication__ChannelBroadcast dispatch, hear vs. own-send);
TurbineChat rooms map through TurbineChatDisplayNames.LogTextType;
CombatChatTranslator's hit/miss/evade lines map to ACE's CombatSelf/
CombatEnemy per Player_Combat.cs; kill/death lines use retail's
decompiled 0x00 Default (not a combat color). ChatWindowController's
transcript now folds LogTextType through RetailChatColorTable with
retail's exact "out-of-range keeps the previous line's color" carry
rule; ChatPanel's combat highlighting sources the same table.

Corrects HearSpeech.cs's doc-comment ChatType legend (4 of 6 entries
were wrong). Adds register row AP-175 for the pre-existing (unchanged)
Popup-renders-in-chat divergence and updates AP-39's stale per-ChatKind
description. Narrows ISSUES #139 — its chat-colors half is done.

Retail renders no chat timestamp prefix path exists in acdream today,
so the "timestamp is always colorGrey 0x0C" rule has nothing to attach
to; noted here per the research doc rather than left silent.

Research: docs/research/2026-08-09-chat-retail-color-table.md
Full Release suite: 11,833 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:24:09 +02:00

159 lines
5.7 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);
log.OnLocalSpeech(sender: "Regal", text: "world", senderGuid: 0x5000_0002u, isRanged: false);
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);
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()
{
var entry = new ChatEntry(ChatKind.System, Sender: "", "Your spell fizzled!", 0, 0);
Assert.Equal("[System] 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);
var after = vm.RecentLines();
Assert.Single(after);
Assert.Equal("Caith says, \"hello\"", after[0]);
}
[Fact]
public void ShowSystemMessage_UsesClientLocalLogTextType()
{
// Campaign CH slice CH1: client-local command output (/help, /clear,
// /framerate, /loc, ...) never reaches the wire — retail's own
// client-local text is LogTextType 0x1A (bright red).
var log = new ChatLog();
var vm = new ChatVM(log);
vm.ShowSystemMessage("Unknown command: foo");
var entry = Assert.Single(log.Snapshot());
Assert.Equal(0x1Au, entry.LogTextType);
}
}