acdream/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
Erik 91c1962b0d fix #416 #415: the retail button state/media machine — roster hover highlight clears; probe wait verbs bind without an artifact dir
#416 (char-select roster highlight never cleared on hover-leave): three
decomp-grounded mechanisms replace the media-keyed _availableStates
approximation.
- UIElement_Button::UpdateState_ @0x00471CF0: the button machine commits
  ONLY states authored on the button's OWN ElementDesc (AccessStateDesc
  gate); unauthored requests no-op, preserving custom semantic states.
- UIElement::SetState @0x00464E70: an unauthored state id is coerced to
  state 0 (the unnamed base state) and committed — ported into
  UiDatElement.TrySetRetailState with the base-descriptor PassToChildren
  cascade arm.
- The SetState media rule @0x004651c0: a committed state replaces the
  playing media ONLY when its media array is non-empty. UiButton now keeps
  per-face-segment media states under that rule (segments model retail's
  PassToChildren children), and LayoutImporter records the raw MediaCount
  including the File=0 draw-nothing images the drawable filter drops —
  the roster bar children's base state is exactly such an image, and it is
  what clears the bar.
The row template truth (probe, installed DAT): the row authors EMPTY
Normal/rollover/Highlight descriptors with PassToChildren; the three bar
children author rollover/Highlight media, NO Normal state, and a File=0
base image. An empty-media Normal_pressed still never blanks a Normal-art
button (the media rule keeps the previous art — the exact behavior the
old gate approximated), and the Appearance spins' property-only Highlight
now genuinely commits: label recolors, arrow art lingers — the retail
split AP-222 approximated with a requested-keyed label hack, now retired.
Live-verified at char select: hover +alex shows the grey bar, moving off
clears it, the selected row keeps its amber bar.

#415 (probe wait world-* verbs dead): the filed snapshot-reset diagnosis
was wrong — the automation bridge simply never bound without
ACDREAM_AUTOMATION_ARTIFACT_DIR. A facts-only
WorldRevealFactsAutomationRuntime now binds whenever the retained UI
exists; checkpoint/screenshot verbs still require the artifact directory
and now report that instead of a generic timeout.

App tests 5568/3 skips, Runtime 1756/0, UI.Abstractions 926/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:25:36 +02:00

304 lines
12 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.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;
namespace AcDream.App.Tests.UI.Layout;
public class UiDatElementTests
{
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static (TextRenderer renderer, UiRenderContext ctx) BuildRenderContext()
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(1920f, 1080f));
var ctx = new UiRenderContext(renderer, new Vector2(1920f, 1080f));
return (renderer, ctx);
}
/// <summary>
/// Retail's blit is copy-or-tile only (Graphic::Draw @0x00693b20 — no
/// stretch mode exists), so an element grown past its media's native size
/// tiles: u1 = Width/tw. Whole-screen stretching is NOT this layer's job —
/// it happens uniformly at UiRoot.FixedCanvasSize / TextRenderer.CanvasScale
/// (register AD-98), covered by the test below.
/// </summary>
[Fact]
public void OwnBackground_TilesUvPastOne_WhenRectExceedsNativeSize()
{
var info = new ElementInfo { Width = 1920, Height = 1080 };
info.StateMedia[""] = (0x06007576u, 1);
var e = new UiDatElement(info, _ => (7u, 800, 600))
{
Left = 0,
Top = 0,
Width = 1920,
Height = 1080,
};
(TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext();
e.DrawSelfAndChildren(ctx);
var (texture, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts);
Assert.Equal(7u, texture);
// Vertex layout: x,y,u,v,r,g,b,a, 6 verts/quad. TextRenderer.AppendQuad emits
// vertex index 1 as (x+w, y+h, u1, v1) — the (u1,v1) far corner.
float uMax = verts[1 * 8 + 2];
float vMax = verts[1 * 8 + 3];
Assert.Equal(1920f / 800f, uMax, 3);
Assert.Equal(1080f / 600f, vMax, 3);
}
/// <summary>
/// AD-98 whole-canvas stretch: with the renderer's CanvasScale set (the
/// char-select 800×600 canvas on a 1920×1080 window), an element drawn at
/// authored size emits a quad scaled by exactly (2.4, 1.8) in GEOMETRY while
/// its UVs stay authored (0..1 here) — one stretched image, no tiling, the
/// same math retail's present-time frame stretch produces.
/// </summary>
[Fact]
public void CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored()
{
var info = new ElementInfo { Width = 800, Height = 600 };
info.StateMedia[""] = (0x06007576u, 1);
var e = new UiDatElement(info, _ => (7u, 800, 600))
{
Left = 0,
Top = 0,
Width = 800,
Height = 600,
};
(TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext();
renderer.CanvasScale = new Vector2(1920f / 800f, 1080f / 600f);
try
{
e.DrawSelfAndChildren(ctx);
}
finally
{
renderer.CanvasScale = Vector2.One;
}
var (_, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts);
// Far corner (vertex 1): geometry scaled to the window, UVs authored.
Assert.Equal(1920f, verts[1 * 8 + 0], 3);
Assert.Equal(1080f, verts[1 * 8 + 1], 3);
Assert.Equal(1f, verts[1 * 8 + 2], 3);
Assert.Equal(1f, verts[1 * 8 + 3], 3);
}
[Fact]
public void ActiveMedia_PrefersNamedStateOverDirect()
{
var info = new ElementInfo();
info.StateMedia[""] = (0x06000001, 1); // DirectState (DrawMode Normal=1)
info.StateMedia["ShowDetail"] = (0x06000002, 3); // named (Alphablend=3)
var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "ShowDetail" };
Assert.Equal(0x06000002u, e.ActiveMedia().File);
Assert.Equal(3, e.ActiveMedia().DrawMode);
e.ActiveState = "";
Assert.Equal(0x06000001u, e.ActiveMedia().File);
Assert.Equal(1, e.ActiveMedia().DrawMode);
}
[Fact]
public void ActiveMedia_NoMedia_ReturnsZero()
{
var e = new UiDatElement(new ElementInfo(), _ => (0, 0, 0));
Assert.Equal(0u, e.ActiveMedia().File);
Assert.Equal(0, e.ActiveMedia().DrawMode);
}
[Fact]
public void ActiveMedia_MissingNamedState_FallsBackToDirect()
{
var info = new ElementInfo();
info.StateMedia[""] = (0x06000005, 1);
var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "NoSuchState" };
Assert.Equal(0x06000005u, e.ActiveMedia().File);
}
// ── G1 tests: DefaultStateName + "Normal" implicit default ───────────────
/// <summary>
/// Task G1 change 5: when an element has no DefaultStateName but does have a "Normal"
/// state sprite, the ctor should default ActiveState to "Normal" so the element
/// renders its normal-state sprite without requiring explicit state assignment.
/// </summary>
[Fact]
public void UiDatElement_DefaultsActiveStateToNormal_WhenNormalPresent()
{
var info = new ElementInfo();
info.StateMedia["Normal"] = (0x0000AAAAu, 1);
info.StateMedia["Hover"] = (0x0000BBBBu, 1);
var e = new UiDatElement(info, _ => (0, 0, 0));
// Should have defaulted to "Normal" state.
Assert.Equal(0x0000AAAAu, e.ActiveMedia().File);
}
/// <summary>
/// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"),
/// it takes priority over the "Normal" implicit default.
/// </summary>
[Fact]
public void UiDatElement_DefaultsActiveStateToDefaultStateName_WhenSet()
{
var info = new ElementInfo { DefaultStateName = "Minimized" };
info.StateMedia["Minimized"] = (0x0000BBBBu, 1);
info.StateMedia["Maximized"] = (0x0000CCCCu, 1);
info.StateMedia["Normal"] = (0x0000DDDDu, 1);
var e = new UiDatElement(info, _ => (0, 0, 0));
// DefaultStateName "Minimized" wins over "Normal" implicit default.
Assert.Equal(0x0000BBBBu, e.ActiveMedia().File);
}
/// <summary>
/// Task G1 change 5: elements with only a DirectState sprite and no "Normal" state
/// should still default to "" (DirectState) — no regression for chrome/grip elements.
/// </summary>
[Fact]
public void UiDatElement_NoDefaultStateName_NoNormal_DefaultsToDirectState()
{
var info = new ElementInfo();
info.StateMedia[""] = (0x06007777u, 1); // DirectState only (e.g. vitals chrome corner)
var e = new UiDatElement(info, _ => (0, 0, 0));
// No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState).
Assert.Equal(0x06007777u, e.ActiveMedia().File);
}
[Fact]
public void NumericStateBridge_SetsNamedStateWithoutChangingGeometry()
{
var info = new ElementInfo { X = 4, Y = 5, Width = 30, Height = 40 };
info.States[RetailUiStateIds.ShowDetail] = new UiStateInfo
{
Id = RetailUiStateIds.ShowDetail,
Name = "ShowDetail",
Image = new UiImageMedia(0x06000009u, 3),
};
info.StateMedia["ShowDetail"] = (0x06000009u, 3);
var element = new UiDatElement(info, _ => (0u, 0, 0))
{
Left = info.X,
Top = info.Y,
Width = info.Width,
Height = info.Height,
};
Assert.True(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
Assert.Equal(RetailUiStateIds.ShowDetail, element.ActiveRetailStateId);
Assert.Equal((0x06000009u, 3), element.ActiveMedia());
Assert.Equal((4f, 5f, 30f, 40f),
(element.Left, element.Top, element.Width, element.Height));
}
[Fact]
public void NumericStateBridge_MissingStateCommitsBaseState()
{
// Retail UIElement::SetState @0x00464E70 coerces an UNAUTHORED state
// id to state 0 (the unnamed base state) and commits it — it never
// preserves the current state (#416; this test previously codified
// the pre-decomp misreading).
var info = new ElementInfo { DefaultStateName = "Normal" };
info.StateMedia["Normal"] = (0x06000001u, 1);
var element = new UiDatElement(info, _ => (0u, 0, 0));
Assert.True(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
Assert.Equal("", element.ActiveState);
}
/// <summary>
/// #416 — the character-select roster row shape: the highlight-bar
/// children (0x10000481/82/83 in 0x21000004) author Normal_rollover
/// media but NO 'Normal' state, so the row's PassToChildren hover-leave
/// cascade commits an unauthored Normal on them. Retail's state-0 arm
/// clears the bar; the old refusal latched it highlighted forever.
/// </summary>
[Fact]
public void TrySetRetailState_UnauthoredNormal_ClearsRolloverMedia()
{
var info = new ElementInfo();
info.States[UiStateInfo.DirectStateId] =
new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
var element = new UiDatElement(info, _ => (0u, 0, 0));
Assert.True(element.TrySetRetailState(2u));
Assert.Equal("Normal_rollover", element.ActiveState);
Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal("", element.ActiveState);
Assert.Equal((0u, 0), element.ActiveMedia());
}
/// <summary>
/// The state-0 fallback must NOT honor a base-DirectState Invisible
/// (dat 0x3B) — that is the #408 construction-time class, gated
/// separately; retail's per-state Invisible honor applies to NAMED
/// authored states only.
/// </summary>
[Fact]
public void TrySetRetailState_UnauthoredStateFallback_DoesNotHonorBaseInvisible()
{
var info = new ElementInfo();
var baseState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
baseState.Properties.Values[0x3Bu] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
info.States[UiStateInfo.DirectStateId] = baseState;
info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
var element = new UiDatElement(info, _ => (0u, 0, 0));
Assert.True(element.Visible);
Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal("", element.ActiveState);
Assert.True(element.Visible);
}
/// <summary>
/// Retail's state-0 arm cascades state 0 to children when the BASE
/// descriptor authors PassToChildren (m_desc.m_bPassToChildren,
/// @0x00464eca).
/// </summary>
[Fact]
public void TrySetRetailState_UnauthoredStateFallback_CascadesBasePerBaseDescriptor()
{
var childInfo = new ElementInfo();
childInfo.StateMedia[""] = (0x06000002u, 1);
childInfo.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
childInfo.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
var child = new UiDatElement(childInfo, _ => (0u, 0, 0));
Assert.True(child.TrySetRetailState(2u));
var parentInfo = new ElementInfo();
parentInfo.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
PassToChildren = true,
};
var parent = new UiDatElement(parentInfo, _ => (0u, 0, 0));
parent.AddChild(child);
Assert.True(parent.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal("", child.ActiveState);
}
}