acdream/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs
Erik 5bdf2fa395 fix(vt): plain-style menu popup — no retail gradient/checkmark/gold scrollbar
Owner live-client report 2026-09-07 ("Drop down menus look horrible, there
is also a checkmark on the text there"): the prior S7 fix only replaced the
CLOSED-state button face when RetailButtonArt=false. The OPEN popup still
drew retail's sprite art unconditionally — the tan/orange gradient panel
(PopupBgSprite), the per-row highlight sprite (which bakes a checkbox/
checkmark glyph into its leftmost ~17px, per TextIndent's doc comment), and
the ornate gold scrollbar chrome — regardless of the menu's style.

UiMenu.OnDrawOverlay now branches on RetailButtonArt before ever reading
SpriteResolve: plain mode draws through two new methods (DrawGridPopupPlain,
DrawScrollablePopupPlain) that use only DrawFill/DrawRectOutline — flat
background + 1px border, the current entry filled like a list selection
(PlainSelectedColor, same value as UiMarkupList.SelectedColor), a new
hover fill (PlainHoverColor) for the row under the pointer, and left-aligned
text at PlainPadding. No checkmark is possible by construction since plain
mode never resolves or draws any sprite. Hover tracking needed a small new
mechanism: UiMenu.ReceivesHoverMouseMove now returns true while a plain
popup is open, so UiRoot's hover dispatch keeps delivering MouseMove to
_hoveredPopupIndex (reset on every open/close transition and on
HoverLeave). Scrollbar overflow (DrawPopupScrollbarPlain) draws a 1px-
bordered track and a flat thumb, both in PlainBorderColor, sharing the
exact UiScrollbar.ThumbRect geometry the hit-test math already uses — no
DAT track/thumb/arrow-button art. Hit-testing (OnHitTest/OnEvent's
MouseDown pick logic) is untouched; the retail sprite branch is now a
separate, unmodified path proven byte-identical by a new golden test.

Mutation proof: reverting UiMenu.cs alone (keeping the new tests) fails the
build outright — the six new tests reference PlainSelectedColor/
PlainHoverColor, which only exist after this change (CS1061 with the old
class). Filters run: AcDream.App.Tests Markup|UiMenu|Menu|Scrollbar
(242 passed, 3 pre-existing unrelated Lane=Manual live-DAT-probe failures
that require ACDREAM_PROBE_LIVE_MOUNT=1 and predate this change) and
AcDream.Plugins.MossTank.Tests Markup (9/9 passed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 10:19:44 +02:00

450 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>&lt;menu&gt;</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);
}
[Fact]
public void Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt()
{
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);
Assert.Equal(0, resolveCalls);
Assert.Equal(0, QuadCount(segs, menu.ScrollTrackSprite));
Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite));
float outerTop = menu.Height;
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
float scrollbarX = inX + PlainColumnWidth;
Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
"expected visible row 0 (selected/current) filled with PlainSelectedColor");
Assert.True(HasFillQuad(segs, scrollbarX, inY, menu.ScrollbarWidth, 5 * PlainRowHeight, menu.PlainBackgroundColor),
"expected the scrollbar track background fill");
// popup bg(1)+outline(4) + selected row(1) + scrollbar bg(1)+outline(4) + thumb(1) = 12.
Assert.Equal(12, QuadCount(segs, 0u));
}
[Fact]
public void Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb()
{
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true);
OpenAndHover(menu);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawOverlays(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
Assert.False(menu.PopupScroll.HasOverflow);
// popup bg(1)+outline(4) + scrollbar bg(1)+outline(4) = 10, no thumb quad
// (nothing selected/hovered here either).
Assert.Equal(10, 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
}
}