CT6 (Campaign CT slice 6): the resize clamp source is the SHARED gmPanelUI host (0x100005FE in LayoutDesc 0x2100006E), not 0x2100002E's own root and not the Character/Skills slot 0x1000018E either — live probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are direct children of the host, not the content parent. Decomp chain: UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls UIElement::StartResizing(this->GetParent(), ...), stashing drag state on that parent; UIElement::MouseResizeElement @0x00461130 then reads GetAttribute_Int(this, 0x3C..0x3F) off that same element every mouse-move. RetailUiRuntime.MountCharacter now imports the host element and passes it as RetailWindowFrame.Options.DatConstraintSource, matching the existing MountSideVitals pattern. CharacterStatController.RebuildActiveList now wraps BOTH the Attributes and Skills tabs' rows in the same UiScrollablePanel viewport (previously only Skills got one; Attributes rows had no clipping/scrolling and the shared scrollbar was force-hidden — owner report item 2). The shared scrollbar is now always bound + visible; UiScrollbar's own IsPresentationVisible/IsModelDisabled already draw the correct full-track "disabled" thumb when content fits. This surfaced and fixed a real #372/#412-class anchor-baseline bug: the viewport's Left|Top|Bottom anchor was capturing its baseline margins lazily on its own first ApplyAnchor call, which happens AFTER the ListBox has already grown from its raw DAT height to its mounted height, permanently capping the viewport short on every later resize. Fixed with an eager CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox .Viewport's own lazy getter. CharacterTitlesController.Bind gained the same defensive Anchors = Left|Top|Bottom fallback for the Titles ListBox that CharacterStatController already had (a no-op on the real DAT — both the Titles page and its ListBox already carry a real authored LayoutPolicy that stretches correctly). Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight, set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read identically by interactive drag, RetailWindowManager.ResizeTo, and RetailWindowLayoutPersistence's restore clamp. No gaps found; no register row (every number is a live-probed authored DAT value or a structural correctness fix, nothing inferred). Tests: CharacterStatControllerTests .CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar, CharacterTitlesControllerTests .TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips, RetailWindowFrameTests .NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds (shared-mechanism regression pin), CharacterPanelLiveDatTests .PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat pin). Existing attribute-row tests updated from list.Children to Descendants(list) for the new nested-viewport shape (the pattern skill rows already needed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
315 lines
12 KiB
C#
315 lines
12 KiB
C#
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>.
|
|
/// Chat (<c>0x2100006F</c>) was the FIRST window to author real DAT
|
|
/// min/max (min 300/100, max 2000/2000 per
|
|
/// <c>CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints</c>);
|
|
/// this test proves an interactive-shaped <c>ResizeTo</c> call against a
|
|
/// chat-shaped mount still clamps at those authored bounds after CT6
|
|
/// wired a SECOND window (Character) onto the same mechanism — a
|
|
/// regression here would mean CT6 accidentally special-cased Character
|
|
/// instead of reusing the standard path.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds()
|
|
{
|
|
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",
|
|
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);
|
|
}
|
|
|
|
[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;
|
|
}
|
|
}
|