acdream/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
Erik f7aa8e0eb7
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s
fix: complete retail parity stability pass
2026-08-28 20:01:39 +02:00

303 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>
/// #408: retail's unauthored-state fallback commits state 0 and applies
/// its properties, including Invisible, through the same OnSetAttribute
/// path as a named state.
/// </summary>
[Fact]
public void TrySetRetailState_UnauthoredStateFallback_RestoresBaseInvisible()
{
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));
element.Visible = true;
Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal("", element.ActiveState);
Assert.False(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);
}
}