173 lines
8.2 KiB
C#
173 lines
8.2 KiB
C#
using AcDream.App.UI;
|
||
using AcDream.App.UI.Layout;
|
||
|
||
namespace AcDream.App.Tests.UI;
|
||
|
||
/// <summary>
|
||
/// #372 regression: the Options panel's Character/Chat/Config tabs rendered
|
||
/// BLANK at the first connected gate while Gameplay (which has no ListBox)
|
||
/// worked. Root cause: <see cref="UiTemplateListBox"/> creates its row
|
||
/// viewport at 0×0 with Left|Top|Right|Bottom fill-anchors; the anchor
|
||
/// baseline captured from that 0-size rect makes
|
||
/// <c>ComputeAnchoredRect</c> keep it 0×0 forever (mR=parentW, so
|
||
/// w=parentW-mR-mL=0), and <see cref="UiScrollablePanel.LayoutScrollableChildren"/>
|
||
/// then culls every row (top+height ≤ 0 is false). These tests pin that the
|
||
/// viewport fills its ListBox and the rows stay visible after a layout pass.
|
||
/// </summary>
|
||
public sealed class UiTemplateListBoxViewportTests
|
||
{
|
||
private static UiTemplateListBox MakeListBox(float width, float height)
|
||
{
|
||
// A ListBox element authoring one template entry, sized like the
|
||
// Options-panel Character ListBox (0x100001FA, 276×560 authored).
|
||
var info = new ElementInfo { Id = 0x100001FAu, Type = 5u };
|
||
var box = new UiTemplateListBox(
|
||
info,
|
||
_ => (0u, 0, 0),
|
||
new[] { new UiTemplateListEntry(0x2100002Bu, 0x10000218u) },
|
||
scrollbarElementId: 0x100001FBu)
|
||
{
|
||
Width = width,
|
||
Height = height,
|
||
};
|
||
return box;
|
||
}
|
||
|
||
[Fact]
|
||
public void Viewport_FillsTheListBox_NotCollapsedToZero()
|
||
{
|
||
var box = MakeListBox(276f, 560f);
|
||
// Add a couple of rows via the prebuilt path (no DAT needed).
|
||
box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f });
|
||
box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f });
|
||
|
||
UiScrollablePanel? viewport = box.ViewportForTest;
|
||
Assert.NotNull(viewport);
|
||
|
||
// Drive the per-frame layout the draw traversal runs: the parent
|
||
// ListBox re-anchors its children (UiElement.DrawSelfAndChildren
|
||
// line ~500), sizing the fill-anchored viewport.
|
||
viewport!.ApplyAnchor(box.Width, box.Height);
|
||
|
||
// The whole point of #372: the viewport must FILL the ListBox, not
|
||
// collapse. Pre-fix this was 0×0.
|
||
Assert.Equal(276f, viewport.Width, 3);
|
||
Assert.Equal(560f, viewport.Height, 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void Rows_StayVisible_AfterLayout_WhenTheyFitTheViewport()
|
||
{
|
||
var box = MakeListBox(276f, 560f);
|
||
var row0 = new UiText { Width = 260f, Height = 20f };
|
||
var row1 = new UiText { Width = 260f, Height = 20f };
|
||
var row2 = new UiText { Width = 260f, Height = 20f };
|
||
box.AddPrebuiltRow(row0);
|
||
box.AddPrebuiltRow(row1);
|
||
box.AddPrebuiltRow(row2);
|
||
|
||
UiScrollablePanel viewport = box.ViewportForTest!;
|
||
viewport.ApplyAnchor(box.Width, box.Height); // size the viewport
|
||
viewport.LayoutScrollableChildren(); // the cull pass (runs each OnDraw)
|
||
|
||
// All three rows sit at y 0/20/40 inside a 560px viewport → visible.
|
||
// Pre-fix the viewport was 0px tall and every row was culled → blank tab.
|
||
Assert.True(row0.Visible, "row 0 culled — the #372 blank-tab bug");
|
||
Assert.True(row1.Visible, "row 1 culled — the #372 blank-tab bug");
|
||
Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug");
|
||
}
|
||
|
||
[Fact]
|
||
public void RemoveTail_PreservesPrefixAndRestacksFutureRows()
|
||
{
|
||
var box = MakeListBox(276f, 560f);
|
||
var prefix = new UiText { Width = 260f, Height = 20f };
|
||
var removedA = new UiText { Width = 260f, Height = 30f };
|
||
var removedB = new UiText { Width = 260f, Height = 40f };
|
||
box.AddPrebuiltRow(prefix);
|
||
box.AddPrebuiltRow(removedA);
|
||
box.AddPrebuiltRow(removedB);
|
||
|
||
box.RemoveTail(1);
|
||
var replacement = new UiText { Width = 260f, Height = 25f };
|
||
box.AddPrebuiltRow(replacement);
|
||
|
||
Assert.Equal(2, box.ItemCount);
|
||
Assert.Same(prefix, box.ViewportForTest!.Children[0]);
|
||
Assert.Same(replacement, box.ViewportForTest.Children[1]);
|
||
Assert.Equal(20f, replacement.Top);
|
||
Assert.Null(removedA.Parent);
|
||
Assert.Null(removedB.Parent);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #412-class regression (2026-08-16, overnight hover/UI round, Batch A bug
|
||
/// 2): the Options panel's Config tab escaped past the window frame — the
|
||
/// footer sitting mid-panel with further rows drawing below the window's
|
||
/// bottom edge. #372's own fixture above never exercises this because
|
||
/// <see cref="MakeListBox"/> gives the ListBox no parent — its own size
|
||
/// never changes after the viewport is seeded. The real Options mount is
|
||
/// different: this ListBox is itself a DAT-imported <see cref="UiElement"/>
|
||
/// carrying a real <see cref="UiLayoutPolicy"/> from its authored parent
|
||
/// (the Config page slot), and a page controller's Bind — which lazily
|
||
/// creates this viewport — runs BEFORE the tree's first draw frame, i.e.
|
||
/// before the ListBox has ever shrunk to fit its actual (smaller than
|
||
/// authored) container. This reproduces that ordering with a real
|
||
/// LayoutPolicy-driven parent standing in for the page slot.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout()
|
||
{
|
||
// A stand-in for the Config page slot: authored 298×575 against an
|
||
// authored 300×600 design canvas (live-DAT-measured values), but its
|
||
// real mounted container is only 300×362 — exactly retail's
|
||
// UIElement::UpdateForParentSizeChange four-edge policy (L=T=R=B=1,
|
||
// "preserve original margin on every edge").
|
||
var slotPolicy = new UiLayoutPolicy(
|
||
leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1,
|
||
originalChild: UiPixelRect.FromPositionAndSize(2, 25, 298, 575),
|
||
originalParent: UiPixelRect.FromPositionAndSize(0, 0, 300, 600));
|
||
var slot = new UiPanel
|
||
{
|
||
Left = 2, Top = 25, Width = 298, Height = 575,
|
||
LayoutPolicy = slotPolicy,
|
||
};
|
||
var root = new UiPanel { Width = 300, Height = 362 };
|
||
root.AddChild(slot);
|
||
|
||
// The ListBox itself ALSO carries a real LayoutPolicy (live-DAT
|
||
// measured: authored 276×560 against the slot's own 298×575 design
|
||
// extent) — this is what shrinks it out from under the viewport.
|
||
var box = MakeListBox(276f, 560f);
|
||
var listBoxPolicy = new UiLayoutPolicy(
|
||
leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1,
|
||
originalChild: UiPixelRect.FromPositionAndSize(0, 0, 276, 560),
|
||
originalParent: UiPixelRect.FromPositionAndSize(0, 0, 298, 575));
|
||
box.LayoutPolicy = listBoxPolicy;
|
||
slot.AddChild(box);
|
||
|
||
// Seed the viewport with rows BEFORE any layout pass has ever run —
|
||
// exactly ConfigOptionsPageController.Bind's own ordering (it runs
|
||
// before RetailWindowFrame.Mount's first draw frame).
|
||
box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f });
|
||
UiScrollablePanel viewport = box.ViewportForTest!;
|
||
|
||
// Drive ONE simulated draw-frame's top-down ApplyAnchor walk — the
|
||
// SAME order DrawSelfAndChildren runs every frame: parent before
|
||
// children, all the way down.
|
||
slot.ApplyAnchor(root.Width, root.Height); // slot shrinks: 575 -> ~337
|
||
box.ApplyAnchor(slot.Width, slot.Height); // listbox shrinks: 560 -> ~297 (still ahead of the viewport)
|
||
viewport.ApplyAnchor(box.Width, box.Height); // the viewport's FIRST EVER ApplyAnchor call
|
||
|
||
// Pre-fix: the viewport's legacy anchor baseline captured a NEGATIVE
|
||
// bottom margin against the ALREADY-SHRUNK ListBox (parentH(~297) -
|
||
// (0+560) < 0), which ComputeAnchoredRect's stretch math preserves
|
||
// forever — the viewport stayed locked at its original 560px height,
|
||
// clipping rows to a bound retail never actually gave it on screen.
|
||
Assert.Equal(box.Height, viewport.Height, 3);
|
||
Assert.True(
|
||
viewport.Height < 400f,
|
||
$"viewport height {viewport.Height} did not shrink with its ListBox "
|
||
+ "(560 == the pre-fix stale-capture bug)");
|
||
}
|
||
}
|