acdream/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs
Erik ac0304dcf0 fix(ui,runtime): OP4 re-review residuals R1-R4 (coordinator pass) — OP4 CLOSED
R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).

R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.

R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.

Full Release suite: 13,083 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 06:36:14 +02:00

197 lines
7.5 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);
}
}