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

@ -77,7 +77,7 @@ public class ChatLayoutConformanceTests
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, null, null, NoTex);
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement? indicator = layout.FindElement(indicatorId);
@ -91,6 +91,18 @@ public class ChatLayoutConformanceTests
var button = Assert.IsType<UiButton>(indicator);
Assert.Null(button.OnClick);
Assert.Null(button.OnClickAt);
// CH6a/b REJECT-review SHOULD-FIX 3: OnClick==null is the wrong level
// to prove "inert" — the fixture's own 0x0B (ToggleBehavior) = true
// means a plain press/release would still flip UiButton's internal
// Selected mirror even with no OnClick bound. SuppressSelfToggle
// (set by ChatWindowController.Bind) is the actual guard; a press
// and release must leave Selected unchanged.
Assert.True(button.SuppressSelfToggle);
bool before = button.Selected;
button.OnEvent(new UiEvent(0, button, UiEventType.MouseDown, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.MouseUp, Data1: 2, Data2: 2));
Assert.Equal(before, button.Selected);
}
[Theory]
@ -110,7 +122,7 @@ public class ChatLayoutConformanceTests
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, null, null, NoTex);
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement? twin = layout.FindElement(lockedTwinId);
@ -191,6 +203,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
@ -226,6 +239,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
@ -250,6 +264,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
@ -324,6 +339,27 @@ public class ChatLayoutConformanceTests
Assert.True(topStrip!.WindowMoveHandle);
}
[Theory]
[InlineData(0x1000069Bu)] // TL corner
[InlineData(0x1000069Du)] // TR corner
[InlineData(0x1000069Eu)] // left edge
[InlineData(0x1000069Fu)] // BL corner
[InlineData(0x100006A0u)] // bottom edge
[InlineData(0x100006A1u)] // BR corner
[InlineData(0x100006A2u)] // right edge
public void MountedChatWindow_LiveGrip_ResolvesNonZeroSprite(uint elementId)
{
// CH6a/b REJECT-review BLOCKER 1: pre-fix, UiResizeGrip drew nothing —
// it was constructed without its ElementInfo/resolve pair, so all seven
// live Type-9 grips rendered NOTHING even though the fixture proves
// every one of them carries real authored art (the 0x06006129 family).
// This is the regression guard: every live grip must resolve a non-zero
// DirectState sprite id, not just decode the right BorderLocation.
var layout = FixtureLoader.LoadChat();
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
Assert.NotEqual(0u, grip.SpriteFile);
}
[Fact]
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
{
@ -340,6 +376,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
@ -396,6 +433,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;

View file

@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// <see cref="ChatTranscriptRenderer.WrapText"/> tests — moved here from
/// <c>ChatWindowControllerTests</c> (CH6a/b REJECT-review NIT 5: WrapText
/// itself moved off <c>ChatWindowController</c> onto
/// <see cref="ChatTranscriptRenderer"/> to close the circular dependency
/// where <see cref="ChatTranscriptRenderer.BuildLines"/> called back into
/// one of its own two consumers).
///
/// <para>
/// Campaign CH user-gate round 1, item F: <c>/help</c> (and "probably many
/// places") never split on embedded <c>'\n'</c> — the whole multi-line blob
/// rode the single early-out as ONE line. Split on <c>'\n'</c> first, then
/// word-wrap each segment; a single-segment text keeps the pre-existing
/// early-out behavior exactly.
/// </para>
/// </summary>
public class ChatTranscriptRendererTests
{
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>(ChatTranscriptRenderer.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>(ChatTranscriptRenderer.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>(ChatTranscriptRenderer.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>(ChatTranscriptRenderer.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>(ChatTranscriptRenderer.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "first", "", "third" }, lines);
}
}

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));
}

View file

@ -60,6 +60,25 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> LoadInfos("chat_2100006f.json");
/// <summary>
/// Deserializes the committed <c>chat_floaty_2100005b.json</c> fixture
/// (retail's floating chat windows 1-4, LayoutDesc <c>0x2100005B</c> — CH6a/b
/// REJECT-review SHOULD-FIX 4) into a raw <see cref="ElementInfo"/> tree and
/// builds the <see cref="ImportedLayout"/> using a null-returning sprite
/// resolver and no dat font — sufficient for conformance checks on resolved
/// widget types.
/// </summary>
public static ImportedLayout LoadFloatyChat()
=> LayoutImporter.Build(LoadFloatyChatInfos(), _ => (0u, 0, 0), null);
/// <summary>
/// Deserializes the committed <c>chat_floaty_2100005b.json</c> fixture into
/// a raw <see cref="ElementInfo"/> tree WITHOUT calling
/// <see cref="LayoutImporter.Build"/>.
/// </summary>
public static AcDream.App.UI.Layout.ElementInfo LoadFloatyChatInfos()
=> LoadInfos("chat_floaty_2100005b.json");
/// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary>
public static ImportedLayout LoadRadar()
=> LayoutImporter.Build(LoadRadarInfos(), _ => (0u, 0, 0), null);

View file

@ -0,0 +1,120 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Dat-free conformance tests for the committed <c>chat_floaty_2100005b.json</c>
/// golden fixture — retail's ACTUAL floating chat window (LayoutDesc
/// <c>0x2100005B</c>, window root <c>0x100004F7</c>, authored 250x108; CH6a/b
/// REJECT-review SHOULD-FIX 4,
/// <c>docs/research/2026-08-10-ch6ab-review-findings.md</c>). Pins the resolved
/// widget types <see cref="FloatingChatWindowController"/> assumes at
/// <c>Bind</c> time — before this fixture existed those assumptions were
/// untested against real dat data.
/// </summary>
public class FloatingChatLayoutConformanceTests
{
private static ElementInfo? Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (var c in n.Children)
{
var f = Find(c, id);
if (f is not null) return f;
}
return null;
}
[Fact]
public void FloatyFixture_ResolvesKnownElements()
{
var root = FixtureLoader.LoadFloatyChatInfos();
Assert.NotNull(Find(root, 0x10000011u)); // transcript
Assert.NotNull(Find(root, 0x10000016u)); // input
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
Assert.NotNull(Find(root, 0x10000019u)); // send button
Assert.NotNull(Find(root, 0x100004D9u)); // title bar
Assert.NotNull(Find(root, 0x1000052Au)); // close button
Assert.NotNull(Find(root, 0x10000529u)); // title-bar drag handle
// No talk-focus menu, no max/min button, no 1-4 indicators — a floaty
// window has none of these (research doc §2.2).
Assert.Null(Find(root, 0x10000014u));
Assert.Null(Find(root, 0x1000046Fu));
Assert.Null(Find(root, 0x10000522u));
}
[Fact]
public void FloatyFixture_ResolvedTypes_MatchRetailRegistry()
{
// CH6a/b REJECT-review SHOULD-FIX 4's three flagged untested
// assumptions: the chat entry field, the title bar, and the close
// button. All three are confirmed correct against the real fixture.
var root = FixtureLoader.LoadFloatyChatInfos();
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input) + Editable 0x16
Assert.True(Find(root, 0x10000016u)!.TryGetEffectiveBool(0x16u, out bool editable) && editable);
Assert.Equal(12u, Find(root, 0x100004D9u)!.Type); // Text (title bar) — no Editable
Assert.False(Find(root, 0x100004D9u)!.TryGetEffectiveBool(0x16u, out bool titleEditable) && titleEditable);
Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send)
Assert.Equal(1u, Find(root, 0x1000052Au)!.Type); // Button (Close)
Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar
Assert.Equal(2u, Find(root, 0x10000529u)!.Type); // Dragbar (title-bar move handle)
}
[Fact]
public void MountedFloatyWindow_ResolvesToTheExpectedWidgetTypes()
{
// The resolved WIDGET types (post-DatWidgetFactory), not just the raw
// dat Type numbers above — this is what FloatingChatWindowController.Bind
// actually casts against.
var layout = FixtureLoader.LoadFloatyChat();
Assert.IsType<UiField>(layout.FindElement(0x10000016u)); // Editable 0x16 -> UiField
Assert.IsType<UiText>(layout.FindElement(0x100004D9u)); // no Editable -> UiText
Assert.IsType<UiButton>(layout.FindElement(0x10000019u)); // Send
Assert.IsType<UiButton>(layout.FindElement(0x1000052Au)); // Close
Assert.IsType<UiScrollbar>(layout.FindElement(0x10000012u));
}
// ── Border/corner grips — CH6a/b SHOULD-FIX 4's "also determines whether
// floaties resize from real grips" ────────────────────────────────────────
[Theory]
[InlineData(0x100004FCu)] // TL corner
[InlineData(0x1000000Fu)] // top edge
[InlineData(0x100004FEu)] // TR corner
[InlineData(0x100004D2u)] // left edge
[InlineData(0x10000501u)] // BL corner
[InlineData(0x100004D4u)] // bottom edge
[InlineData(0x10000503u)] // BR corner
[InlineData(0x100004D3u)] // right edge
public void FloatyFixture_EveryBorderElement_IsALiveResizeGrip(uint elementId)
{
// Unlike the main window (whose plain top EDGE strip is a Type-2
// Dragbar move handle, not a grip — the main window has no title bar
// so the top strip doubles as the move handle), the floaty window's
// move handle is its OWN title-bar group (0x10000529, Type 2) — so
// ALL EIGHT border/corner elements here are genuine Type-9 Resizebar
// grips. A floaty window resizes from every edge and every corner.
var root = FixtureLoader.LoadFloatyChatInfos();
Assert.Equal(9u, Find(root, elementId)!.Type);
}
[Theory]
[InlineData(0x100004FCu)]
[InlineData(0x1000000Fu)]
[InlineData(0x100004FEu)]
[InlineData(0x100004D2u)]
[InlineData(0x10000501u)]
[InlineData(0x100004D4u)]
[InlineData(0x10000503u)]
[InlineData(0x100004D3u)]
public void MountedFloatyWindow_EveryLiveGrip_ResolvesNonZeroSprite(uint elementId)
{
// BLOCKER 1's regression guard extended to the floaty layout: every
// one of the eight live grips must carry real authored border/corner
// media, not draw nothing.
var layout = FixtureLoader.LoadFloatyChat();
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
Assert.NotEqual(0u, grip.SpriteFile);
}
}

View file

@ -17,6 +17,7 @@ public sealed class RetailLayoutFixtureGenerator
private static readonly (uint Id, string FileName)[] Layouts =
{
(ChatWindowController.LayoutId, "chat_2100006f.json"),
(FloatingChatWindowController.LayoutId, "chat_floaty_2100005b.json"),
(0x21000016u, "toolbar_21000016.json"),
(0x2100001Du, "link_status_2100001D.json"),
(0x21000020u, "vitae_21000020.json"),

File diff suppressed because it is too large Load diff

View file

@ -90,6 +90,34 @@ public class UiButtonTests
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void SuppressSelfToggle_PressReleaseDoesNotFlipSelected()
{
// CH6a/b REJECT-review SHOULD-FIX 3: the chat-window 1-4 indicators
// (0x10000522-0x10000525) carry DAT property 0x0B (ToggleBehavior) =
// true — same shape as ToggleRelease_SelectsHighlightState above —
// but retail's own click dispatch has no case for their element ids
// (gmMainChatUI::ListenToElementMessage @0x004CDA80), so a click must
// NOT flip their Selected mirror. Only SetIndicatorOpen (an external
// writer) may change it.
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.SuppressSelfToggle = true;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.False(b.Selected);
Assert.Equal("Normal", b.ActiveState);
// The external mirror path still works — SuppressSelfToggle only
// blocks the self-click, not a producer's own write.
b.Selected = true;
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void DisabledProperty_SelectsGhostedAndSuppressesClick()
{

View file

@ -116,7 +116,8 @@ public sealed class ChatWindowStateTests
{
var state = new ChatWindowState();
// Addressed to window 2, evaluated from window 1's perspective: not a
// broadcast (targetWindowId != 0) and not addressed to window 1.
// broadcast (targetWindowId is a real window id, not
// BroadcastTargetWindow) and not addressed to window 1.
Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 2u, logTextType: 0x02u));
}
@ -124,38 +125,65 @@ public sealed class ChatWindowStateTests
public void ShouldDisplay_Broadcast_FilterHit_Shows()
{
var state = new ChatWindowState();
Assert.True(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x02u)); // Speech
Assert.True(state.ShouldDisplay(
windowId: 1, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x02u)); // Speech
}
[Fact]
public void ShouldDisplay_Broadcast_FilterMiss_DoesNotShow()
{
var state = new ChatWindowState();
Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x0Au)); // Social
Assert.False(state.ShouldDisplay(
windowId: 1, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x0Au)); // Social
}
[Fact]
public void ShouldDisplay_MainWindow_ShowsEveryBroadcastRegardlessOfSeededFilter()
public void ShouldDisplay_MainWindow_BroadcastRespectsItsOwnFilter()
{
// CH6a/b REJECT-review BLOCKER (SHOULD-FIX 2): before the fix, window
// 0's explicit-addressing branch (targetWindowId == windowId) used the
// SAME literal 0 as the broadcast sentinel, so it short-circuited true
// for EVERY broadcast line regardless of window 0's own filter. Now
// that BroadcastTargetWindow is a distinct sentinel, the main
// window's filter (0xFBFFFFFF — excludes 0x1A, Society opt-in) is
// genuinely consulted for a broadcast line, exactly like every other
// window.
var state = new ChatWindowState();
Assert.False(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x1Au)); // excluded
Assert.False(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x20u)); // Society, opt-in
Assert.True(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x02u)); // Speech
}
[Fact]
public void ShouldDisplay_MainWindow_ExplicitlyAddressed_AlwaysShows()
{
// Window id 0 (main) is still a valid EXPLICIT address, distinct from
// BroadcastTargetWindow — this is what keeps a future
// targetWindowId == MainWindowId (AP-180's per-window echo)
// expressible: explicit addressing to main always wins, even for a
// type main's own filter would otherwise reject.
var state = new ChatWindowState();
// Window 0's own filter is 0xFBFFFFFF (excludes 0x1A) but that filter
// is never actually consulted for a broadcast message: targetWindowId
// (0) == windowId (0) is already true via the first branch.
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x1Au));
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x21u));
}
// ── SetFilter / SetOpen / Toggle ─────────────────────────────────────────
[Fact]
public void SetFilter_MainWindow_IsANoOp()
public void SetFilter_MainWindow_Persists()
{
// CH6a/b REJECT-review SHOULD-FIX 2: dropped the old no-op — the main
// window's filter is now genuinely settable, matching retail's own
// options-page-driven main-window filter.
var state = new ChatWindowState();
ulong before = state.GetFilter(0);
state.SetFilter(0, 0u);
state.SetFilter(0, 0x1u);
Assert.Equal(before, state.GetFilter(0));
Assert.Equal(0x1u, state.GetFilter(0));
Assert.True(state.TypeIsActive(0, 0x00u));
Assert.False(state.TypeIsActive(0, 0x02u));
}
[Fact]