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:
parent
56b84deeab
commit
1aa7709988
23 changed files with 6284 additions and 248 deletions
|
|
@ -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)!;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
5474
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_floaty_2100005b.json
Normal file
5474
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_floaty_2100005b.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue