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>
This commit is contained in:
Erik 2026-08-25 07:11:42 +02:00
parent 989f665214
commit 025108a8aa
13 changed files with 717 additions and 120 deletions

View file

@ -721,7 +721,24 @@ public sealed class CharacterTitlesControllerTests
dividerGrown.Y >= 0f && dividerGrown.Y + divider.Height <= 600f,
"expected the Titles divider to land inside the grown window at its authored " +
$"spot; got {dividerGrown.Y}");
AssertQuadCoversY(renderer, dividerGrown.Y, dividerGrown.Y + divider.Height);
// CT-GF1 fix round (S5): AssertQuadCoversY alone only proves SOMETHING drew
// in that Y band -- not that it was specifically THIS divider's own quad (the
// grown Titles page has other rows/backgrounds that could coincidentally
// share the band). Strengthen both halves: pin the divider's exact rect (X
// AND Y range, not Y alone), THEN diff against the same rect with the
// divider hidden -- if hiding it does not empty the rect out, whatever drew
// there was never uniquely attributable to the divider in the first place.
AssertQuadCoversRect(
renderer, dividerGrown.X, dividerGrown.Y,
dividerGrown.X + divider.Width, dividerGrown.Y + divider.Height);
divider.Visible = false;
renderer.Begin(new Vector2(screen.Width, screen.Height));
handle.OuterFrame.DrawSelfAndChildren(ctx);
AssertNoQuadCoversRect(
renderer, dividerGrown.X, dividerGrown.Y,
dividerGrown.X + divider.Width, dividerGrown.Y + divider.Height);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
@ -744,6 +761,45 @@ public sealed class CharacterTitlesControllerTests
}
}
/// <summary>CT-GF1 fix round (S5): the X-and-Y-range companion to
/// <see cref="AssertQuadCoversY"/> — a passing vertex must land inside BOTH
/// axes' rect, not just the Y band, so an unrelated same-band element (a
/// background, another row) cannot satisfy this vacuously.</summary>
private static void AssertQuadCoversRect(TextRenderer renderer, float xLo, float yLo, float xHi, float yHi)
{
bool found = renderer.DebugSpriteSegmentVerts.Any(seg =>
{
for (int i = 0; i < seg.Verts.Count / 8; i++)
{
float vx = seg.Verts[i * 8];
float vy = seg.Verts[i * 8 + 1];
if (vx >= xLo - 0.5f && vx <= xHi + 0.5f && vy >= yLo - 0.5f && vy <= yHi + 0.5f)
return true;
}
return false;
});
Assert.True(found, $"expected at least one quad vertex inside rect [{xLo},{yLo}]..[{xHi},{yHi}]");
}
/// <summary>CT-GF1 fix round (S5): the negative half of <see cref="AssertQuadCoversRect"/> —
/// used after hiding the divider to prove the earlier positive assertion was
/// attributable to it specifically (a diff, not a coincidence).</summary>
private static void AssertNoQuadCoversRect(TextRenderer renderer, float xLo, float yLo, float xHi, float yHi)
{
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.False(
vx >= xLo - 0.5f && vx <= xHi + 0.5f && vy >= yLo - 0.5f && vy <= yHi + 0.5f,
$"unexpected quad vertex at ({vx},{vy}) inside the divider's own rect " +
$"[{xLo},{yLo}]..[{xHi},{yHi}] after hiding it");
}
}
}
private static void AssertQuadCoversY(TextRenderer renderer, float yLo, float yHi)
{
bool found = renderer.DebugSpriteSegmentVerts.Any(seg =>

View file

@ -0,0 +1,279 @@
using System;
using System.Collections.Generic;
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.Core.Items;
using AcDream.Core.Properties;
using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// CT-GF1 fix round (S1): a draw-capture regression sweep mounting each major
/// retained window through its REAL controller (the same production Bind
/// entry points other suites already exercise individually) with a NON-ZERO
/// sprite resolver, drawing through a <see cref="RecordingGpuDevice"/>, and
/// asserting a per-window VERTEX-COUNT FLOOR plus the presence of one KEY
/// sprite id. Every key sprite id is read LIVE off the bound
/// controller/element after Bind — never a hardcoded numeric guess (this
/// project's workflow forbids guessing dat ids) — so the assertion tracks
/// whatever the real import/bind pipeline actually resolved, not an
/// assumption about it.
///
/// This is the class of coverage the CH6a/b BLOCKER 1 bug slipped past: an
/// authored non-zero sprite id sitting right there on the ElementInfo, with
/// nothing actually reaching <c>DrawSprite</c> because the widget was built
/// without its resolve delegate. A structural/geometry test (FindElement,
/// property checks) cannot see that class of regression; only an actual draw
/// pass through a real render context can.
///
/// <para>
/// Windows covered: <b>Character</b> (this is ALSO CT-GF1's own motivating
/// case — its <see cref="RetailWindowChrome.NineSlice"/> chrome draws via
/// <see cref="UiNineSlicePanel.OnDrawAfterChildren"/>, the exact code path
/// the S2/item-1 fix-round caution note calls out as needing re-proof after
/// moving the ambient clip to wrap it), <b>Chat</b> (Imported chrome, real
/// committed fixture), <b>Vendor</b> (Imported chrome, real committed
/// fixture), <b>Options</b> (the 4-tab panel host — no
/// <see cref="RetailWindowFrame"/> wrapper; mounts as a bare
/// <see cref="UiTabPanel"/>, matching <c>OptionsPanelControllerTests</c>'s
/// own established pattern). SKIPPED for lack of a reusable, fixture-driven,
/// SINGLE top-level Bind entry point at the time of writing: Inventory/
/// Paperdoll (composed from several independent controllers, no single
/// window-level Bind), the social panel and the map/house host (their own
/// mount probes are Lane=Manual, live-DAT-only — see
/// <c>SocialPanelLiveMountProbeTests</c>/<c>MapHousePanelSlotProbeTests</c>).
/// </para>
///
/// <para>
/// Floors are set at roughly 40-45% of this session's OBSERVED vertex count
/// per window (Character 588, Chat 162, Vendor 54, Options 240 — see each
/// <c>Build*</c> method's own trailing comment) — per the fix-round
/// instruction, loose enough to survive legitimate content growth/shrinkage,
/// tight enough to still catch "half (or all) of this window stopped
/// drawing" (a resolve-wiring regression), not exact counts.
/// </para>
/// </summary>
public sealed class UiWindowDrawCaptureSweepTests
{
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);
}
/// <summary>Total vertex count across every recorded sprite segment — the
/// same per-segment <c>Verts.Count / 8</c> convention every other test in
/// this suite uses (8 floats packed per vertex).</summary>
private static int TotalVertexCount(TextRenderer renderer)
{
int total = 0;
foreach (var seg in renderer.DebugSpriteSegmentVerts)
total += seg.Verts.Count / 8;
return total;
}
public static IEnumerable<object[]> Windows()
{
yield return new object[] { "Character" };
yield return new object[] { "Chat" };
yield return new object[] { "Vendor" };
yield return new object[] { "Options" };
}
[Theory]
[MemberData(nameof(Windows))]
public void MountedWindow_DrawsAVertexFloor_AndItsLiveKeySpriteId(string window)
{
(UiElement drawRoot, uint keySprite, int vertexFloor) = window switch
{
"Character" => BuildCharacter(),
"Chat" => BuildChat(),
"Vendor" => BuildVendor(),
"Options" => BuildOptions(),
_ => throw new ArgumentOutOfRangeException(nameof(window), window, null),
};
Assert.NotEqual(0u, keySprite);
var (_, renderer, ctx) = MakeContext(1600f, 1200f);
drawRoot.DrawSelfAndChildren(ctx);
int vertices = TotalVertexCount(renderer);
Assert.True(
vertices >= vertexFloor,
$"{window}: expected at least {vertexFloor} drawn vertices, got {vertices} " +
"-- a resolve-wiring regression (CH6a/b BLOCKER 1's class) would show up as a " +
"near-zero count here.");
Assert.Contains(
renderer.DebugSpriteSegmentVerts,
s => s.Texture == keySprite);
}
private static (UiElement drawRoot, uint keySprite, int vertexFloor) BuildCharacter()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCharacterInfos(), id => (id, 8, 8), null);
CharacterStatController.Bind(
layout, SampleData.SampleCharacter, spriteResolve: id => (id, 8, 8));
var screen = new UiRoot { Width = 1600f, Height = 1200f };
RetailWindowHandle handle = RetailWindowFrame.Mount(
screen,
layout.Root,
id => (id, 8, 8),
new RetailWindowFrame.Options
{
WindowName = WindowNames.Character,
Chrome = RetailWindowChrome.NineSlice,
ContentHeight = 362f,
MinWidth = 310f,
MaxWidth = 310f,
MinHeight = 372f,
MaxHeight = 1000f,
ResizeX = false,
ResizeY = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
// Key sprite: RetailChromeSprites.TopEdge -- drawn by
// UiNineSlicePanel.OnDrawAfterChildren, the exact code path the S2/
// item-1 fix-round caution note calls out. Not read "live" (it's a
// shared named constant, not a per-window authored id), but it is
// NOT a guess either -- it is the actual constant the chrome drawer
// uses, verified by reading UiNineSlicePanel.OnDrawAfterChildren.
return (handle.OuterFrame, RetailChromeSprites.TopEdge, 250); // observed baseline 588
}
private static (UiElement drawRoot, uint keySprite, int vertexFloor) BuildChat()
{
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,
id => (id, 8, 8));
Assert.NotNull(controller);
var root = new UiRoot { Width = 1600f, Height = 1200f };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller!.Root,
id => (id, 8, 8),
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 10f,
DatConstraintSource = controller.DatWindowInfo,
});
controller.AttachWindow(handle);
// Key sprite: read LIVE off the bound scrollbar's own TrackSprite --
// the scrollbar always draws its track whenever the window does, so
// this tracks whatever the real fixture actually authors rather than
// a hardcoded literal.
return (handle.OuterFrame, controller.Scrollbar.TrackSprite, 80); // observed baseline 162
}
private static (UiElement drawRoot, uint keySprite, int vertexFloor) BuildVendor()
{
ImportedLayout layout = FixtureLoader.LoadVendor();
var screen = new UiRoot { Width = 1600f, Height = 1200f };
RetailWindowHandle window = RetailWindowFrame.Mount(
screen,
layout.Root,
id => (id, 8, 8),
new RetailWindowFrame.Options
{
WindowName = "vendor-sweep",
Chrome = RetailWindowChrome.Imported,
Visible = true,
});
var objects = new ClientObjectTable();
var itemInteraction = new ItemInteractionController(
objects,
new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(),
playerGuid: static () => 0u,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
VendorUiController? controller = VendorUiController.Bind(
layout,
new VendorState(),
window,
static (_, iconId, _, _, _) => iconId,
objects,
static () => 0u,
itemInteraction,
new SelectionState(),
new StackSplitQuantityState(),
datFont: null,
debugFont: null,
id => (id, 8, 8));
Assert.NotNull(controller);
// Key sprite: read LIVE off the bound category-filter menu's own
// button-face sprite (0x100000BF, VendorUiController.TypeFilterMenuId) --
// its face always draws whenever the window does, so this tracks
// whatever the real committed vendor fixture actually authors.
var typeMenu = Assert.IsType<UiMenu>(layout.FindElement(VendorUiController.TypeFilterMenuId));
return (window.OuterFrame, typeMenu.NormalSprite, 25); // observed baseline 54
}
private static (UiElement drawRoot, uint keySprite, int vertexFloor) BuildOptions()
{
// NOTE: FixtureLoader.LoadOptionsPanelHost() (the convenience wrapper) bakes
// in FixtureLoader's own NULL-returning sprite resolver at LayoutImporter.Build
// time -- permanent for every widget it builds, unaffected by whatever resolver
// is later passed into OptionsPanelController.Bind (which only reaches content
// that controller creates itself, e.g. the per-page footer backing). Building
// from the raw ElementInfo tree here (matching BuildCharacter/BuildChat/
// BuildVendor's own pattern above) is what actually gets a non-zero resolver
// onto the imported header/tab/page widgets themselves.
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadOptionsPanelHostInfos(), id => (id, 8, 8), null);
var callbacks = new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { });
OptionsPanelController? controller = OptionsPanelController.Bind(
layout, callbacks, resolveSprite: id => (id, 8, 8));
Assert.NotNull(controller);
controller!.ActivateTabs();
// Key sprite: RetailChromeSprites.CenterFill -- the exact id
// OptionsPanelControllerTests' own CollectFooterBackings helper pins
// as every page's footer backing (a UiSolidSpriteFill), read here as
// the same shared named constant, not a guess.
return (layout.Root, RetailChromeSprites.CenterFill, 120); // observed baseline 240
}
}

View file

@ -271,4 +271,109 @@ public sealed class UiAncestorClipTests
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);
}
}