Applies all 11 items from the Opus dual-lens review of989f6652(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>
379 lines
17 KiB
C#
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);
|
|
}
|
|
}
|