acdream/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs
Erik 47e40900f3 fix(chat): Campaign CH user-gate round 1 — jump-in-air edge, portal cue cadence, wrap/prefix/color fixes
The user tested Campaign CH's CODE-COMPLETE build live and reported ten
defects (docs/plans/2026-08-09-chat-parity-campaign.md, "User gate —
round 1"). Items A-G are fixed here; the remaining three (extra chat
windows on 1/2/3/4, resize working in only one corner, transparency/
artifacts) are out of scope for a fix and filed as slice CH6.

A. Jump-in-air refusal never fired live: the jump block only ever
   evaluated input.Jump inside the grounded-charge or already-charging
   branches. PlayerMovementController now detects the press RISING EDGE
   while airborne and reports WeenieError.NotGrounded once per press,
   leaving the grounded charge/fire path untouched.
B. ChatVM's invented "[System] " prefix is dropped — retail prints
   system text bare. [Popup] is unchanged (AP-175).
C. SpewBoxController's color is now the user-pinned exact value
   (1, 1, 0.247, 1), the same bright yellow as an incoming Tell.
   Register row AP-178 updated: color CLOSES, size/position/font stay
   open per the user's live report that they still differ.
D. Closes #329: PortalTunnelPresentation now emits the portal wait cue
   unconditionally on every rotation-segment boundary, matching
   gmSmartBoxUI::UseTime's decompiled else-arm exactly instead of gating
   on a 5-second hold local transits never reached. PortalWaitNotice
   Controller now renders it in the same pinned yellow as item C.
   Register row AP-150 retired.
E. Closes #362: new ClientCommandResponses.cs parses and renders the
   four previously-unhandled inbound GameEvents (ChannelIndex,
   ChannelList, AvailableHouses, AllegianceInfoResponse), each ported
   line-for-line from the named-retail decomp's inbound handlers.
   Register row TS-70 retired.
F. ChatWindowController.WrapText now splits on embedded '\n'/'\r\n'
   first, then word-wraps each segment independently — server text like
   /help's reply no longer collapses onto one line.
G. The chat input field's right edge no longer holds a fixed absolute
   pixel position across a window resize; Bind now upgrades it to
   retail edge-mode 1 (UiLayoutPolicy) or the AnchorEdges.Right stretch
   fallback so it tracks the window's client width instead of
   overflowing past a narrower resize.

Full Release suite: 12,247 passed / 4 skipped / 0 failed (baseline
12,221/4/0 + 26 new tests across items A, E, F, G).

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

164 lines
6.1 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);
}
}