acdream/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
Erik 989f665214 feat(CT-GF1): port retail ancestor-clip to the retained UI tree
Fixes the CT7 gate finding: on the Titles tab, the authored divider
0x10000530 escapes the Character window above its top edge at the
CT6-correct 372px mounted default (computed Y ~ -178, matching the
owner's screenshot). Retail clips child rendering to the intersected
ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the
element's screen Box2D plus a SmartArray<Box2D> of inherited clip
rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82),
and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect
only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw
walk rendered children unclipped by default, so any authored element
relying on clipping -- this divider, and the chat input row at small
window sizes (the owner's earlier "text input sticks out on resize"
report) -- became a visible artifact.

Mechanism (element-level, reusing the existing clip-rect-stack
infrastructure in UiRenderContext.PushClip/PopClip):

- UiElement.ClipsChildren now defaults to TRUE for every element
  (was an opt-in used only by UiScrollablePanel/UiItemList). Each
  element's children draw AND hit-test clipped to the intersection
  of its own rect with the inherited ancestor clip; an element
  positioned outside its parent's box silently disappears, matching
  retail's non-empty-intersection gate. HitTest's existing early
  bounds check already implemented this shape for ClipsChildren=true
  elements -- flipping the default aligns hit-testing with the new
  draw-clip default in one property, per the plan's own point 4.

- UiElement.ExpandsClipForPopup (default false) is the one opt-out:
  retail spawns a menu popup as a SEPARATE top-level region
  (UIElement_Menu::MakePopup), clipped only by the screen; acdream
  draws UiMenu's popup inline from the owning button in a second
  traversal (OnDrawOverlay, pre-existing -- its own doc comment
  already says "regardless of this element's position in the tree").
  DrawOverlays now resets the accumulated clip to unbounded
  (UiRenderContext.PushClipUnbounded, sharing the existing clip
  stack) for exactly the OnDrawOverlay call of an opted-in element.
  UiMenu overrides ExpandsClipForPopup=>true, paired with
  ClipsChildren=>false so its own out-of-bounds OnHitTest union (the
  popup occupies ly<0 or ly>=Height depending on open direction)
  stays reachable through the same early-bounds gate that now
  defaults on for every other element.

Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow
OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the
ONLY OnDrawOverlay override client-wide, so it is the only element
needing ExpandsClipForPopup. RetailTooltipPresenter's popup and
UiRoot's drag ghost both already escape structurally -- the tooltip
mounts as an ordinary UiRoot CHILD (sibling of every window, clipped
only by the canvas), and the drag ghost is drawn directly by UiRoot
outside the tree entirely -- neither needed a code change, both are
covered by new tests proving the invariant. UiResizeGrip and
UiNineSlicePanel's frame/bevel draw entirely within their own
[0,Width]x[0,Height] (grip flush at the window's own edges; the
window's own Width/Height already represents the OUTER frame
including its 5px bevel, so its ClipsChildren push already covers
the frame's own content children correctly -- no negative insets
found). UiScrollbar draws entirely within its own bounds (confirmed
by reading OnDraw).

Hit-testing: aligned with the new default via the single
ClipsChildren flip (see above); UiMenu's own opt-out override keeps
its popup hit-test union working, verified by the full UiMenuTests
suite staying green.

Divergence register: AD-113 filed for the ExpandsClipForPopup
adaptation (inline popup drawing vs retail's separate top-level
region).

Fixed two pre-existing test-harness gaps the new default surfaced
(both real bugs in the harnesses, not workarounds around the fix):
- ChatLayoutConformanceTests' bottom-right-grip grow test read a
  STALE (pre-shrink) grip screen position because it drove two resize
  gestures back-to-back with no intervening Draw pass -- the only
  place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame
  draws every tick, so production never hits this; the test now
  inserts a real DrawSelfAndChildren pass between the two gestures,
  matching a real frame boundary.
- VendorUiControllerTests' hand-built Items/Buying/Selling page
  containers were left at their bare 0x0 UiElement default (the
  harness never runs a real DAT-driven layout pass) -- harmless
  before ancestor clipping existed, but now hides every child of an
  unsized page. Sized them to the window's own content root, matching
  production's shape (a tab page fills the window body).

Tests (all confirmed as genuine regression pins by temporarily
reverting the relevant default/override and observing the exact
predicted failure, then reverting back):
- CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe
  CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate
  repro against the real character_2100002E.json fixture through
  RetailWindowFrame.Mount at the CT6 372px default -- the divider
  renders nothing (computed Y ~ -173, matching the owner's ~-178);
  growing the window to 600px renders it at its authored spot.
- ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad
  RendersOutsideTheWindowRect: no input-row quad escapes the chat
  window rect at three small sizes (300x100 sanity control,
  120x40/80x30 genuine pre-fix overflow -- verified failing without
  the fix at Y=38/55 past the window edge).
- UiAncestorClipTests (new file): the core mechanism against plain
  synthetic elements (culled-outside / clipped-at-the-edge / hit-test
  parity), UiMenu's popup escaping a tiny owning window (and staying
  clipped while closed), and the tooltip's structural immunity
  (mounts as a UiRoot sibling, unaffected by a tiny ancestor window).

Verification: full solution build green; hermetic suite green
(--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&
Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&
Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure",
14,000+ tests across every project); InstalledDat lane green
(ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure,
205+34+3+172 tests). CharacterTitlesControllerTests' existing suite
and the full UiMenuTests/UiScrollbarTests suites are unaffected.

src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by
this change and deliberately left out of this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 06:29:15 +02:00

875 lines
39 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
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>
/// Dat-free conformance tests for the committed chat_2100006f.json golden fixture —
/// retail's ACTUAL main chat window (LayoutDesc <c>0x2100006F</c>, window root
/// <c>0x10000600</c>, authored 410x100; Campaign CH slice CH6a). Verifies that
/// LayoutImporter.ImportInfos correctly resolves the BaseElement / BaseLayoutId
/// inheritance chain.
/// </summary>
public class ChatLayoutConformanceTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
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 ChatFixture_ResolvesKnownElements()
{
var root = FixtureLoader.LoadChatInfos();
Assert.NotNull(Find(root, 0x10000011u)); // transcript
Assert.NotNull(Find(root, 0x10000016u)); // input
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
Assert.NotNull(Find(root, 0x10000014u)); // channel menu
Assert.NotNull(Find(root, 0x10000019u)); // send button
Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
}
[Fact]
public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
{
var root = FixtureLoader.LoadChatInfos();
Assert.Equal(6u, Find(root, 0x10000014u)!.Type); // Menu
Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar
Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send)
Assert.Equal(1u, Find(root, 0x1000046Fu)!.Type); // Button (Max/Min)
Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript)
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
}
[Theory]
[InlineData(0x10000522u)]
[InlineData(0x10000523u)]
[InlineData(0x10000524u)]
[InlineData(0x10000525u)]
public void ChatFixture_ChatWindowIndicatorButtons_ImportFromTheAuthoredTree(uint indicatorId)
{
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80 writes these
// four state-mirror buttons for floating chat windows 1-4. CH6a imports
// them generically (visible, inert) and leaves wiring their live/pressed
// state to CH6b's floating-window slice.
var root = FixtureLoader.LoadChatInfos();
Assert.NotNull(Find(root, indicatorId));
}
[Theory]
[InlineData(0x10000522u)]
[InlineData(0x10000523u)]
[InlineData(0x10000524u)]
[InlineData(0x10000525u)]
public void MountedChatWindow_IndicatorButtons_BindAloneLeavesClickUnwiredButSelfFlipSuppressed(
uint indicatorId)
{
// Bind() alone (no BindIndicatorClicks call) leaves OnClick unwired —
// the click half is a separate opt-in step RetailUiRuntime performs
// once its own ToggleFloatingChatWindow method is available (round 4,
// 2026-08-10 — see ChatWindowController.SetIndicatorOpen's doc for the
// full retail mechanism reconciliation). This test only proves the
// Bind-alone state; MountedChatWindow_IndicatorButtons_ClickTogglesWindow
// below exercises the full click round trip through BindIndicatorClicks.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement? indicator = layout.FindElement(indicatorId);
Assert.NotNull(indicator);
Assert.True(indicator!.Visible);
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 "self-flip suppressed" — 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, and it stays true even after round 4 wires a
// real click handler — see SetIndicatorOpen's doc for why the blind
// self-flip must stay suppressed regardless.
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]
[InlineData(0x10000522u, 1)]
[InlineData(0x10000523u, 2)]
[InlineData(0x10000524u, 3)]
[InlineData(0x10000525u, 4)]
public void MountedChatWindow_IndicatorButtons_ClickTogglesWindow_ThroughBindIndicatorClicks(
uint indicatorId, int expectedWindowId)
{
// Round 4 (2026-08-10): clicking an indicator now toggles its floating
// chat window through the SAME chokepoint the Alt+1..4 keybinds use
// (user-directed retail behavior — see SetIndicatorOpen's doc for the
// full mechanism reconciliation: the generic UIElement_Button
// action-dispatch system is real and armed on the button side, but
// the shipped floating-window fixture authors no matching listener
// registration, so this wiring cannot be proven purely from the DAT).
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
var toggled = new List<int>();
controller!.BindIndicatorClicks(windowId =>
{
toggled.Add(windowId);
return true;
});
UiElement? indicator = layout.FindElement(indicatorId);
var button = Assert.IsType<UiButton>(indicator);
Assert.NotNull(button.OnClick);
button.OnEvent(new UiEvent(0, button, UiEventType.MouseDown, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.MouseUp, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.Click, Data1: 2, Data2: 2));
Assert.Equal(new[] { expectedWindowId }, toggled);
}
[Fact]
public void MountedChatWindow_IndicatorButtons_ClickRoundTrip_KeepsMirrorConsistent()
{
// The full round trip: click -> BindIndicatorClicks' delegate calls the
// real toggle -> the caller (standing in for RetailUiRuntime's
// WindowVisibilityChanged plumbing) calls SetIndicatorOpen with the
// window's NEW state -> Selected matches that new state, not a blind
// self-flip. SuppressSelfToggle staying true is what makes this
// authoritative-writer invariant hold even though the button now has
// a real OnClick.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
bool windowOpen = false;
controller!.BindIndicatorClicks(windowId =>
{
windowOpen = !windowOpen;
controller.SetIndicatorOpen(windowId, windowOpen);
return true;
});
var button = Assert.IsType<UiButton>(layout.FindElement(0x10000522u));
Assert.False(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));
button.OnEvent(new UiEvent(0, button, UiEventType.Click, Data1: 2, Data2: 2));
Assert.True(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));
button.OnEvent(new UiEvent(0, button, UiEventType.Click, Data1: 2, Data2: 2));
Assert.False(button.Selected);
}
[Theory]
[InlineData(0x10000693u)]
[InlineData(0x10000694u)]
[InlineData(0x10000695u)]
[InlineData(0x10000696u)]
[InlineData(0x10000697u)]
[InlineData(0x10000698u)]
[InlineData(0x10000699u)]
[InlineData(0x1000069Au)]
public void BoundChatWindow_LockedTwinBorderArt_SeedsUnlockedUntilRegistration(uint lockedTwinId)
{
// Bind seeds the unlocked skin for standalone layouts. Once registered,
// RetailWindowLockPresentationController applies the canonical UiLocked
// state; its real-fixture swap is covered by that controller's tests.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement? twin = layout.FindElement(lockedTwinId);
Assert.NotNull(twin);
Assert.False(twin!.Visible);
}
[Fact]
public void ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
var input = Assert.IsType<UiField>(layout.FindElement(0x10000016u));
Assert.True(transcript.Selectable);
Assert.True(input.Selectable);
Assert.True(input.OneLine);
Assert.Equal(0x10000016u, input.DatElementId);
}
/// <summary>
/// Campaign CH round 4 (<c>docs/research/2026-08-10-retail-ui-text-style.md</c>
/// §2.5/§2.6): the chat transcript's base style (<c>0x10000372</c> in layout
/// <c>0x2100003F</c>) authors NO property 0x21 (Outline) anywhere in its
/// inheritance chain — regenerated straight from the real installed DAT
/// (<c>RetailLayoutFixtureGenerator</c>), proving the property-0x21/0x22
/// importer (<see cref="ElementReader.ApplyCanonicalLegacyProjection"/>)
/// resolves this correctly end to end, not merely by a missing-field JSON
/// default.
/// </summary>
[Fact]
public void ChatFixture_TranscriptElementInfo_CarriesNoOutline()
{
var root = FixtureLoader.LoadChatInfos();
var transcript = Find(root, 0x10000011u)!;
Assert.False(transcript.Outline);
Assert.Null(transcript.OutlineColor);
}
/// <summary>Same fact at the built-widget level — <c>DatWidgetFactory.BuildText</c>
/// must not turn a false <c>ElementInfo.Outline</c> into a true <c>UiText.Outline</c>.</summary>
[Fact]
public void ChatFixture_TranscriptWidget_OutlineIsOff()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
Assert.False(transcript.Outline);
}
/// <summary>
/// The transcript's default fill resolves to the authored
/// <c>ARGB(255,204,204,204)</c> (style <c>0x10000372</c> property 0x1B) —
/// Fix 5's target. Pins the value at the built-widget level, on the SAME
/// regenerated fixture the outline test above uses.
/// </summary>
[Fact]
public void ChatFixture_TranscriptWidget_DefaultColorIsAuthoredOffWhite()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
var expected = new System.Numerics.Vector4(204f / 255f, 204f / 255f, 204f / 255f, 1f);
Assert.Equal(expected, transcript.DefaultColor);
}
[Fact]
public void ChatFixture_ScrollbarImportsInheritedMediaRoles()
{
var layout = FixtureLoader.LoadChat();
var scrollbar = Assert.IsType<UiScrollbar>(layout.FindElement(0x10000012u));
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite);
Assert.Equal(0x06004C63u, scrollbar.ThumbSprite);
Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite);
// Retail seating (2026-08-24): UpdateScrollingArea @0x00470AA0 puts
// the INCREMENT designee (0x10000072, UP-arrow art 0x06004C6C) on the
// top button and the DECREMENT designee (0x10000071, DOWN-arrow
// 0x06004C69) on the bottom, ignoring authored Y.
Assert.Equal(0x06004C6Cu, scrollbar.UpSprite);
Assert.Equal(0x06004C69u, scrollbar.DownSprite);
Assert.Equal(0x06004C64u, scrollbar.ThumbRolloverSprite);
Assert.Equal(0x06004C65u, scrollbar.ThumbPressedSprite);
}
[Fact]
public void ChatFixture_WindowRootCarriesRetailHeightConstraints()
{
var root = FixtureLoader.LoadChatInfos();
var window = Find(root, 0x10000600u)!;
Assert.True(window.TryGetEffectiveInteger(0x3Eu, out int minHeight));
Assert.True(window.TryGetEffectiveInteger(0x3Cu, out int maxHeight));
Assert.Equal(100, minHeight);
Assert.Equal(2000, maxHeight);
}
[Fact]
public void ChatFixture_WindowRootCarriesRetailWidthConstraints()
{
var root = FixtureLoader.LoadChatInfos();
var window = Find(root, 0x10000600u)!;
Assert.True(window.TryGetEffectiveInteger(0x3Fu, out int minWidth));
Assert.True(window.TryGetEffectiveInteger(0x3Du, out int maxWidth));
Assert.Equal(300, minWidth);
Assert.Equal(2000, maxWidth);
}
[Fact]
public void ChatFixture_RootIsAuthored410x100_NoCropNeeded()
{
// Campaign CH slice CH6a: 0x2100006F's window root is already the exact
// mounted extent — the 490px content-width crop that the wrong
// 0x21000006 import needed no longer applies to anything.
var root = FixtureLoader.LoadChatInfos();
Assert.Equal(410f, root.Width);
Assert.Equal(100f, root.Height);
}
[Fact]
public void ChatFixture_DockedRoot_ReflowsContentAcrossMountedWidth()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
Assert.NotNull(controller);
// No crop-then-rebase dance needed: the root's OWN authored extent
// (410x100) already matches the design width children's imported
// LayoutPolicy captured, so growing it directly exercises the same
// raw-edge reflow the crop workaround needed a rebase step for.
var root = controller!.Root;
Assert.Equal(410f, root.Width);
root.Width = 615;
// Both panels are authored with a 5px margin to BOTH the left and right
// window edges (X=5, right-edge width = 410-(5+400) = 5) — a stretch
// grows the panel by the delta MINUS both margins: 615 - 5 - 5 = 605.
var transcriptPanel = layout.FindElement(0x10000010u)!;
var inputBar = layout.FindElement(0x10000013u)!;
transcriptPanel.ApplyAnchor(root.Width, root.Height);
inputBar.ApplyAnchor(root.Width, root.Height);
Assert.Equal(605f, transcriptPanel.Width);
Assert.Equal(605f, inputBar.Width);
}
/// <summary>
/// 2026-08-24 rewrite: this test used to pin the content-widening
/// reflow (button grown past 46px for the invented long captions).
/// Retail never resizes the talk button — the authored 46x17 stands
/// through selection changes AND layout passes, and the input keeps its
/// authored start (owner-reported "button too big" delta).
/// </summary>
[Fact]
public void ChatFixture_ChannelButton_KeepsAuthoredWidthThroughLayoutPass()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
Assert.NotNull(controller);
float inputLeft = controller!.Input.Left;
controller.Menu.OnSelect!.Invoke(ChatChannelKind.General);
controller.Menu.ApplyAnchor(controller.Menu.Parent!.Width, controller.Menu.Parent.Height);
Assert.Equal(46f, controller.Menu.Width);
Assert.Equal(inputLeft, controller.Input.Left);
}
[Fact]
public void ChatMaximize_ResizesOuterFrameByHalfParent_AndRestores()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
var root = new UiRoot { Width = 800, Height = 600 };
// Campaign CH slice CH6a: 0x2100006F's own border art IS the window
// chrome — Chrome=Imported, no content-width crop, no MinWidth override
// (the DAT's own 0x3D/0x3F=2000/300 govern). Frame == content, so the
// maximize math below operates directly on the authored 410x100 extent
// with the DAT's real minH=100/maxH=2000 (not the wrong layout's 360).
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.Imported,
Left = 12f,
Top = 390f,
DatConstraintSource = controller.DatWindowInfo,
});
controller.AttachWindow(handle);
var maxMin = Assert.IsType<UiButton>(layout.FindElement(0x1000046Fu));
// frame.Height=100, parentHeight=600, expansion=300 → targetHeight =
// clamp(min(400,600),100,2000) = 400. growUp because Top(390)+400>600.
// targetTop = max(0, 390-(400-100)) = 90. Lower edge fixed at 490.
maxMin.OnClick!();
Assert.True(controller.IsMaximized);
Assert.Equal(90f, handle.Top);
Assert.Equal(400f, handle.Height);
Assert.Equal(490f, handle.Top + handle.Height); // lower edge stays fixed while growing upward
Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId);
maxMin.OnClick!();
Assert.False(controller.IsMaximized);
Assert.Equal(390f, handle.Top);
Assert.Equal(100f, handle.Height);
Assert.Equal(RetailUiStateIds.Minimized, maxMin.ActiveRetailStateId);
}
[Theory]
[InlineData(0x1000069Bu, UiResizeGrip.Border.UpperLeft)]
[InlineData(0x1000069Du, UiResizeGrip.Border.UpperRight)]
[InlineData(0x1000069Fu, UiResizeGrip.Border.LowerLeft)]
[InlineData(0x100006A1u, UiResizeGrip.Border.LowerRight)]
public void MountedChatWindow_CornerGrip_ImportsWithCorrectBorderLocation(
uint elementId, UiResizeGrip.Border expected)
{
// Campaign CH slice CH6a, user-gate round 2 item 6: pins that the 4
// corner grips from 0x2100006F import with the RIGHT BorderLocation —
// the prerequisite for the reported "no diagonal cursor" / "can't grow
// in Y from the bottom-right corner" symptoms to be fixed at all.
var layout = FixtureLoader.LoadChat();
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
Assert.Equal(expected, grip.BorderLocation);
}
[Fact]
public void MountedChatWindow_TopStrip_IsAMoveHandleNotAGrip()
{
// 0x1000069C (the plain top edge, between the two top corners) is a
// Type-2 Dragbar in the real DAT, not a Resizebar — retail's main chat
// window has no title bar, so the top strip moves the window while its
// two corners (above) resize it.
var layout = FixtureLoader.LoadChat();
var topStrip = layout.FindElement(0x1000069Cu);
Assert.NotNull(topStrip);
Assert.IsNotType<UiResizeGrip>(topStrip);
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);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
[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_ActuallyEmitsASpriteDraw_NotJustResolvesSpriteFile(uint elementId)
{
// CH6a/b re-review rider: MountedChatWindow_LiveGrip_ResolvesNonZeroSprite
// (above) only proves the ElementInfo carries a non-zero DirectState
// sprite id — it never calls OnDraw, so a media-less regression (a grip
// constructed WITHOUT its resolve delegate, or one whose resolve always
// returns a zero handle/dimension — exactly the CH6a/b BLOCKER 1 bug)
// would still pass it. This drives the grip through a REAL
// UiRenderContext over a REAL TextRenderer (backed by the in-memory
// RecordingGpuDevice test double, no live GPU) and asserts the draw
// call chain actually queued sprite geometry for that grip's texture.
var infos = FixtureLoader.LoadChatInfos();
// Distinct from FixtureLoader's own null-returning resolver: echoes the
// sprite id as a nonzero fake texture handle with nonzero dimensions,
// so UiResizeGrip.OnDraw's `tex == 0 || tw == 0 || th == 0` guard does
// not short-circuit before reaching ctx.DrawSprite.
var layout = LayoutImporter.Build(infos, id => (id, 8, 8), null);
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
Assert.NotEqual(0u, grip.SpriteFile);
Assert.True(grip.Visible);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
grip.DrawSelfAndChildren(ctx);
var seg = Assert.Single(
renderer.DebugSpriteSegments,
s => s.Texture == grip.SpriteFile);
Assert.True(seg.VertexCount > 0);
}
[Fact]
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
{
// The literal user-gate round 2 report: "the window cannot grow in the
// Y axis when dragging from the bottom-right corner." Exercises the
// FULL production path (ChatWindowController.Bind + RetailWindowFrame.Mount
// with the real Chrome=Imported options and the real DAT min/max
// constraints from 0x2100006F, minH=100/maxH=2000/minW=300/maxW=2000)
// rather than just the pure ResizeRect math.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
var root = new UiRoot { Width = 1600, Height = 1200 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 440f,
DatConstraintSource = controller.DatWindowInfo,
});
controller.AttachWindow(handle);
var brGrip = Assert.IsType<UiResizeGrip>(layout.FindElement(0x100006A1u));
Assert.Equal(UiResizeGrip.Border.LowerRight, brGrip.BorderLocation);
var gs = brGrip.ScreenPosition;
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
// Shrink first (this direction was never in question per the report).
// Height is already AT the DAT's authored minimum (minH=100 == the
// authored 100px height), so it clamps rather than shrinking further —
// width has headroom (minW=300 < 410) and does shrink.
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX - 20, pressY - 20);
root.OnMouseUp(UiMouseButton.Left, pressX - 20, pressY - 20);
Assert.Equal(390f, handle.Width);
Assert.Equal(100f, handle.Height);
// CT-GF1: a real frame draws every tick, which is what reflows an
// anchored child's Left/Top against its parent's CURRENT size
// (UiElement.ApplyAnchor runs only from DrawSelfAndChildren) — so by
// the time a player's next click lands, the grip is already
// repositioned for the just-shrunk window. This test drives the resize
// directly without an intervening render, so without this draw pass the
// grip's ScreenPosition below stays at its PRE-shrink (now stale, wider)
// anchor and lands outside the shrunk window's own bounds — UiElement's
// new default ancestor clip (ClipsChildren) then refuses the press
// before it ever reaches the grip. Matches a real frame boundary, not a
// workaround for the clip.
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(root.Width, root.Height));
var drawCtx = new UiRenderContext(renderer, new Vector2(root.Width, root.Height));
root.DrawSelfAndChildren(drawCtx);
// Now grow from the shrunken state — this is the reported-broken direction.
var brGripAfterShrink = Assert.IsType<UiResizeGrip>(layout.FindElement(0x100006A1u));
var gs2 = brGripAfterShrink.ScreenPosition;
int pressX2 = (int)(gs2.X + 2), pressY2 = (int)(gs2.Y + 2);
root.OnMouseDown(UiMouseButton.Left, pressX2, pressY2);
root.OnMouseMove(pressX2 + 70, pressY2 + 70);
root.OnMouseUp(UiMouseButton.Left, pressX2 + 70, pressY2 + 70);
Assert.Equal(460f, handle.Width);
Assert.Equal(170f, handle.Height);
}
[Fact]
public void MountedChatScrollbar_ButtonsAndThumbDragDriveTranscriptModel()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 440f,
DatConstraintSource = controller.DatWindowInfo,
});
controller.AttachWindow(handle);
UiScrollbar bar = controller.Scrollbar;
UiScrollable scroll = controller.Transcript.Scroll;
scroll.LineHeight = 16;
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(350, scroll.ScrollY);
var screen = bar.ScreenPosition;
int centerX = (int)(screen.X + bar.Width / 2f);
// Top/decrement button.
int upY = (int)(screen.Y + 8f);
root.OnMouseDown(UiMouseButton.Left, centerX, upY);
root.OnMouseUp(UiMouseButton.Left, centerX, upY);
Assert.Equal(334, scroll.ScrollY);
// Re-layout must retain the position chosen through the scrollbar.
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(334, scroll.ScrollY);
// Drag the thumb toward the top of the track.
float trackTop = 16f;
float trackLength = bar.Height - 32f;
var (thumbY, thumbHeight) = UiScrollbar.ThumbRect(scroll, trackTop, trackLength);
int dragStartY = (int)(screen.Y + thumbY + thumbHeight / 2f);
int dragEndY = (int)(screen.Y + trackTop + thumbHeight / 2f);
root.OnMouseDown(UiMouseButton.Left, centerX, dragStartY);
root.OnMouseMove(centerX, dragEndY);
root.OnMouseUp(UiMouseButton.Left, centerX, dragEndY);
Assert.True(scroll.ScrollY < 334);
int draggedPosition = scroll.ScrollY;
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(draggedPosition, scroll.ScrollY);
}
/// <summary>
/// 2026-08-24 owner report: resizing the chat window let the text input
/// stick out past the window edge. The authored edge modes (input row
/// 0x10000013 L1/R1 = stretch, field 0x10000016 L1/R1 = stretch, Send
/// 0x10000019 L2/R1 = right-docked, menu button 0x10000014 L1/R2 =
/// left-docked) must keep the whole input row inside the window at every
/// size, narrower AND wider than the authored 410.
/// </summary>
[Theory]
[InlineData(300f, 100f)]
[InlineData(600f, 160f)]
[InlineData(220f, 80f)]
public void ResizingTheWindow_KeepsTheInputRowInsideIt(float width, float height)
{
var infos = FixtureLoader.LoadChatInfos();
ImportedLayout layout = LayoutImporter.Build(infos, NoTex, null);
// Bind the REAL controller — production geometry overrides included.
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance,
new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement window = layout.FindElement(0x10000600u)!;
var root = new UiRoot { Width = 800f, Height = 600f };
root.AddChild(window);
ApplyLayoutPassLocal(window);
window.Width = width;
window.Height = height;
window.ResetAnchorCapture();
ApplyLayoutPassLocal(window);
ApplyLayoutPassLocal(window); // second frame — policies settle
UiElement inputBar = layout.FindElement(0x10000013u)!;
UiElement input = layout.FindElement(0x10000016u)!;
UiElement send = layout.FindElement(0x10000019u)!;
UiElement menuButton = layout.FindElement(0x10000014u)!;
Assert.True(inputBar.Left >= 0f && inputBar.Left + inputBar.Width <= width + 0.5f,
$"input bar [{inputBar.Left},{inputBar.Left + inputBar.Width}] escapes window width {width}");
float inputRight = inputBar.Left + input.Left + input.Width;
Assert.True(inputRight <= width + 0.5f,
$"input field right {inputRight} escapes window width {width}");
float sendRight = inputBar.Left + send.Left + send.Width;
Assert.True(sendRight <= width + 0.5f,
$"send right {sendRight} escapes window width {width}");
Assert.True(menuButton.Left >= 0f, "menu button escaped left");
// The field must stay BETWEEN the menu button and the send button.
Assert.True(input.Left >= menuButton.Left + menuButton.Width - 0.5f,
$"input {input.Left} overlaps menu button ending {menuButton.Left + menuButton.Width}");
Assert.True(input.Left + input.Width <= send.Left + 0.5f,
$"input ends {input.Left + input.Width} past send start {send.Left}");
}
/// <summary>
/// CT-GF1 regression pin — companion to
/// <see cref="ResizingTheWindow_KeepsTheInputRowInsideIt"/> above, which
/// only proves the input row's Left/Width GEOMETRY stays inside the
/// window: that test's <c>LayoutImporter.Build</c> call resolves every
/// sprite through <see cref="NoTex"/> (texture 0), and
/// <c>UiDatElement.OnDraw</c>'s own <c>tex == 0</c> guard means nothing
/// ever reaches a quad — exactly why the owner's "text input sticks out
/// on resize" report was previously unreproducible in a fixture. This
/// test resolves REAL non-zero textures (same <c>id =&gt; (id, 8, 8)</c>
/// pattern as <c>MountedChatWindow_LiveGrip_ActuallyEmitsASpriteDraw_
/// NotJustResolvesSpriteFile</c> above) and draws the whole mounted
/// window through a <see cref="RecordingGpuDevice"/> at small sizes, then
/// asserts every emitted quad's vertices stay inside the window's own
/// [0,width]x[0,height] rect. The mechanism CT-GF1 ports
/// (<c>UiElement.ClipsChildren</c>'s new client-wide default,
/// retail's <c>UIRegion::DrawHere @0x0069FA30</c> ancestor-clip
/// intersection) is what makes this true now — confirmed a real
/// regression pin (not vacuous) by temporarily reverting the default:
/// the 120x40/80x30 cases fail without the fix (a quad renders ~15-38px
/// past the window's bottom edge, the input row's authored ~72px extent
/// no longer fitting a window shrunk below the ~100px it was designed
/// for) and pass with it; 300x100 is the "still comfortably fits, sanity"
/// control case.
/// </summary>
[Theory]
[InlineData(300f, 100f)]
[InlineData(120f, 40f)]
[InlineData(80f, 30f)]
public void ResizingTheWindowSmall_NoInputRowQuadRendersOutsideTheWindowRect(float width, float height)
{
var infos = FixtureLoader.LoadChatInfos();
ImportedLayout layout = LayoutImporter.Build(infos, id => (id, 8, 8), null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance,
new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement window = layout.FindElement(0x10000600u)!;
var root = new UiRoot { Width = 800f, Height = 600f };
root.AddChild(window);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(root.Width, root.Height));
var ctx = new UiRenderContext(renderer, new Vector2(root.Width, root.Height));
window.DrawSelfAndChildren(ctx);
window.Width = width;
window.Height = height;
window.ResetAnchorCapture();
// Two frames — same "raw-edge LayoutPolicy needs a settle pass" reasoning
// as the geometry sibling test above.
renderer.Begin(new Vector2(root.Width, root.Height));
window.DrawSelfAndChildren(ctx);
renderer.Begin(new Vector2(root.Width, root.Height));
window.DrawSelfAndChildren(ctx);
float windowLeft = window.ScreenPosition.X;
float windowTop = window.ScreenPosition.Y;
const float Slop = 0.5f;
foreach (var seg in renderer.DebugSpriteSegmentVerts)
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vx = seg.Verts[i * 8];
float vy = seg.Verts[i * 8 + 1];
Assert.True(
vx >= windowLeft - Slop && vx <= windowLeft + width + Slop
&& vy >= windowTop - Slop && vy <= windowTop + height + Slop,
$"quad vertex ({vx},{vy}) escapes the {width}x{height} chat window rect " +
$"[{windowLeft},{windowTop}]-[{windowLeft + width},{windowTop + height}] " +
$"(texture {seg.Texture})");
}
}
}
private static void ApplyLayoutPassLocal(UiElement parent)
{
foreach (var child in parent.Children)
{
child.ApplyAnchor(parent.Width, parent.Height);
ApplyLayoutPassLocal(child);
}
}
/// <summary>
/// 2026-08-24 owner report ("chat button too big; caption should be
/// whiter — same for Send"): retail keeps the AUTHORED 46x17 talk
/// button (HandleSelection @0x004cd540 never resizes it — the authored
/// SHORT captions fit, which is why retail abbreviates), and both the
/// button caption child (0x10000015) and the Send button (0x10000019)
/// author PURE WHITE text with their own FontDid 0x40000002. The
/// previous content-widening reflow and invented warm-gold labels are
/// retired.
/// </summary>
[Fact]
public void TalkButtonAndSend_KeepAuthoredSizeFontAndWhiteCaptions()
{
var infos = FixtureLoader.LoadChatInfos();
ImportedLayout layout = LayoutImporter.Build(infos, NoTex, null);
var requestedFonts = new List<uint>();
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance,
new ChatWindowState(), null, null, NoTex,
resolveFont: did => { requestedFonts.Add(did); return null; });
Assert.NotNull(controller);
UiMenu menu = Assert.IsType<UiMenu>(layout.FindElement(0x10000014u));
// Authored 46x17 stands — no content widening.
Assert.Equal(46f, menu.Width);
menu.OnSelect!.Invoke(ChatChannelKind.General);
Assert.Equal(46f, menu.Width);
// Authored caption color: pure white; authored H=Center over the
// full 46x17 face (not the synthetic 20px left indent).
Assert.Equal(new System.Numerics.Vector4(1f, 1f, 1f, 1f), menu.TextColor);
Assert.True(menu.ButtonTextCentered);
var send = Assert.IsType<UiButton>(layout.FindElement(0x10000019u));
Assert.Equal(new System.Numerics.Vector4(1f, 1f, 1f, 1f), send.LabelColor);
// Both caption fonts were requested from the authored FontDid.
Assert.Contains(0x40000002u, requestedFonts);
}
}