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 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>
This commit is contained in:
parent
989f665214
commit
025108a8aa
13 changed files with 717 additions and 120 deletions
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue