acdream/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs
Erik cc11e077a4 feat(vtank): slice 7 fix — UiMenu plain closed state, gold art opt-in
Owner live-client report 2026-09-07: "Those BIG gold/yellow buttons HAS
to go. That is not how vtank looks." VTank/Decal's HudCombo is a flat
dark box (background/border matching its own HudList) with a
left-aligned value and a small down-arrow — retail's gold pushbutton
art (the 3-slice LED-arrow face UiMenu.DrawButtonFace draws) is a
different widget family entirely.

Adds UiMenu.RetailButtonArt (default true, so every existing
non-markup UiMenu caller — chat's channel menu, vendor's category
dropdown, Config's option menus, the retail confirmation dialog, and
DatWidgetFactory's generic Type-6 element — keeps its byte-identical
retail face) plus DrawPlainClosedState/DrawPlainTriangle, which draw
the flat box entirely with UiRenderContext.DrawFill/DrawRectOutline (no
sprite or DAT quad at all) using colors mirroring UiMarkupList's own
chrome (background 0,0,0,0.92; border 0.46,0.37,0.16,1; text
0.91,0.87,0.76,1). Open/pressed only tints the border
(0.70,0.58,0.24,1) — never a sprite swap.

Mutation check: temporarily disabled the new `if (!RetailButtonArt)`
branch in OnDraw (reverting it to the pre-fix unconditional retail
path) — 3 of the 6 new UiMenuPlainStyleTests failed exactly as
expected (Plain_ClosedState_DrawsNoTexturedFaceQuad,
Plain_ClosedState_DrawsFillOutlineTextAndTriangle,
Plain_ClosedState_TriangleSitsRightAligned_TextSitsAtListPadding); the
3 retail-path/default-value tests kept passing since they don't
exercise the removed branch. Restored before committing.

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

211 lines
8.8 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));
}
}