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