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>
875 lines
39 KiB
C#
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 => (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);
|
|
}
|
|
}
|