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>
279 lines
12 KiB
C#
279 lines
12 KiB
C#
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
|
|
}
|
|
}
|