Owner live-client report 2026-09-07: "For scrollable dropdown or the meta window we use the same assets as we do in for example chat or inventory window." The plain-style <menu> popup's scrollable-overflow scrollbar (DrawScrollablePopupPlain / DrawPopupScrollbarPlain in UiMenu.cs) drew a home-made flat 1px track + flat thumb instead of the gold track + up/down arrow buttons + thumb the chat SpewBox and inventory UiItemList already use through RetailScrollbarChrome. The owner only ever objected to the retail ROW art (checkmark glyph, gradient panel) — the bar itself was never in scope for the plain-row fix, so this change touches only the scrollbar draw call and leaves the plain row rendering untouched. DrawScrollablePopupPlain now calls the existing DrawPopupScrollbar helper (the same procedural sprite-chrome draw VendorUiController/ ConfigOptionsPageController already use) whenever a SpriteResolve is wired, falling back to the old flat DrawPopupScrollbarPlain only for a hand-built UiMenu with no resolver at all. New RetailScrollbarChrome.ApplyToMenuPopup(UiMenu) wires the same vertical skin ids (Track/Up/Down/ThumbTop/Mid/Bot Normal) the chat/inventory scrollbar uses onto a menu's own ScrollTrackSprite/etc properties. Mutation shown to fail first: UiMenuPlainStyleTests's Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt and Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb asserted resolveCalls==0 and an all-fill scrollbar — both failed (6 resolve calls, 6 sprite quads instead of 0) against the new DrawPopupScrollbar call before being rewritten to Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain and Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll, which pin the new sprite-chrome behavior (6 resolved ids on overflow: track, up, down, thumb top/mid/bottom; 3 on content-fits: track+up+down, no thumb; 0 on a menu built with no resolver) while re-asserting the rows are still plain fills with zero retail row-sprite quads. Retail's own RetailButtonArt=true popup path (DrawGridPopup/DrawScrollablePopup) is untouched — its regression golden (Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden) still passes byte-for-byte. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
480 lines
22 KiB
C#
480 lines
22 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 DatReaderWriter.Types;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.UI;
|
||
|
||
/// <summary>
|
||
/// S7 fix ("BIG gold/yellow buttons has to go" — owner live-client report
|
||
/// 2026-09-07, looking at the live client: retail's gold pushbutton art on
|
||
/// the plugin-markup <c><menu></c> closed state does not match VTank's
|
||
/// own <c>HudCombo</c> shape, a flat dark box with a thin border, left-
|
||
/// aligned value text, and a small down-arrow — the same widget family as
|
||
/// <see cref="UiMarkupList"/>'s own list boxes
|
||
/// (<c>docs/research/vtank-kb/08-ui-views.md</c> §2).
|
||
///
|
||
/// <para>
|
||
/// Draw-level pins against the same <see cref="RecordingGpuDevice"/>/
|
||
/// <see cref="TextRenderer"/> apparatus <c>MarkupIconTests</c> uses: the
|
||
/// plain closed state (<see cref="UiMenu.RetailButtonArt"/> = false) emits
|
||
/// no textured button-face quad at all, only untextured fills (background +
|
||
/// 1px border + the ▾ triangle) plus the DAT-font caption glyphs; the retail
|
||
/// closed state (the class default, and every non-markup <see cref="UiMenu"/>
|
||
/// caller) is untouched — it still resolves and draws its gold
|
||
/// <see cref="UiMenu.NormalSprite"/>/<see cref="UiMenu.PressedSprite"/> face.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <see cref="TextRenderer.DebugSpriteSegmentVerts"/> merges CONTIGUOUS
|
||
/// same-texture draws into one segment (<c>NextSpriteSeg</c>'s "extend the
|
||
/// current same-texture run" rule) — a segment boundary appears only where
|
||
/// the texture actually changes in submission order. So these tests count
|
||
/// QUADS (48 floats = 6 verts × 8 floats each) summed across every segment
|
||
/// of a given texture, rather than assuming one segment per draw call.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class UiMenuPlainStyleTests
|
||
{
|
||
private const int FloatsPerQuad = 48; // 6 vertices/quad × 8 floats/vertex (AppendQuad).
|
||
|
||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||
{
|
||
public IGpuFrame? CurrentFrame => null;
|
||
}
|
||
|
||
private static (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 (renderer, ctx);
|
||
}
|
||
|
||
private static int QuadCount(
|
||
System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList<float> Verts)> segs,
|
||
uint texture)
|
||
=> segs.Where(s => s.Texture == texture).Sum(s => s.Verts.Count) / FloatsPerQuad;
|
||
|
||
// A distinctive, obviously-not-zero texture id for the retail gold face —
|
||
// if the plain path ever regressed into calling SpriteResolve for its
|
||
// face, this id would show up in the recorded segments.
|
||
private const uint FaceTexture = 77u;
|
||
private const uint FontTexture = 1u;
|
||
|
||
private static UiDatFont MakeFont()
|
||
{
|
||
var glyphs = new System.Collections.Generic.Dictionary<char, FontCharDesc>
|
||
{
|
||
['W'] = new FontCharDesc { Unicode = 'W', Width = 8, Height = 8 },
|
||
};
|
||
return new UiDatFont(
|
||
fgTex: FontTexture, fgW: 32, fgH: 32,
|
||
bgTex: 0, bgW: 0, bgH: 0,
|
||
lineHeight: 16f, baselineOffset: 12f,
|
||
glyphs);
|
||
}
|
||
|
||
private static UiMenu MakeMenu(bool retailButtonArt) => new()
|
||
{
|
||
Width = 100f, Height = 20f,
|
||
DatFont = MakeFont(),
|
||
SpriteResolve = _ => (FaceTexture, 46, 17),
|
||
RetailButtonArt = retailButtonArt,
|
||
NormalSprite = 0x06004D65u,
|
||
PressedSprite = 0x06004D66u,
|
||
Items = new[] { new UiMenu.MenuItem("Warrior", (object?)"Warrior") },
|
||
ButtonLabelProvider = () => "W",
|
||
};
|
||
|
||
[Fact]
|
||
public void Plain_ClosedState_DrawsNoTexturedFaceQuad()
|
||
{
|
||
var menu = MakeMenu(retailButtonArt: false);
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
|
||
menu.DrawSelfAndChildren(ctx);
|
||
|
||
Assert.Equal(0, QuadCount(renderer.DebugSpriteSegmentVerts, FaceTexture));
|
||
}
|
||
|
||
[Fact]
|
||
public void Plain_ClosedState_DrawsFillOutlineTextAndTriangle()
|
||
{
|
||
var menu = MakeMenu(retailButtonArt: false);
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
|
||
menu.DrawSelfAndChildren(ctx);
|
||
|
||
var segs = renderer.DebugSpriteSegmentVerts;
|
||
// Exactly: background fill (1 quad) + 1px outline (4 sides) +
|
||
// the ▾ triangle (4 stacked bands) = 9 untextured quads.
|
||
Assert.Equal(9, QuadCount(segs, 0u));
|
||
// The caption glyph drew exactly one quad through the DAT font texture.
|
||
Assert.Equal(1, QuadCount(segs, FontTexture));
|
||
}
|
||
|
||
[Fact]
|
||
public void Plain_ClosedState_TriangleSitsRightAligned_TextSitsAtListPadding()
|
||
{
|
||
var menu = MakeMenu(retailButtonArt: false);
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
|
||
menu.DrawSelfAndChildren(ctx);
|
||
|
||
// The glyph's dest quad starts at UiMenu.PlainPadding (left-aligned,
|
||
// no arrow-cap offset baked in the way the retail face indents it).
|
||
var glyphSeg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == FontTexture);
|
||
Assert.Equal(UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
|
||
|
||
// DrawPlainClosedState submits fill, then the 4 outline sides
|
||
// (all texture 0, merged into one segment), THEN the caption glyph
|
||
// (texture 1, its own segment), THEN the 4 triangle bands (texture 0
|
||
// again — a NEW segment, since the glyph draw broke the run). That
|
||
// last texture-0 segment is exactly the triangle: 4 quads, 192 floats.
|
||
var untexturedSegs = renderer.DebugSpriteSegmentVerts.Where(s => s.Texture == 0u).ToList();
|
||
Assert.Equal(2, untexturedSegs.Count); // [fill+outline], [triangle]
|
||
var triangleSeg = untexturedSegs[^1];
|
||
Assert.Equal(4 * FloatsPerQuad, triangleSeg.Verts.Count);
|
||
|
||
// Each band's right edge (Verts[8] within its own 48-float quad chunk
|
||
// — the second AppendQuad vertex's X, same convention
|
||
// MarkupIconTests.UiMarkupList_IconColumn_... uses) must sit within
|
||
// the box's right 13px (7px glyph + 6px margin), never touching the
|
||
// left-aligned text.
|
||
for (int q = 0; q < 4; q++)
|
||
{
|
||
float rightEdgeX = triangleSeg.Verts[q * FloatsPerQuad + 8];
|
||
Assert.True(rightEdgeX <= menu.Width - 6f + 0.01f);
|
||
Assert.True(rightEdgeX >= menu.Width - 13f - 0.01f);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Retail_ClosedState_StillEmitsItsGoldFaceSprite()
|
||
{
|
||
var menu = MakeMenu(retailButtonArt: true);
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
|
||
menu.DrawSelfAndChildren(ctx);
|
||
|
||
Assert.True(QuadCount(renderer.DebugSpriteSegmentVerts, FaceTexture) > 0);
|
||
}
|
||
|
||
[Fact]
|
||
public void RetailButtonArt_DefaultsTrue_SoNonMarkupCallersAreUnaffected()
|
||
{
|
||
// Every existing UiMenu construction site (chat, vendor, config
|
||
// options, the retail confirmation dialog, DatWidgetFactory's generic
|
||
// Type-6 element) never sets RetailButtonArt at all — the class
|
||
// default must keep drawing retail's gold art.
|
||
Assert.True(new UiMenu().RetailButtonArt);
|
||
}
|
||
|
||
[Fact]
|
||
public void Retail_ClosedState_DrawIsByteForByteUnchanged_RegressionGolden()
|
||
{
|
||
// A golden pin for a plain (non-markup) UiMenu built exactly the way
|
||
// pre-S7 code built one: no RetailButtonArt set at all (class
|
||
// default). Its drawn quad counts/textures must be identical to what
|
||
// the retail branch always produced — the S7 style switch must not
|
||
// have touched this path at all.
|
||
UiMenu menu = new()
|
||
{
|
||
Width = 100f, Height = 20f,
|
||
DatFont = MakeFont(),
|
||
SpriteResolve = _ => (FaceTexture, 46, 17),
|
||
NormalSprite = 0x06004D65u,
|
||
PressedSprite = 0x06004D66u,
|
||
Items = new[] { new UiMenu.MenuItem("Warrior", (object?)"Warrior") },
|
||
ButtonLabelProvider = () => "W",
|
||
};
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
|
||
menu.DrawSelfAndChildren(ctx);
|
||
|
||
var segs = renderer.DebugSpriteSegmentVerts;
|
||
// 3-slice face (LED cap, stretched body, arrow cap) = 3 quads of
|
||
// FaceTexture, plus exactly one caption glyph quad (FontTexture). No
|
||
// arrow-cap overlay sprite (ids left at 0 -> DrawArrowCap no-ops) and
|
||
// no untextured fill/outline/triangle quad at all — the plain path is
|
||
// never reached for a menu that never sets RetailButtonArt.
|
||
Assert.Equal(3, QuadCount(segs, FaceTexture));
|
||
Assert.Equal(1, QuadCount(segs, FontTexture));
|
||
Assert.Equal(0, QuadCount(segs, 0u));
|
||
}
|
||
|
||
// ── OPEN-popup coverage (owner live-client report 2026-09-07: "Drop down
|
||
// menus look horrible, there is also a checkmark on the text there") ────
|
||
//
|
||
// The S7 fix above only replaced the CLOSED-state button face. The tests
|
||
// below pin the OPEN popup: plain mode draws no sprite/gradient/checkmark
|
||
// art at all (only untextured fills via DrawFill/DrawRectOutline, exactly
|
||
// like UiMarkupList's own chrome), while the retail popup — the class
|
||
// default, and every non-markup UiMenu caller — is unchanged (the
|
||
// existing golden above only covers the closed state; the golden here
|
||
// covers the open popup).
|
||
|
||
private const float PlainRowHeight = 18f;
|
||
private const float PlainColumnWidth = 90f;
|
||
|
||
private static UiMenu MakePopupMenu(
|
||
bool retailButtonArt, int itemCount, int rowsPerColumn, bool scrollable,
|
||
System.Action<int>? countResolveCall = null)
|
||
{
|
||
var items = Enumerable.Range(0, itemCount)
|
||
.Select(i => new UiMenu.MenuItem(i == 0 ? "W" : $"row{i}", (object?)i))
|
||
.ToArray();
|
||
return new UiMenu
|
||
{
|
||
Width = 100f, Height = 20f,
|
||
DatFont = MakeFont(),
|
||
// Retail tests read the texture id straight back (id => (id, w, h)) so a
|
||
// texture id is a specific sprite by construction — the same convention
|
||
// UiAncestorClipTests uses. Plain tests wrap this to prove it is NEVER
|
||
// invoked (no gradient/sprite of ANY kind, not just the ones this class
|
||
// happens to name).
|
||
SpriteResolve = id =>
|
||
{
|
||
countResolveCall?.Invoke(1);
|
||
return (id, 8, 8);
|
||
},
|
||
RetailButtonArt = retailButtonArt,
|
||
NormalSprite = 0x06004D65u,
|
||
PressedSprite = 0x06004D66u,
|
||
PopupBgSprite = 0x0600124Cu,
|
||
ItemNormalSprite = 0x0600124Eu,
|
||
ItemHighlightSprite = 0x0600124Du,
|
||
// Non-zero retail scrollbar chrome ids (UiScrollbar.cs's own doc-cited
|
||
// values) so a plain test can assert these are never resolved — a zero
|
||
// id would be indistinguishable from "never set", and would collide
|
||
// with the untextured-fill bucket's own texture-0 key.
|
||
ScrollTrackSprite = 0x06004C5Fu,
|
||
ScrollThumbSprite = 0x06004C63u,
|
||
ScrollThumbTopSprite = 0x06004C60u,
|
||
ScrollThumbBottomSprite = 0x06004C66u,
|
||
ScrollUpSprite = 0x06004C6Cu,
|
||
ScrollDownSprite = 0x06004C69u,
|
||
ColumnWidth = PlainColumnWidth,
|
||
RowHeight = PlainRowHeight,
|
||
RowsPerColumn = rowsPerColumn,
|
||
Scrollable = scrollable,
|
||
OpenUpward = false, // downward: PopupTop == Height, simplest math for these tests
|
||
Items = items,
|
||
ButtonLabelProvider = () => "W",
|
||
};
|
||
}
|
||
|
||
private static bool HasFillQuad(
|
||
System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList<float> Verts)> segs,
|
||
float x, float y, float w, float h, Vector4 color, float tol = 0.05f)
|
||
{
|
||
foreach (var seg in segs)
|
||
{
|
||
if (seg.Texture != 0u) continue;
|
||
var v = seg.Verts;
|
||
for (int b = 0; b + FloatsPerQuad <= v.Count; b += FloatsPerQuad)
|
||
{
|
||
float qx = v[b], qy = v[b + 1];
|
||
float qw = v[b + 8] - qx, qh = v[b + 9] - qy;
|
||
float r = v[b + 4], g = v[b + 5], bl = v[b + 6], a = v[b + 7];
|
||
if (MathF.Abs(qx - x) < tol && MathF.Abs(qy - y) < tol
|
||
&& MathF.Abs(qw - w) < tol && MathF.Abs(qh - h) < tol
|
||
&& MathF.Abs(r - color.X) < tol && MathF.Abs(g - color.Y) < tol
|
||
&& MathF.Abs(bl - color.Z) < tol && MathF.Abs(a - color.W) < tol)
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>Opens the popup (MouseDown on the closed face) then, if given,
|
||
/// hovers a row via MouseMove — the same (Data1,Data2) local-coordinate
|
||
/// convention <see cref="UiMenu.OnEvent"/> already uses for MouseDown.</summary>
|
||
private static void OpenAndHover(UiMenu menu, int? hoverRow = null)
|
||
{
|
||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: 10)));
|
||
Assert.True(menu.IsOpen);
|
||
if (hoverRow is { } row)
|
||
{
|
||
// ix = lx - Border, iy = ly - (PopupTop + Border); PopupTop == Height (20)
|
||
// for these OpenUpward=false menus, Border == RetailChromeSprites.Border (5).
|
||
int ly = 20 + RetailChromeSprites.Border + row * (int)PlainRowHeight + (int)(PlainRowHeight / 2);
|
||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseMove, Data1: 10, Data2: ly)));
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Plain_OpenPopup_GridMode_DrawsFlatFillsSelectedAndHover_NoSpriteResolveCalls()
|
||
{
|
||
int resolveCalls = 0;
|
||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 7, scrollable: false,
|
||
countResolveCall: n => resolveCalls += n);
|
||
menu.Selected = 1; // row 1 is "current"
|
||
OpenAndHover(menu, hoverRow: 2); // row 2 is hovered (not selected)
|
||
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
menu.DrawOverlays(ctx);
|
||
var segs = renderer.DebugSpriteSegmentVerts;
|
||
|
||
Assert.Equal(0, resolveCalls); // no DAT art resolved at all — not even by id
|
||
Assert.Equal(0, QuadCount(segs, 0x0600124Cu)); // retail PopupBgSprite never drawn
|
||
Assert.Equal(0, QuadCount(segs, 0x0600124Du)); // retail ItemHighlightSprite (bakes the checkmark) never drawn
|
||
Assert.Equal(0, QuadCount(segs, 0x0600124Eu)); // retail ItemNormalSprite never drawn
|
||
|
||
float outerTop = menu.Height; // OpenUpward=false
|
||
float outerW = menu.PopupOuterWidth, outerH = menu.PopupOuterHeight;
|
||
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
|
||
|
||
Assert.True(HasFillQuad(segs, 0f, outerTop, outerW, outerH, menu.PlainBackgroundColor),
|
||
"expected the plain popup background fill");
|
||
Assert.True(HasFillQuad(segs, inX, inY + 1 * PlainRowHeight, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
|
||
"expected row 1 (selected/current) filled with PlainSelectedColor");
|
||
Assert.True(HasFillQuad(segs, inX, inY + 2 * PlainRowHeight, PlainColumnWidth, PlainRowHeight, menu.PlainHoverColor),
|
||
"expected row 2 (hovered) filled with PlainHoverColor");
|
||
|
||
// background(1) + outline(4 sides) + selected row(1) + hovered row(1) = 7,
|
||
// nothing else untextured.
|
||
Assert.Equal(7, QuadCount(segs, 0u));
|
||
}
|
||
|
||
[Fact]
|
||
public void Plain_OpenPopup_RowText_LeftAlignedAtPlainPadding()
|
||
{
|
||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 1, rowsPerColumn: 7, scrollable: false);
|
||
OpenAndHover(menu);
|
||
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
menu.DrawOverlays(ctx);
|
||
|
||
// Item 0's label is "W" — the one glyph MakeFont() defines — so exactly
|
||
// one FontTexture quad renders, at column 0's PlainPadding inset (no
|
||
// authored TextIndent/centering in plain mode).
|
||
var glyphSeg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == FontTexture);
|
||
Assert.Equal(RetailChromeSprites.Border + UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
|
||
}
|
||
|
||
// ── Owner live-client report 2026-09-07 ("For scrollable dropdown or the
|
||
// meta window we use the same assets as we do in for example chat or
|
||
// inventory window"): the plain popup's SCROLLBAR now draws retail's own
|
||
// chrome (the exact sprite ids RetailScrollbarChrome wires onto
|
||
// chat/inventory's own bar) — only the ROWS stayed plain. These two tests
|
||
// used to pin a fully flat/untextured scrollbar; they now pin the
|
||
// opposite: real sprite draws for the bar, untouched plain fills for the
|
||
// rows, and no visible bar at all once the content fits.
|
||
|
||
[Fact]
|
||
public void Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain()
|
||
{
|
||
int resolveCalls = 0;
|
||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true,
|
||
countResolveCall: n => resolveCalls += n);
|
||
menu.Selected = 0; // row 0 (visible) is "current"
|
||
OpenAndHover(menu);
|
||
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
menu.DrawOverlays(ctx);
|
||
var segs = renderer.DebugSpriteSegmentVerts;
|
||
|
||
Assert.True(menu.PopupScroll.HasOverflow);
|
||
// Track + up + down + thumb top/mid/bottom = 6 resolved sprite ids —
|
||
// the SAME chrome ids the chat/inventory scrollbar resolves through
|
||
// the same SpriteResolve seam, no longer the flat DrawFill-only path.
|
||
Assert.Equal(6, resolveCalls);
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollTrackSprite));
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollUpSprite));
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollDownSprite));
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbTopSprite));
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbSprite));
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbBottomSprite));
|
||
|
||
float outerTop = menu.Height;
|
||
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
|
||
|
||
// The rows are untouched by the chrome swap: still a plain fill, no
|
||
// DAT row/checkbox art at all (RetailButtonArt=false's own contract).
|
||
Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
|
||
"expected visible row 0 (selected/current) still filled with PlainSelectedColor");
|
||
Assert.Equal(0, QuadCount(segs, menu.ItemHighlightSprite));
|
||
Assert.Equal(0, QuadCount(segs, menu.ItemNormalSprite));
|
||
|
||
// popup bg(1)+outline(4) + selected row(1) = 6 untextured quads;
|
||
// the scrollbar itself no longer contributes any (it is all sprite
|
||
// draws now).
|
||
Assert.Equal(6, QuadCount(segs, 0u));
|
||
}
|
||
|
||
[Fact]
|
||
public void Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll()
|
||
{
|
||
int resolveCalls = 0;
|
||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true,
|
||
countResolveCall: n => resolveCalls += n);
|
||
OpenAndHover(menu);
|
||
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
menu.DrawOverlays(ctx);
|
||
var segs = renderer.DebugSpriteSegmentVerts;
|
||
|
||
Assert.False(menu.PopupScroll.HasOverflow);
|
||
|
||
// Content-fits still draws the track + up/down buttons (retail's own
|
||
// proportion-0x88-defaults-to-1.0 rule — a content-fits bar shows a
|
||
// full-track thumb elsewhere in this class), but no thumb: 3 resolves.
|
||
Assert.Equal(3, resolveCalls);
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollTrackSprite));
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollUpSprite));
|
||
Assert.Equal(1, QuadCount(segs, menu.ScrollDownSprite));
|
||
Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite));
|
||
|
||
// popup bg(1)+outline(4) = 5 untextured quads (nothing
|
||
// selected/hovered here either, and the scrollbar draws no fills).
|
||
Assert.Equal(5, QuadCount(segs, 0u));
|
||
}
|
||
|
||
[Fact]
|
||
public void Plain_OpenPopup_HitTesting_SelectsHoveredRow_ClosesPopup()
|
||
{
|
||
// The new hover-tracking MouseMove handling must not change what a
|
||
// MouseDown on the same row does — same rows, same scroll, same pick.
|
||
object? picked = null;
|
||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 7, scrollable: false);
|
||
menu.OnSelect = p => picked = p;
|
||
OpenAndHover(menu, hoverRow: 2);
|
||
|
||
int ly = 20 + RetailChromeSprites.Border + 2 * (int)PlainRowHeight + (int)(PlainRowHeight / 2);
|
||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: ly)));
|
||
|
||
Assert.Equal(2, picked);
|
||
Assert.False(menu.IsOpen);
|
||
}
|
||
|
||
[Fact]
|
||
public void Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden()
|
||
{
|
||
// A golden pin for the OPEN popup on a retail-styled (RetailButtonArt=true,
|
||
// the class default) menu — proves the S7-follow-up refactor of
|
||
// OnDrawOverlay (adding the plain branch) left the retail branch
|
||
// byte-identical: same bevel, same panel-fill sprite, same per-row
|
||
// highlight/normal sprite, and critically NO untextured fill anywhere
|
||
// (the plain path is a fully separate branch, never blended in).
|
||
var menu = MakePopupMenu(retailButtonArt: true, itemCount: 2, rowsPerColumn: 7, scrollable: false);
|
||
menu.Selected = 1;
|
||
OpenAndHover(menu);
|
||
|
||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||
menu.DrawOverlays(ctx);
|
||
var segs = renderer.DebugSpriteSegmentVerts;
|
||
|
||
Assert.Equal(1, QuadCount(segs, RetailChromeSprites.CenterFill)); // bevel drawn
|
||
Assert.Equal(1, QuadCount(segs, 0x0600124Cu)); // PopupBgSprite panel fill
|
||
Assert.Equal(1, QuadCount(segs, 0x0600124Du)); // ItemHighlightSprite (row 1, selected)
|
||
Assert.Equal(1, QuadCount(segs, 0x0600124Eu)); // ItemNormalSprite (row 0)
|
||
Assert.Equal(0, QuadCount(segs, 0u)); // no untextured fill in the retail path
|
||
}
|
||
}
|