acdream/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs
Erik 752782d0a9 fix(CT-GF1): UiLabel opts out of the self-clip — plugin markup text restored
CT7 gate regression (owner report): all MossTank plugin text vanished
except button captions. A markup <label> authors position only, so
UiLabel's box was degenerate (0x0) and CT-GF1's completed self-clip
(UIRegion::DrawHere @0x0069FA30 shape) cropped its glyphs to nothing;
markup buttons author w/h, which is why their captions survived.

UiLabel now opts out of the self-clip — it is ClickThrough pure text
whose real containment is its ancestors (the plugin panel/window, which
are properly sized), the effective retail behavior for a text region
whose box hugs its glyphs — and keeps a truthful box by measuring its
current text each draw. Mechanism pin: an unsized label's subtree must
render inside its sized parent (probe-child draw-capture test).

Gate note recorded by the owner in the same round: the Titles-page
divider IS visible inside the window in retail while scrolling — a
retail quirk our clipped rendering now reproduces exactly. CT7 gate
PASSED apart from this regression.

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

425 lines
19 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);
}
/// <summary>
/// CT7 gate regression (2026-08-25): the MossTank plugin's markup
/// labels lost ALL text under the completed self-clip — a markup
/// &lt;label&gt; authors position only, so UiLabel's box was 0x0 and the
/// element's own clip cropped every glyph. UiLabel now opts out of the
/// self-clip (its ancestors clip — the retail containment for a text
/// region whose box hugs its glyphs) and keeps a truthful measured box.
/// Button captions never regressed because markup buttons author w/h.
/// </summary>
[Fact]
public void UnsizedMarkupLabel_InsideASizedPanel_DoesNotSelfClipItsSubtree()
{
var (device, renderer, ctx) = MakeContext(800f, 600f);
var panel = new UiPanel
{
Left = 50f, Top = 50f, Width = 200f, Height = 100f,
BackgroundColor = default, BorderColor = default,
};
// Exactly what MarkupDocument builds for <label x=".." y=".."> —
// position only, no Width/Height (the box is degenerate at draw
// time). Pre-fix, UiLabel inherited the ambient self-clip and its
// 0x0 push cropped everything it painted to nothing — the MossTank
// vanished-text regression. The mechanism pin below hangs a
// sprite-drawing probe child under the label: with the self-clip
// the probe cannot render; with UiLabel's opt-out it must.
var label = new UiLabel { Left = 10f, Top = 10f, Text = "MossTank status line" };
var probe = new UiPanel
{
Left = 0f, Top = 0f, Width = 100f, Height = 10f,
BackgroundSprite = 42u,
SpriteResolve = id => id == 42u ? (42u, 8, 8) : (0u, 0, 0),
BackgroundColor = default, BorderColor = default,
};
label.AddChild(probe);
panel.AddChild(label);
panel.DrawSelfAndChildren(ctx);
Assert.True(
AnyQuadAt(renderer, (x, y) => x >= 60f && x <= 160f && y >= 60f && y <= 70f),
"the unsized label self-clipped its subtree away (MossTank regression)");
// The box became truthful (fallback-measured without a font), not degenerate.
Assert.True(label.Width > 0f && label.Height > 0f,
$"label box stayed degenerate: {label.Width}x{label.Height}");
}
}