acdream/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs
Erik 025108a8aa fix(CT-GF1): review fix round — literal DrawHere clip shape, empty-clip cull, popup input routing
Applies all 11 items from the Opus dual-lens review of 989f6652 (0
blockers, 7 SHOULD-FIX, 4 NOTE):

- S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right
  after PushAlpha and wraps OnDraw + the children walk +
  OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere
  @0x0069FA30 shape, which clips an element's OWN DrawSelf too, not
  just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph
  blits to its own clipped surface rect; UIRegion::DrawSelf
  @0x0069F1A0 blits per clip rect). Deleted the two now-redundant
  ad-hoc self-clips this supersedes: UiText.DrawText and
  UiField.DrawMultiLine both pushed their own (0,0,Width,Height) —
  exactly what the new ambient clip already provides one level up.
  Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox,
  an authored INNER sub-rect that can be smaller than and offset from
  the button's own full rect — a genuine narrower viewport, not a
  redundant duplicate.
- S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override
  — correct under the old opt-in-false default, inverted under the
  new default-true (an unconfigured list would stop clipping instead
  of clipping like everything else).
- S4: pinned the escaped-popup input path end to end. New
  UiAncestorClipTests test mounts a menu inside a short window on a
  real UiRoot, opens it, and proves a click in the escaped popup
  region reaches the menu through UiRoot.PopupHit (a plain top-down
  walk is proven to reject the same point first). UiRoot.WantsMouse
  now also checks PopupHit — it previously only checked Captured/
  HitTestTopDown, so a game action could fire underneath an open
  dropdown's escaped region. OnMouseDown/OnScroll already routed
  through PopupHit first (#374); unchanged.
- S5: strengthened the Titles-divider regression test's positive
  half. The old assertion only checked SOME quad's Y fell in a band —
  vacuously true given other same-band content. Now asserts the
  divider's exact rect (X and Y), then diffs against the same rect
  with the divider hidden (Visible=false) to prove the quad was
  actually attributable to it.
- S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/
  Options mounted through their real production Bind entry points
  with a non-zero sprite resolver, drawn via RecordingGpuDevice,
  asserting a per-window vertex floor (~40-45% of this session's
  observed baseline: Character 588, Chat 162, Vendor 54, Options 240)
  plus one key sprite id read LIVE off the bound controller/element
  (never hardcoded). Character's key sprite (RetailChromeSprites.
  TopEdge) specifically exercises OnDrawAfterChildren, the exact path
  S2's caution note flagged. Inventory/Paperdoll/social/map-house
  skipped — no single fixture-driven top-level Bind entry point.
- S6: added the CT-GF1 subsection to the campaign plan's ledger
  (989f6652 + this fix round; CT7 re-gate still owed).
- S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect
  (0,0,ScreenSize), not null — retail's own popup region is
  SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region
  bounded by the screen), not truly unbounded. AD-113 amended.
- N1: UiRoot overrides ClipsChildren => false — the root's own region
  IS the screen (the viewport already scissors it), so this is a
  safety net against a momentarily zero-sized root silently blanking
  the whole UI tree under the new ancestor-clip default.
- N2: added the empty-clip subtree cull (retail's var_24 gate
  @0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly
  separate traversal untouched by this change. New test proves a menu
  inside a fully-clipped (zero-width) window still draws its open
  popup via the overlay pass while the main pass draws nothing.
- N3: CT7 script §5 now names the collapsed-toolbar check and the
  four highest-overflow windows (combat/vitals bar, Options
  bottom-button row, map/house page, floaty chat) as explicit
  eyeball items for the re-gate.
- N4: verification below covers both the working tree and the clean
  committed tree.

Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate
@0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip);
UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen-
clipped popup region).

Verification (both runs green, --filter "Lane!=InstalledDat&
Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&
Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&
Status!=KnownFailure"): full Release solution build green; working
tree 14,900+ tests across every project (one LandblockPresentation
PipelineTests flake reproduced ONLY under full-solution parallel
load, passes standalone and on rerun — unrelated to this change,
streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT
_TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core
tests). Clean committed tree (git stash push -u the uncommitted
owner probe + docs files, rerun, stash pop) reported in the session
summary.

src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively
(git add -p) so only this commit's own two hunks (ClipsChildren
override, WantsMouse) landed; the probe hunk is untouched and stays
uncommitted, same as before this fix round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:11:42 +02:00

379 lines
17 KiB
C#

using System.Linq;
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;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign CT slice CT-GF1: mechanism-level tests for the client-wide retained-UI
/// ancestor clip (<see cref="UiElement.ClipsChildren"/>'s new default-true, porting
/// retail's <c>UIRegion::DrawHere @0x0069FA30</c> clip-rect-chain intersection) and its
/// one deliberate opt-out (<see cref="UiElement.ExpandsClipForPopup"/>, used by
/// <see cref="UiMenu"/>'s inline-drawn popup). The Titles-page divider gate repro lives
/// in <c>CharacterTitlesControllerTests</c> (the real owner-reported symptom); the chat
/// input-row regression pin lives in <c>ChatLayoutConformanceTests</c>. This file covers
/// the underlying mechanism directly with small synthetic trees.
/// </summary>
public sealed class UiAncestorClipTests
{
private sealed class TestElement : UiElement { }
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static (RecordingGpuDevice device, TextRenderer renderer, UiRenderContext ctx) MakeContext(
float w, float h)
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(w, h));
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
return (device, renderer, ctx);
}
private static bool AnyQuadAt(TextRenderer renderer, System.Func<float, float, bool> predicate)
{
foreach (var seg in renderer.DebugSpriteSegmentVerts)
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
if (predicate(seg.Verts[i * 8], seg.Verts[i * 8 + 1]))
return true;
}
}
return false;
}
/// <summary>
/// Core mechanism, plain elements (no dat import involved): a child positioned
/// entirely outside its parent's [0,Width]x[0,Height] rect renders NOTHING — the
/// retail <c>UIRegion::DrawHere</c> "intersection empty -> skip the subtree" gate
/// (@0x0069FB8E). A sibling positioned INSIDE the parent still renders normally.
/// </summary>
[Fact]
public void ChildOutsideParentBounds_RendersNothing_SiblingInsideStillRenders()
{
var parent = new TestElement { Width = 50f, Height = 50f };
var outside = new UiSolidSpriteFill
{
Left = -100f, Top = -100f, Width = 20f, Height = 20f,
SpriteId = 7u,
SpriteResolve = id => (id, 8, 8),
};
var inside = new UiSolidSpriteFill
{
Left = 5f, Top = 5f, Width = 10f, Height = 10f,
SpriteId = 9u,
SpriteResolve = id => (id, 8, 8),
};
parent.AddChild(outside);
parent.AddChild(inside);
var (_, renderer, ctx) = MakeContext(200f, 200f);
parent.DrawSelfAndChildren(ctx);
Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u);
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
}
/// <summary>
/// A child straddling the parent's edge is clipped to the visible sliver, not
/// culled outright and not drawn full-size — the intersected rect
/// <c>UIRegion::DrawHere</c> passes to <c>DrawSelf</c>.
/// </summary>
[Fact]
public void ChildStraddlingParentEdge_ClipsToTheVisibleSliver()
{
var parent = new TestElement { Width = 50f, Height = 50f };
var straddling = new UiSolidSpriteFill
{
Left = 40f, Top = 10f, Width = 30f, Height = 10f, // spans x=[40,70), parent ends at 50
SpriteId = 3u,
SpriteResolve = id => (id, 8, 8),
};
parent.AddChild(straddling);
var (_, renderer, ctx) = MakeContext(200f, 200f);
parent.DrawSelfAndChildren(ctx);
var seg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 3u);
float maxX = 0f;
for (int i = 0; i < seg.Verts.Count / 8; i++)
maxX = System.MathF.Max(maxX, seg.Verts[i * 8]);
Assert.True(maxX <= 50.01f, $"clipped quad's rightmost X ({maxX}) must not exceed the parent's edge (50)");
}
/// <summary>
/// Hit-testing gets the SAME default: a point outside the parent's bounds never
/// reaches a child positioned there, even though the child's own local hit-test
/// would otherwise accept it (aligning HitTest with the new draw-clip default per
/// the CT-GF1 plan's point 4).
/// </summary>
[Fact]
public void ChildOutsideParentBounds_IsNeverHit()
{
var parent = new TestElement { Width = 50f, Height = 50f };
var outside = new TestElement { Left = -30f, Top = -30f, Width = 20f, Height = 20f };
parent.AddChild(outside);
UiElement? hit = parent.HitTest(-20f, -20f); // lands inside `outside`'s own local rect
Assert.Null(hit);
}
/// <summary>
/// CT-GF1's one opt-out: <see cref="UiMenu"/>'s popup (drawn inline via
/// <c>OnDrawOverlay</c>) must keep escaping its owning window's ancestor clip —
/// retail's separate top-level popup region, see <see cref="UiElement.ExpandsClipForPopup"/>'s
/// doc comment and the AD-113 divergence register row. A small window (80x18, the
/// menu button's own size) sits well below the canvas top; the popup opens UPWARD
/// (the class default) and must still render there, well outside the window's own
/// [0,80]x[0,18] rect.
/// </summary>
[Fact]
public void UiMenuPopup_StillRendersOutsideItsOwningWindow_AncestorClipDoesNotCutItOff()
{
var root = new TestElement { Width = 200f, Height = 200f };
var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f };
var menu = new UiMenu
{
Width = 80f,
Height = 18f,
Items = new[] { new UiMenu.MenuItem("Row", (object?)null) },
SpriteResolve = id => (id, 8, 8),
};
root.AddChild(window);
window.AddChild(menu);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)));
Assert.True(menu.IsOpen);
// Not wrapped in BeginOverlayLayer/EndOverlayLayer (which UiRoot.DrawCore does
// in production, routing overlay draws to a SEPARATE buffer with no debug
// accessor) — the clip mechanism under test is layer-agnostic, so drawing to
// the normal buffer keeps DebugSpriteSegmentVerts usable here.
var (_, renderer, ctx) = MakeContext(200f, 200f);
root.DrawOverlays(ctx);
// The popup opens upward (bottom touches the button's top, y=0), so its
// absolute screen top is window.Top(150) minus its own outer height — well
// above window.Top. Assert at least one popup quad renders strictly above the
// owning window's own top edge, i.e. outside window's [0,18] local rect.
bool escapedAboveWindow = AnyQuadAt(renderer, (_, y) => y < 150f - 0.5f);
Assert.True(
escapedAboveWindow,
"expected the open UiMenu popup to render above its owning window's top edge");
}
/// <summary>
/// Companion negative check: with the SAME geometry but the popup left CLOSED, no
/// quad renders above the window at all — proving the escape above is specifically
/// about the OPEN popup's content, not a blanket unclipped draw for the whole menu.
/// </summary>
[Fact]
public void UiMenuClosed_NothingRendersAboveItsOwningWindow()
{
var root = new TestElement { Width = 200f, Height = 200f };
var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f };
var menu = new UiMenu
{
Width = 80f,
Height = 18f,
Items = new[] { new UiMenu.MenuItem("Row", (object?)null) },
SpriteResolve = id => (id, 8, 8),
};
root.AddChild(window);
window.AddChild(menu);
Assert.False(menu.IsOpen);
// Not wrapped in BeginOverlayLayer/EndOverlayLayer (which UiRoot.DrawCore does
// in production, routing overlay draws to a SEPARATE buffer with no debug
// accessor) — the clip mechanism under test is layer-agnostic, so drawing to
// the normal buffer keeps DebugSpriteSegmentVerts usable here.
var (_, renderer, ctx) = MakeContext(200f, 200f);
root.DrawOverlays(ctx);
Assert.False(AnyQuadAt(renderer, (_, y) => y < 150f - 0.5f));
}
/// <summary>
/// Retail's hover tooltip is the OTHER content this codebase draws "regardless of
/// tree position" (see <see cref="RetailTooltipPresenter"/>'s class doc): it mounts
/// its popup as an ordinary <see cref="UiRoot"/> CHILD (a sibling of every window),
/// not nested inside whatever widget triggered it — so unlike <see cref="UiMenu"/>'s
/// popup, it needs no <see cref="UiElement.ExpandsClipForPopup"/> opt-out; it was
/// never inside the triggering window's ancestor-clip subtree to begin with. This
/// pins that structural invariant survives CT-GF1: hovering a target buried inside
/// a tiny (20x20) window still mounts and DRAWS the tooltip popup, unclipped by that
/// window's own bounds.
/// </summary>
[Fact]
public void RetailTooltip_StillRendersOutsideATinyAncestorWindow_BecauseItMountsAtRootLevel()
{
const uint popupRootId = 0x900u;
const uint textChildId = 0x901u;
const uint popupLayoutDid = 0x21000041u;
const uint popupBgSprite = 42u;
ImportedLayout BuildPopup()
{
var rootInfo = new ElementInfo
{
Id = popupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
TooltipTextChildElementId = textChildId,
};
rootInfo.StateMedia[""] = (popupBgSprite, 1);
var textInfo = new ElementInfo
{
Id = textChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
};
return LayoutImporter.BuildFromInfos(
rootInfo, new[] { textInfo }, id => (id, 8, 8), null);
}
var root = new UiRoot { Width = 800f, Height = 600f };
var presenter = new RetailTooltipPresenter(root, (_, _) => BuildPopup());
// A tiny "window" ancestor (20x20) hosting the hover target as a nested child.
// If the tooltip were drawn from INSIDE this subtree, CT-GF1's new default
// ancestor clip would cut it off — the popup's mouse-anchored position (32px
// offset per PositionAtMouse) lands well outside a 20x20 rect.
var window = new TestElement { Left = 5f, Top = 5f, Width = 20f, Height = 20f };
var target = new TestElement
{
Left = 2f, Top = 2f, Width = 10f, Height = 10f,
AuthoredTooltipEnabled = true,
AuthoredTooltipText = "Rotate left.",
AuthoredTooltipRootElementId = popupRootId,
AuthoredTooltipLayoutDid = popupLayoutDid,
};
window.AddChild(target);
root.AddChild(window);
root.OnMouseMove(10, 10); // inside `target`, well inside the 20x20 window
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
// Mounted as a UiRoot SIBLING of `window`, not nested inside it.
UiElement popup = Assert.Single(root.Children, c => c != window);
Assert.Same(root, popup.Parent);
var (_, renderer, ctx) = MakeContext(800f, 600f);
root.DrawSelfAndChildren(ctx);
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == popupBgSprite);
presenter.Dispose();
}
/// <summary>
/// CT-GF1 fix round (S4): pins the input half of the popup-escape mechanism the
/// draw-only tests above only cover visually. A menu mounted inside a SHORT owning
/// window on a REAL <see cref="UiRoot"/>, opened, has its popup's first row land
/// well ABOVE the window's own [0,Height) local rect — the escaped region. An
/// ordinary top-down <see cref="UiRoot.OnMouseDown"/> walk would reject a point
/// there before ever reaching the menu: the owning `window`'s own
/// <see cref="UiElement.ClipsChildren"/> default (true, CT-GF1) rejects any
/// out-of-bounds local coordinate in <see cref="UiElement.HitTest"/> BEFORE
/// recursing into its children, so the menu's own out-of-bounds
/// <c>OnHitTest</c> union is never consulted. <c>UiRoot</c>'s <c>PopupHit</c>
/// routing (#374) is what rescues this: while a popup is registered active, a
/// press/scroll/<see cref="UiRoot.WantsMouse"/> query is tested directly against
/// the popup element itself, bypassing the ancestor walk entirely.
/// </summary>
[Fact]
public void EscapedPopupClick_ReachesTheMenu_ThroughAShortOwningWindow()
{
var root = new UiRoot { Width = 200f, Height = 200f };
var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f };
string? picked = null;
var menu = new UiMenu
{
Width = 80f,
Height = 18f,
OpenUpward = true,
RowsPerColumn = 1, // one row -> a small, exactly-known popup rect
Items = new[] { new UiMenu.MenuItem("Row", (object?)"row") },
SpriteResolve = id => (id, 8, 8),
};
menu.OnSelect = p => picked = p as string;
window.AddChild(menu);
root.AddChild(window);
// Open the popup via a REAL click on the button face (screen space).
root.OnMouseDown(UiMouseButton.Left, 20, 155);
root.OnMouseUp(UiMouseButton.Left, 20, 155);
Assert.True(menu.IsOpen);
// OuterW = ColumnWidth(191) + 2*Border(5) = 201; OuterH = 1*RowHeight(17) +
// 2*Border(5) = 27. Opens upward from the button's own screen top (150), so
// the popup spans screen Y = 150-27=123 .. 150 -- strictly above the owning
// window's own [150,168) rect, i.e. the escaped region.
const int rowScreenY = 135; // inside [123,150)
const int rowScreenX = 60; // inside [10,211)
Assert.True(rowScreenY < 150, "sanity: the row must sit above the window's own top edge");
// Without PopupHit, a plain top-down walk at this point would be rejected by
// `window`'s own ancestor-clip bounds check before ever reaching the menu --
// proven directly against the SAME tree/geometry, no popup registered.
Assert.Null(root.Pick(rowScreenX, rowScreenY));
// WantsMouse must recognize the escaped popup region too (S4), so a game
// action does not fire underneath an open dropdown.
root.OnMouseMove(rowScreenX, rowScreenY);
Assert.True(root.WantsMouse, "WantsMouse must see the escaped popup through PopupHit");
root.OnMouseDown(UiMouseButton.Left, rowScreenX, rowScreenY);
root.OnMouseUp(UiMouseButton.Left, rowScreenX, rowScreenY);
Assert.Equal("row", picked);
Assert.False(menu.IsOpen);
}
/// <summary>
/// CT-GF1 fix round (N2): the empty-clip subtree cull added to
/// <see cref="UiElement.DrawSelfAndChildren"/> (retail's <c>var_24</c> gate,
/// <c>UIRegion::DrawHere @0x0069FB8E</c>) early-outs once the intersected clip
/// goes empty -- e.g. a window whose own Width has collapsed to zero.
/// <see cref="UiElement.DrawOverlays"/> is a wholly SEPARATE traversal (the
/// second pass <see cref="UiRoot.Draw"/> runs after the main one) that shares no
/// clip-stack state with the cull above, so an open <see cref="UiMenu"/> popup
/// nested inside such a window must keep drawing there regardless.
/// </summary>
[Fact]
public void UiMenuPopup_StillDraws_EvenWhenItsOwningWindowIsFullyClippedAway()
{
var root = new TestElement { Width = 200f, Height = 200f };
var window = new TestElement { Left = 10f, Top = 150f, Width = 0f, Height = 18f };
var menu = new UiMenu
{
Width = 80f,
Height = 18f,
Items = new[] { new UiMenu.MenuItem("Row", (object?)null) },
SpriteResolve = id => (id, 8, 8),
};
root.AddChild(window);
window.AddChild(menu);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)));
Assert.True(menu.IsOpen);
var (_, renderer, ctx) = MakeContext(200f, 200f);
// Main pass: `window`'s own zero-width clip is empty -- the cull skips its
// whole subtree (including the menu's own button face), so nothing draws.
root.DrawSelfAndChildren(ctx);
Assert.Empty(renderer.DebugSpriteSegmentVerts);
// Overlay pass: the SAME open popup still renders -- proves the cull above is
// scoped to DrawSelfAndChildren and never reaches DrawOverlays.
root.DrawOverlays(ctx);
Assert.NotEmpty(renderer.DebugSpriteSegmentVerts);
}
}