fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture

Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 13:09:32 +02:00
parent 56b84deeab
commit 1aa7709988
23 changed files with 6284 additions and 248 deletions

View file

@ -152,7 +152,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
}
@ -165,7 +165,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
var panel = layout.FindElement(0x10000010u);
@ -182,7 +182,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
Assert.False(ctrl!.Transcript.Centered);
@ -195,7 +195,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
var bar = layout.FindElement(0x10000013u);
@ -209,7 +209,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, null, null, NoTex)!;
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
vm.ShowSystemMessage("one wrapped transcript line");
IReadOnlyList<UiText.Line> first = ctrl.Transcript.LinesProvider();
@ -247,8 +247,14 @@ public class ChatWindowControllerTests
var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log);
var bus = new CaptureBus();
// 0x22 is above the default main filter's low dword (retail's
// 0xFBFFFFFF only covers types 0-31) — accept everything so this
// test isolates the color-carry-forward behavior it's actually
// about from the (correctly) separate window-filter concern.
var filters = new ChatWindowState();
filters.SetFilter(ChatWindowState.MainWindowId, ulong.MaxValue);
var ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, null, null, NoTex)!;
rootInfo, layout, vm, () => bus, filters, null, null, NoTex)!;
log.OnSystemMessage("first", chatType: 0x05u); // System, colorBrightPurple
log.OnSystemMessage("middle", chatType: 0x22u); // out of range — carries 0x05's color
@ -269,7 +275,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
ctrl!.Input.OnSubmit!.Invoke("hello world");
@ -286,7 +292,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
ctrl!.Input.OnSubmit!.Invoke("/ls");
@ -303,7 +309,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
// Switch channel to General via the generic OnSelect (payload is ChatChannelKind).
@ -328,7 +334,7 @@ public class ChatWindowControllerTests
var vm = new ChatVM(new ChatLog());
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.Null(ctrl);
}
@ -351,7 +357,7 @@ public class ChatWindowControllerTests
// 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);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
Assert.Null(ctrl!.Input.LayoutPolicy);
Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, ctrl.Input.Anchors & (AnchorEdges.Left | AnchorEdges.Right));
@ -377,7 +383,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
const float authoredParentWidth = 490f;
@ -415,7 +421,7 @@ public class ChatWindowControllerTests
// 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);
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
Assert.NotNull(ctrl!.Input.LayoutPolicy);
@ -439,76 +445,8 @@ public class ChatWindowControllerTests
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);
}
// WrapText moved to ChatTranscriptRendererTests.cs (CH6a/b REJECT-review
// NIT 5 — WrapText itself moved from this class to ChatTranscriptRenderer).
// ── SetIndicatorOpen: Campaign CH slice CH6b — the button mirror ─────────
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80: State 6
@ -524,7 +462,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!;
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
var indicator = Assert.IsType<UiButton>(layout.FindElement(indicatorId));
ctrl.SetIndicatorOpen(windowId, open: true);
@ -537,7 +475,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!;
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
var indicator = Assert.IsType<UiButton>(layout.FindElement(0x10000522u));
ctrl.SetIndicatorOpen(1, open: true);
@ -551,7 +489,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!;
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
var indicator2 = Assert.IsType<UiButton>(layout.FindElement(0x10000523u));
ctrl.SetIndicatorOpen(1, open: true);
@ -566,7 +504,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!;
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
Assert.Throws<ArgumentOutOfRangeException>(() => ctrl.SetIndicatorOpen(windowId, open: true));
}