acdream/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
Erik 1fd515436c feat(chat): Campaign CH slice CH6a — retail chat-window layout + 8-grip resize
Swap ChatWindowController's imported main-chat LayoutDesc from the wrong
0x21000006 (an unrelated layout whose root and 800px resize bar appear
nowhere in the EoR gameplay UI) to retail's ACTUAL main chat window,
0x2100006F (window root 0x10000600, authored 410x100 — confirmed by a
direct DAT dump, found in dats.Local not dats.Portal). Every downstream
compensation that existed only to paper over the wrong import is deleted:
the hand-cropped 490px content width, the dropped 800px resize bar, the
9px transcript patch, the orphan-sibling pruning, the max/min-vs-scrollbar
overlap shift, and the scrollbar top-reclaim. The window now mounts with
RetailWindowChrome.Imported (0x2100006F's own 8 border/corner elements are
its complete chrome) instead of the universal nine-slice wrapper.

LayoutImporter/DatWidgetFactory gain a Type-9 (UIElement_Resizebar) case:
UiResizeGrip decodes retail's exact four-bool BorderLocation algorithm
(0x2A=bottom/0x2B=left/0x2C=right/0x2D=top,
UIElement_Resizebar::StartMouseResizing @0x0046B7E0) into a ResizeEdges
bitmask. A direct DAT dump established the true shape: only 7 of the 8
grip-position ids are Type 9 — the straight top-EDGE strip (0x1000069C) is
a Type-2 Dragbar (move handle), not a Resizebar, because the main window
has no title bar. UiRoot now gives a directly-hit grip's own edges
priority over its generic proximity heuristic, and a directly-hit move
handle the same priority over ambient proximity — so the plain top strip
moves the window while its two corner grips resize it including the Y
axis, and all 4 edges + 4 corners work everywhere else. This also fixes
the reported "no diagonal cursor at corners" (CursorFeedbackController's
existing RetailCursorCatalog cursor ids already matched the DAT exactly;
they just never received a genuine diagonal edge combination) and "cannot
grow in Y from the bottom-right corner" (the old NineSlice+crop mount's
indirection is gone; the Imported mount uses the DAT's real
minH=100/maxH=2000/minW=300/maxW=2000 directly).

The 8 cosmetic "_Locked" border-art twins default hidden (register row
AP-185 — retail's UiLocked-driven art swap between the two skins is not
ported; UiRoot.UiLocked continues to gate the underlying interaction
correctly either way). The 4 chat-window-1..4 indicator buttons import
generically (visible, inert) for CH6b to wire. The two hand-drawn
translucent-black tints on the transcript/input are removed now that
their parent panels draw their own authored background sprites.

Filed #366 (chat window's new-unseen-text indicator 0x1000048C is
swallowed by UiText.ConsumesDatChildren, pre-existing and out of scope).
Corrected the research doc's "all eight grips" claim against the direct
DAT dump. Full Release suite: 12,317 passed / 4 skipped / 0 failed.

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

490 lines
20 KiB
C#

using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Smoke tests for <see cref="ChatWindowController.Bind"/> — no dats, no GL.
///
/// Building the Type-12 "skipped" elements via the pure <see cref="LayoutImporter"/>
/// path is the correct approach: we build a synthetic info tree that reflects the
/// real chat layout hierarchy (root → transcript panel + input bar as Type-3
/// containers, with Type-12 children for transcript + input, plus a Type-3 track
/// and menu), call <see cref="LayoutImporter.Build"/> to get the widget tree
/// (Type-12 children become property-driven text/field widgets), then call
/// <see cref="ChatWindowController.Bind"/> which binds those widgets in place.
/// </summary>
public class ChatWindowControllerTests
{
// ── Null-resolve helper (no GL needed) ─────────────────────────────────
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
// ── Capture bus — records every Publish call ────────────────────────────
private sealed class CaptureBus : ICommandBus
{
public readonly List<object> Published = new();
public void Publish<T>(T cmd) where T : notnull => Published.Add(cmd!);
}
// ── Synthetic element tree matching the real chat layout topology ────────
/// <summary>
/// Build a minimal synthetic ElementInfo tree that mirrors the real chat
/// layout (0x2100006F) with enough fidelity for Bind to succeed:
/// root (Type-3)
/// transcriptPanel (Type-3) [0x10000010]
/// transcript (Type-12, no media) [0x10000011] ← built as UiText by factory; Bind binds in place
/// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind
/// inputBar (Type-3) [0x10000013]
/// menu (Type-6) [0x10000014]
/// input (Type-12, Editable+Selectable) [0x10000016] ← built as UiField
/// send (Type-3) [0x10000019]
/// maxmin (Type-3) [0x1000046F]
/// </summary>
private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree(
ChatLog? log = null)
{
var transcriptNode = new ElementInfo
{
Id = 0x10000011u, Type = 12, // Type-12, no media → skipped by factory
X = 16, Y = 0, Width = 458, Height = 74,
};
var trackNode = new ElementInfo
{
Id = 0x10000012u, Type = 3,
X = 474, Y = 6, Width = 16, Height = 68,
};
var transcriptPanel = new ElementInfo
{
Id = 0x10000010u, Type = 3, X = 0, Y = 9, Width = 490, Height = 74,
};
transcriptPanel.Children.Add(transcriptNode);
transcriptPanel.Children.Add(trackNode);
var menuNode = new ElementInfo
{
Id = 0x10000014u, Type = 6, X = 0, Y = 0, Width = 46, Height = 17,
};
var inputNode = new ElementInfo
{
Id = 0x10000016u, Type = 12,
X = 46, Y = 0, Width = 398, Height = 17,
};
var inputState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
inputState.Properties.Values[0x16u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputState.Properties.Values[0x20u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputState.Properties.Values[0x27u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputNode.States[UiStateInfo.DirectStateId] = inputState;
var sendNode = new ElementInfo
{
Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17,
};
var inputBar = new ElementInfo
{
Id = 0x10000013u, Type = 3, X = 0, Y = 83, Width = 490, Height = 17,
};
inputBar.Children.Add(menuNode);
inputBar.Children.Add(inputNode);
inputBar.Children.Add(sendNode);
var maxMinNode = new ElementInfo
{
Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16,
};
var root = new ElementInfo
{
Id = 0x10000600u, Type = 3, Width = 490, Height = 100,
};
root.Children.Add(transcriptPanel);
root.Children.Add(inputBar);
root.Children.Add(maxMinNode);
var layout = LayoutImporter.Build(root, NoTex, null);
var vm = new ChatVM(log ?? new ChatLog());
return (root, layout, vm);
}
// ── Test 1: Bind returns non-null with the minimal tree ──────────────────
[Fact]
public void Bind_Returns_NonNull_OnValidTree()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
}
// ── Test 2: Transcript is placed as a child of the transcript panel ──────
[Fact]
public void Bind_Transcript_IsChildOfTranscriptPanel()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
var panel = layout.FindElement(0x10000010u);
Assert.NotNull(panel);
// The transcript widget must be a child of the transcript panel.
Assert.Contains(ctrl!.Transcript, panel!.Children);
}
// ── Test 3: Input is placed as a child of the input bar ─────────────────
[Fact]
public void Bind_Transcript_ForcesScrollableTextMode()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
Assert.False(ctrl!.Transcript.Centered);
Assert.False(ctrl.Transcript.RightAligned);
}
[Fact]
public void Bind_Input_IsChildOfInputBar()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
var bar = layout.FindElement(0x10000013u);
Assert.NotNull(bar);
Assert.Contains(ctrl!.Input, bar!.Children);
}
[Fact]
public void TranscriptLayout_IsReusedUntilContentOrWidthChanges()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, null, null, NoTex)!;
vm.ShowSystemMessage("one wrapped transcript line");
IReadOnlyList<UiText.Line> first = ctrl.Transcript.LinesProvider();
IReadOnlyList<UiText.Line> unchanged = ctrl.Transcript.LinesProvider();
Assert.Same(first, unchanged);
Assert.Equal(1, ctrl.TranscriptLayoutBuildCount);
vm.ShowSystemMessage("second line");
IReadOnlyList<UiText.Line> appended = ctrl.Transcript.LinesProvider();
Assert.NotSame(first, appended);
Assert.Equal(2, ctrl.TranscriptLayoutBuildCount);
ctrl.Transcript.Width -= 40f;
IReadOnlyList<UiText.Line> resized = ctrl.Transcript.LinesProvider();
Assert.NotSame(appended, resized);
Assert.Equal(3, ctrl.TranscriptLayoutBuildCount);
vm.Clear();
Assert.Empty(ctrl.Transcript.LinesProvider());
Assert.Equal(4, ctrl.TranscriptLayoutBuildCount);
}
[Fact]
public void TranscriptLines_OutOfRangeLogTextType_CarriesPreviousLinesColor()
{
// Retail's SetFontColorHelper leaves m_curFontColor UNCHANGED for an
// out-of-range index instead of reverting to a default (research
// doc §3.2) — RetailChatColorTable.TryGetColor returns false for
// any index >= 0x22 and ChatWindowController.GetTranscriptLines
// carries the prior line's resolved color forward. Three entries;
// the middle one uses 0x22 (one past the last real retail slot,
// 0x21) so its rendered color must equal the first line's, not
// the third's.
var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log);
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, null, null, NoTex)!;
log.OnSystemMessage("first", chatType: 0x05u); // System, colorBrightPurple
log.OnSystemMessage("middle", chatType: 0x22u); // out of range — carries 0x05's color
log.OnSystemMessage("third", chatType: 0x00u); // Default, colorGreen
IReadOnlyList<UiText.Line> lines = ctrl.Transcript.LinesProvider();
Assert.Equal(3, lines.Count);
Assert.Equal(lines[0].Color, lines[1].Color);
Assert.NotEqual(lines[0].Color, lines[2].Color);
}
// ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ─────
[Fact]
public void Bind_InputSubmit_PublishesSendChatCmd()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
ctrl!.Input.OnSubmit!.Invoke("hello world");
// ChatCommandRouter.Submit should have published a SendChatCmd.
Assert.Single(bus.Published);
var cmd = Assert.IsType<SendChatCmd>(bus.Published[0]);
Assert.Equal("hello world", cmd.Text);
}
[Fact]
public void Bind_LifestoneSubmit_PublishesTypedClientCommand()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
ctrl!.Input.OnSubmit!.Invoke("/ls");
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
}
// ── Test 5: Channel change updates the channel used by subsequent submits ─
[Fact]
public void Bind_ChannelChange_UpdatesSubmitChannel()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
// Switch channel to General via the generic OnSelect (payload is ChatChannelKind).
ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General);
ctrl.Input.OnSubmit!.Invoke("hey all");
Assert.Single(bus.Published);
var cmd = Assert.IsType<SendChatCmd>(bus.Published[0]);
Assert.Equal(ChatChannelKind.General, cmd.Channel);
}
// ── Test 6: Bind returns null when required elements are absent ──────────
[Fact]
public void Bind_Returns_Null_WhenTranscriptPanelMissing()
{
// Build a layout that is missing the transcript panel entirely.
var root = new ElementInfo { Id = 0x10000600u, Type = 3, Width = 490, Height = 100 };
// No children → TranscriptPanelId and InputBarId are absent from the widget tree.
var layout = LayoutImporter.Build(root, NoTex, null);
var vm = new ChatVM(new ChatLog());
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, null, null, NoTex);
Assert.Null(ctrl);
}
// ── Input field resize: Campaign CH user-gate round 1, item G ────────────
// The input line overflowed the chat window's right edge on resize. Its
// right edge held a FIXED absolute pixel position (either the imported
// UiLayoutPolicy's mode-0 "frozen at current" fallback, or the
// AnchorEdges default with no Right bit) — nothing re-ran the
// Left/Width recompute on a plain window resize, only at bind time and
// on channel change. Bind now leaves the input's right edge tracking
// the live parent width either way.
[Fact]
public void Bind_InputField_WithNoImportedLayoutPolicy_NeverOverflowsOnNarrowerResize()
{
// BuildTestTree's synthetic ElementInfo nodes never set
// HasOriginalParentSize, so DatWidgetFactory.CreateLayoutPolicy
// returns null for every widget here — this exercises the
// AnchorEdges fallback branch of the fix.
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
Assert.Null(ctrl!.Input.LayoutPolicy);
Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, ctrl.Input.Anchors & (AnchorEdges.Left | AnchorEdges.Right));
// First frame after Bind(): the per-frame draw pass calls ApplyAnchor
// against the LIVE (still-authored, 490px) inputBar width — this is
// the lazy margin CAPTURE, matching UiElement.ApplyAnchor's
// "!_anchorCaptured" first-call semantics. Only THEN does a resize
// (a later frame, a smaller parent width) exercise the stretch.
const float authoredParentWidth = 490f;
ctrl.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height);
const float narrowerParentWidth = 300f;
ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height);
Assert.True(
ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth,
$"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})");
}
[Fact]
public void Bind_InputField_WithNoImportedLayoutPolicy_GrowsWithWiderResize()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
const float authoredParentWidth = 490f;
ctrl!.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height);
float originalWidth = ctrl.Input.Width;
const float widerParentWidth = 800f;
ctrl.Input.ApplyAnchor(widerParentWidth, ctrl.Input.Height);
Assert.True(ctrl.Input.Width > originalWidth, "the input should widen when the window grows");
Assert.True(ctrl.Input.Left + ctrl.Input.Width <= widerParentWidth);
}
[Fact]
public void Bind_InputField_WithImportedLayoutPolicy_RightEdgeTracksParentDeltaInsteadOfFreezing()
{
// Mirror the REAL production import path: the input field carries an
// authored UiLayoutPolicy (HasOriginalParentSize=true, as a real
// ImportInfos-resolved LayoutDesc element would). Right=0 here is
// retail's raw edge mode BEFORE the fix's upgrade — proving Bind
// replaces it with mode 1 rather than leaving mode 0's "frozen at
// current pixel position" behavior in place.
var (rootInfo, layout, vm) = BuildTestTree();
var inputInfo = FindById(rootInfo, 0x10000016u)
?? throw new System.InvalidOperationException("test fixture missing the input node");
inputInfo.HasOriginalParentSize = true;
inputInfo.OriginalParentWidth = 490f;
inputInfo.OriginalParentHeight = 17f;
inputInfo.Left = 1u; // near-edge: fixed to current (retail mode 1)
inputInfo.Top = 1u;
inputInfo.Right = 0u; // far-edge mode this fix must upgrade away from
inputInfo.Bottom = 1u;
// Rebuild the widget tree now that the fixture carries the policy
// inputs (BuildTestTree already built one without them).
layout = LayoutImporter.Build(rootInfo, NoTex, null);
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
Assert.NotNull(ctrl!.Input.LayoutPolicy);
Assert.Equal(1u, ctrl.Input.LayoutPolicy!.RightMode);
const float narrowerParentWidth = 300f;
ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height);
Assert.True(
ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth,
$"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})");
}
private static ElementInfo? FindById(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (var child in node.Children)
{
if (FindById(child, id) is { } found) return found;
}
return null;
}
// ── WrapText: Campaign CH user-gate round 1, item F ──────────────────────
// /help (and "probably many places") never split on embedded '\n' — the
// whole multi-line blob rode the single early-out as ONE line. Split on
// '\n' first, then word-wrap each segment; a single-segment text keeps
// the pre-existing early-out behavior exactly.
private static float MeasureByCharCount(string s) => s.Length;
[Fact]
public void WrapText_EmbeddedNewlines_ProduceOneRenderedLinePerSegment()
{
string text = "line one\nline two\nline three";
// maxW is generous — every segment fits without word-wrapping, so
// this isolates the newline-split behavior specifically.
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "line one", "line two", "line three" }, lines);
}
[Fact]
public void WrapText_CarriageReturnNewline_NormalizesTheSameAsBareNewline()
{
string text = "line one\r\nline two";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "line one", "line two" }, lines);
}
[Fact]
public void WrapText_SegmentLongerThanMaxWidth_StillWordWraps()
{
// Each segment is independently word-wrapped by the SAME algorithm
// the single-line path always used — a multi-line server message
// whose second line overflows the window still wraps that line.
string text = "short\nthis segment is much too long to fit on one line";
var lines = new List<string>(ChatWindowController.WrapText(text, 10f, MeasureByCharCount));
Assert.Equal("short", lines[0]);
Assert.True(lines.Count > 2, "the long second segment should have wrapped into multiple lines");
Assert.All(lines, line => Assert.True(MeasureByCharCount(line) <= 10f));
Assert.Equal(
"this segment is much too long to fit on one line",
string.Join(" ", lines.Skip(1)));
}
[Fact]
public void WrapText_SingleSegmentText_KeepsTheEarlyOutBehavior()
{
// No '\n' at all — the pre-existing single-line early-out path
// (whole text fits => returned verbatim as one fragment) is
// unchanged.
string text = "no newlines here";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { text }, lines);
}
[Fact]
public void WrapText_ConsecutiveNewlines_ProduceABlankLine()
{
string text = "first\n\nthird";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "first", "", "third" }, lines);
}
}