acdream/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
Erik 996cd73675 fix(CT6): fix round — chrome-inclusive host clamp (BLOCKER B1) + 372px mount default
Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:

BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, clamping MinWidth to 320 while the
window's actual mounted outer width stayed 310 — silently below its own
minimum until RetailWindowManager.ResizeTo forcibly widened it despite
ResizeX=false. Fixed with a new
RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame opt-out
(chrome inset = 0 for constraint resolution only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.

S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.

S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.

S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.

N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.

N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.

CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.

Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).

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

409 lines
16 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 AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailWindowFrameTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Theory]
[InlineData(9, 459f, 288f)]
[InlineData(13, 587f, 416f)]
[InlineData(18, 747f, 576f)]
public void CombatRoot_FitsRequestedFavoriteSlotCapacity(
int visibleSlots,
float expectedRootWidth,
float expectedListWidth)
{
ImportedLayout combat = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
float width = RetailCombatLayout.FitFavoriteSlots(combat, visibleSlots);
Assert.Equal(expectedRootWidth, width);
Assert.Equal(expectedListWidth, combat.FindElement(
SpellcastingUiController.FavoriteListId)!.Width);
}
[Fact]
public void CombatRoot_EighteenSlotsKeepArrowsAndCastInsideFrame()
{
ImportedLayout combat = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(combat);
UiElement group = combat.FindElement(0x100000AAu)!;
UiElement scrollbar = combat.FindElement(SpellcastingUiController.FavoriteScrollbarId)!;
UiElement cast = combat.FindElement(SpellcastingUiController.CastButtonId)!;
Assert.Equal(747f, combat.Root.Width);
Assert.Equal((40f, 622f), (group.Left, group.Width));
Assert.Equal(622f, scrollbar.Width);
Assert.Equal((662f, 75f), (cast.Left, cast.Width));
Assert.Equal(737f, cast.Left + cast.Width);
}
[Fact]
public void ImportedChrome_MountsRootWithoutASecondFrame()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 220, Height = 90 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "vitals",
Chrome = RetailWindowChrome.Imported,
Left = 12,
Top = 18,
ResizeX = true,
ResizeY = false,
MinWidth = 40,
ContentClickThrough = false,
});
Assert.Same(content, handle.OuterFrame);
Assert.Same(content, handle.ContentRoot);
Assert.Same(root, content.Parent);
Assert.Equal((12f, 18f, 220f, 90f),
(content.Left, content.Top, content.Width, content.Height));
Assert.True(content.Draggable);
Assert.True(content.Resizable);
Assert.True(content.ResizeX);
Assert.False(content.ResizeY);
Assert.Equal(40f, content.MinWidth);
}
[Fact]
public void ImportedChrome_CanPreserveFixedHudDesktopAnchors()
{
var root = new UiRoot { Width = 1920, Height = 1080 };
var content = new UiPanel { Width = 1730, Height = 90 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "combat",
Chrome = RetailWindowChrome.Imported,
Left = 0f,
Top = 980f,
OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom,
});
Assert.Equal(
AnchorEdges.Left | AnchorEdges.Bottom,
handle.OuterFrame.Anchors);
}
[Fact]
public void NineSlice_UsesCroppedContentAndDatHeightConstraints()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 800, Height = 100 };
var constraints = Constraints(minHeight: 100, maxHeight: 360);
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "chat",
Chrome = RetailWindowChrome.NineSlice,
Left = 10,
Top = 440,
ContentWidth = 490,
DatConstraintSource = constraints,
MinWidth = 200,
Opacity = 0.75f,
});
var frame = Assert.IsType<UiNineSlicePanel>(handle.OuterFrame);
Assert.Same(content, handle.ContentRoot);
Assert.Same(frame, content.Parent);
Assert.Equal((500f, 110f), (frame.Width, frame.Height));
Assert.Equal((5f, 5f, 490f, 100f),
(content.Left, content.Top, content.Width, content.Height));
Assert.Equal(200f, frame.MinWidth);
Assert.Equal(110f, frame.MinHeight);
Assert.Equal(370f, frame.MaxHeight);
Assert.Equal(0.75f, frame.Opacity);
}
/// <summary>
/// CT6 shared-mechanism regression pin: the authored-min/max clamp exists
/// in exactly ONE place — <see cref="UiElement.MinWidth"/>/<see cref="UiElement.MaxWidth"/>/
/// <see cref="UiElement.MinHeight"/>/<see cref="UiElement.MaxHeight"/>,
/// set once at <see cref="RetailWindowFrame.Mount"/> from
/// <see cref="RetailWindowFrame.Options.DatConstraintSource"/> and then
/// enforced identically by <c>RetailWindowManager.ResizeTo</c> (this
/// test), the interactive drag path in <c>UiRoot</c>, and the persisted-
/// geometry restore clamp in <c>RetailWindowLayoutPersistence.Apply</c>.
///
/// <para>CT6 fix round (2026-08-25, N4 rename): this test's ORIGINAL name
/// (<c>NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds</c>)
/// overclaimed. It mounts a content-shaped root (490×100, roughly chat's
/// own default extent) with a synthetic height-only constraint source
/// (<see cref="Constraints"/> sets ONLY 0x3E/0x3C — no width bounds at
/// all) — it exercises the NineSlice wrapper's chrome-inset ARITHMETIC on
/// a content-shaped source, not chat's actual authored DAT contract. Chat
/// itself mounts with <c>Chrome = RetailWindowChrome.Imported</c> (its
/// own root already IS the complete window, chrome included — see
/// <c>RetailUiRuntime.MountChat</c>), so the real chat contract needs
/// ZERO chrome inset, the opposite of what this test's NineSlice
/// wrapping exercises. See
/// <see cref="Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset"/>
/// below for the genuine chat-shaped pin (Chrome=Imported, chat's real
/// 300/100/2000/2000 four-sided constraint).</para>
/// </summary>
[Fact]
public void NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize()
{
var root = new UiRoot { Width = 1920, Height = 1080 };
var content = new UiPanel { Width = 490, Height = 100 };
var constraints = Constraints(minHeight: 100, maxHeight: 2000);
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "chat-shaped",
Chrome = RetailWindowChrome.NineSlice,
Left = 10,
Top = 20,
ResizeX = false,
ResizeY = true,
DatConstraintSource = constraints,
});
// Request far below the authored minimum (100 + 10px chrome inset =
// 110) — the clamp must hold, not the requested value.
Assert.True(handle.ResizeTo(handle.Width, 5f));
Assert.Equal(110f, handle.Height);
// Request far above the authored maximum (2000 + 10 = 2010).
Assert.True(handle.ResizeTo(handle.Width, 50000f));
Assert.Equal(2010f, handle.Height);
// A request inside the bounds is honored exactly.
Assert.True(handle.ResizeTo(handle.Width, 500f));
Assert.Equal(500f, handle.Height);
}
/// <summary>
/// CT6 fix round (2026-08-25, N4): the true chat-contract pin the
/// renamed test above no longer claims to be. Chat's real LayoutDesc
/// (<c>0x2100006F</c>, root <c>0x10000600</c>) mounts with
/// <c>Chrome = RetailWindowChrome.Imported</c> — its own root already IS
/// the complete retail window, own dragbar/border/resize-grip children
/// included (<c>RetailUiRuntime.MountChat</c>) — and authors real
/// four-sided constraints, MinWidth=300, MinHeight=100, MaxWidth=2000,
/// MaxHeight=2000
/// (<c>CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints</c>).
/// Because Imported chrome applies NO wrapper (<c>inset = 0</c>), the
/// mounted clamp must equal those four DAT values EXACTLY — no chrome
/// inset composed on top, the same "authored value governs verbatim"
/// contract CT6's BLOCKER B1 fix restored for the Character window's
/// chrome-inclusive host source.
/// </summary>
[Fact]
public void Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset()
{
var root = new UiRoot { Width = 1920, Height = 1080 };
var content = new UiPanel { Width = 410, Height = 100 };
var constraints = ChatConstraints();
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "chat",
Chrome = RetailWindowChrome.Imported,
Left = 10,
Top = 440,
ResizeX = true,
ResizeY = true,
DatConstraintSource = constraints,
});
Assert.Equal(300f, handle.OuterFrame.MinWidth);
Assert.Equal(100f, handle.OuterFrame.MinHeight);
Assert.Equal(2000f, handle.OuterFrame.MaxWidth);
Assert.Equal(2000f, handle.OuterFrame.MaxHeight);
// No chrome inset: the clamp must hold at EXACTLY the authored
// 100/2000 bounds, not 110/2010 the way a NineSlice-wrapped source
// would.
Assert.True(handle.ResizeTo(handle.Width, 5f));
Assert.Equal(100f, handle.Height);
Assert.True(handle.ResizeTo(handle.Width, 50000f));
Assert.Equal(2000f, handle.Height);
}
[Fact]
public void NineSlice_CanSupplyBorderWithoutDuplicatingAuthoredCenter()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 300, Height = 362 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root, content, NoTex,
new RetailWindowFrame.Options
{
WindowName = "character-info",
DrawChromeCenter = false,
});
var frame = Assert.IsType<UiNineSlicePanel>(handle.OuterFrame);
Assert.False(frame.DrawCenterFill);
}
[Fact]
public void CollapsibleMount_ReturnsToolbarFrameAndIndependentAxes()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 300, Height = 122 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "toolbar",
Chrome = RetailWindowChrome.CollapsibleNineSlice,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
ContentClickThrough = true,
});
var frame = Assert.IsType<UiCollapsibleFrame>(handle.OuterFrame);
Assert.False(frame.ResizeX);
Assert.True(frame.ResizeY);
Assert.Equal(ResizeEdges.Bottom, frame.ResizableEdges);
Assert.True(frame.ConstrainResizeToParent);
Assert.True(content.ClickThrough);
Assert.False(content.Draggable);
Assert.False(content.Resizable);
}
[Fact]
public void HiddenInventory_RestoredExpandedBeforeFirstDraw_GrowsContentsRows()
{
ImportedLayout layout = FixtureLoader.LoadInventory();
UiItemList grid = Assert.IsType<UiItemList>(
layout.FindElement(InventoryController.ContentsGridId));
grid.Columns = 6;
grid.CellWidth = 32f;
grid.CellHeight = 32f;
grid.Flush();
for (int i = 0; i < 102; i++)
grid.AddItem(new UiItemSlot());
var root = new UiRoot { Width = 1280, Height = 720 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
layout.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Inventory,
Chrome = RetailWindowChrome.NineSlice,
ContentWidth = layout.Root.Width,
ContentHeight = layout.Root.Height,
Visible = false,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
MaxHeight = 560f,
});
// Production installs the controller-owned policies after mounting, while
// inventory is still hidden, and captures their design baseline immediately.
InventoryController.ConfigureResizeLayout(layout);
// Persistence restores the hidden window before its first visible draw.
handle.ResizeTo(handle.Width, 527f);
ApplyAnchors(handle.OuterFrame);
grid.LayoutCells();
Assert.Equal(251f, grid.Height);
Assert.Equal(48, grid.Children.Count(child => child.Visible));
Assert.Equal(224f, grid.GetItem(42)!.Top); // final row intersects 251px view by 27px
}
private static void ApplyAnchors(UiElement parent)
{
foreach (UiElement child in parent.Children)
{
child.ApplyAnchor(parent.Width, parent.Height);
ApplyAnchors(child);
}
}
private static ElementInfo Constraints(int minHeight, int maxHeight)
{
var info = new ElementInfo();
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
direct.Properties.Values[0x3Eu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = minHeight,
};
direct.Properties.Values[0x3Cu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = maxHeight,
};
info.States[UiStateInfo.DirectStateId] = direct;
return info;
}
/// <summary>
/// Synthetic four-sided constraint matching chat's real authored DAT
/// values (<c>CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints</c>):
/// MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000.
/// </summary>
private static ElementInfo ChatConstraints()
{
var info = new ElementInfo();
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
direct.Properties.Values[0x3Fu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = 300,
};
direct.Properties.Values[0x3Eu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = 100,
};
direct.Properties.Values[0x3Du] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = 2000,
};
direct.Properties.Values[0x3Cu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = 2000,
};
info.States[UiStateInfo.DirectStateId] = direct;
return info;
}
}