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; /// /// Campaign CT slice CT-GF1: mechanism-level tests for the client-wide retained-UI /// ancestor clip ('s new default-true, porting /// retail's UIRegion::DrawHere @0x0069FA30 clip-rect-chain intersection) and its /// one deliberate opt-out (, used by /// 's inline-drawn popup). The Titles-page divider gate repro lives /// in CharacterTitlesControllerTests (the real owner-reported symptom); the chat /// input-row regression pin lives in ChatLayoutConformanceTests. This file covers /// the underlying mechanism directly with small synthetic trees. /// 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 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; } /// /// 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 UIRegion::DrawHere "intersection empty -> skip the subtree" gate /// (@0x0069FB8E). A sibling positioned INSIDE the parent still renders normally. /// [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); } /// /// A child straddling the parent's edge is clipped to the visible sliver, not /// culled outright and not drawn full-size — the intersected rect /// UIRegion::DrawHere passes to DrawSelf. /// [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)"); } /// /// 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). /// [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); } /// /// CT-GF1's one opt-out: 's popup (drawn inline via /// OnDrawOverlay) must keep escaping its owning window's ancestor clip — /// retail's separate top-level popup region, see '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. /// [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"); } /// /// 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. /// [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)); } /// /// Retail's hover tooltip is the OTHER content this codebase draws "regardless of /// tree position" (see 's class doc): it mounts /// its popup as an ordinary CHILD (a sibling of every window), /// not nested inside whatever widget triggered it — so unlike 's /// popup, it needs no 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. /// [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(); } /// /// 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 , 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 walk would reject a point /// there before ever reaching the menu: the owning `window`'s own /// default (true, CT-GF1) rejects any /// out-of-bounds local coordinate in BEFORE /// recursing into its children, so the menu's own out-of-bounds /// OnHitTest union is never consulted. UiRoot's PopupHit /// routing (#374) is what rescues this: while a popup is registered active, a /// press/scroll/ query is tested directly against /// the popup element itself, bypassing the ancestor walk entirely. /// [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); } /// /// CT-GF1 fix round (N2): the empty-clip subtree cull added to /// (retail's var_24 gate, /// UIRegion::DrawHere @0x0069FB8E) early-outs once the intersected clip /// goes empty -- e.g. a window whose own Width has collapsed to zero. /// is a wholly SEPARATE traversal (the /// second pass runs after the main one) that shares no /// clip-stack state with the cull above, so an open popup /// nested inside such a window must keep drawing there regardless. /// [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); } /// /// CT7 gate regression (2026-08-25): the MossTank plugin's markup /// labels lost ALL text under the completed self-clip — a markup /// <label> 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. /// [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