feat(ui): ChatPanel — second devtools panel proves the abstraction

Adds a second real panel behind ACDREAM_DEVTOOLS=1. Shows the tail
of ChatLog (last 20 entries by default) formatted per ChatKind:

  "Caith: hello"                  — LocalSpeech
  "Regal says distantly: hi"      — RangedSpeech
  "[ch 7] Caith: g'day"           — Channel
  "[Tell] Regal: psst"            — Tell
  "[System] Your spell fizzled!"  — System
  "[Popup] A door stands..."      — Popup

Why now: proves the D.2a IPanelRenderer contract survives beyond a
single progress-bar panel. ChatPanel exercises Text() + Separator()
on a variable-length list where VitalsPanel was a fixed three-widget
layout. No renderer primitives needed to grow — the contract held,
which is the whole point of the abstraction layer.

Files:
  - src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs (new)
      Snapshots ChatLog tail every frame. Cheap at default 500-entry
      cap. Per-kind formatting lives here (not in the panel) so the
      D.2b retail-look swap inherits plain-text fallbacks.
  - src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs (new)
      IPanel implementation. Separator + N Text lines. "(no messages
      yet)" fallback when the log is empty.
  - src/AcDream.App/Rendering/GameWindow.cs
      Registers the ChatPanel alongside VitalsPanel in the devtools
      init block. Uses the existing GameWindow.Chat field already
      fed by H.1's wire layer + GameEventWiring.WireAll.
  - tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs (new)
      12 tests covering tail selection, display-limit bounds, every
      ChatKind's formatting, null-log + zero-limit guards, no stale
      caching across appends.

Also fixes one stale "Hexa.NET.ImGui" mention in VitalsPanel's xmldoc
(pivoted to ImGui.NET in 55aaca7; doc needed a trailing update).

Build: 0 warnings, 0 errors. Tests: 23 UI.Abstractions (up from 11,
all Core + Core.Net still green), 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 00:48:07 +02:00
parent 4d1b8b8aee
commit 9faf9d7e3a
5 changed files with 273 additions and 2 deletions

View file

@ -0,0 +1,118 @@
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: hello", lines[0]);
Assert.Equal("Regal: 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: msg25", lines[0]);
Assert.Equal("A: msg29", lines[4]);
}
[Fact]
public void FormatEntry_LocalSpeech_SenderColonText()
{
var entry = new ChatEntry(ChatKind.LocalSpeech, "Caith", "hello", 0x5000_0001u, 0);
Assert.Equal("Caith: hello", ChatVM.FormatEntry(entry));
}
[Fact]
public void FormatEntry_RangedSpeech_IncludesDistanceHint()
{
var entry = new ChatEntry(ChatKind.RangedSpeech, "Caith", "hello", 0x5000_0001u, 0);
Assert.Equal("Caith says distantly: hello", ChatVM.FormatEntry(entry));
}
[Fact]
public void FormatEntry_Channel_IncludesChannelId()
{
var entry = new ChatEntry(ChatKind.Channel, "Caith", "g'day", 0x5000_0001u, 7u);
Assert.Equal("[ch 7] Caith: g'day", ChatVM.FormatEntry(entry));
}
[Fact]
public void FormatEntry_Tell_PrefixedWithTellTag()
{
var entry = new ChatEntry(ChatKind.Tell, "Regal", "psst", 0x5000_0002u, 0);
Assert.Equal("[Tell] Regal: psst", ChatVM.FormatEntry(entry));
}
[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: hello", after[0]);
}
}