feat(CT6): character window Y-resize clamped at retail's authored host minimum + shrink-and-scroll list contract
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>
This commit is contained in:
parent
4cbbdaf4bf
commit
ec50455a63
10 changed files with 741 additions and 74 deletions
|
|
@ -380,6 +380,63 @@ public sealed class CharacterPanelLiveDatTests
|
|||
Assert.Equal(2000, tree.MaxHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT6 (2026-08-25 live probe): the ACTUAL resize clamp source for the
|
||||
/// Character/Skills window is the SHARED <c>gmPanelUI</c> host
|
||||
/// (<c>0x100005FE</c> in LayoutDesc <c>0x2100006E</c>) — not
|
||||
/// <c>0x2100002E</c>'s own root (see the pin above) and not the
|
||||
/// Character/Skills slot <c>0x1000018E</c> either (asserted below to
|
||||
/// author no constraints of its own). Decomp chain:
|
||||
/// <c>UIElement_Resizebar::StartMouseResizing @0x0046B7E0</c> calls
|
||||
/// <c>UIElement::StartResizing(this->GetParent(), ...)</c>, stashing
|
||||
/// the drag state on that PARENT; <c>UIElement::MouseResizeElement
|
||||
/// @0x00461130</c> then reads <c>GetAttribute_Int(this, 0x3C..0x3F)</c>
|
||||
/// off that SAME element every mouse-move. The bottom Resizebar
|
||||
/// (<c>0x10000660</c>) and top Dragbar (<c>0x1000065C</c>) are both
|
||||
/// DIRECT CHILDREN of the host (siblings of the content parent
|
||||
/// <c>0x10000180</c>), confirmed here — so the host's own authored
|
||||
/// 0x3C..0x3F values are what <c>RetailUiRuntime.MountCharacter</c>
|
||||
/// must plumb through as <see cref="RetailWindowFrame.Options.DatConstraintSource"/>.
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
||||
ElementInfo? host = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu);
|
||||
Assert.NotNull(host);
|
||||
Assert.Equal(310f, host!.Width);
|
||||
Assert.Equal(372f, host.Height);
|
||||
|
||||
// MinWidth == MaxWidth: no horizontal Resizebar is authored on this
|
||||
// host (only the top Dragbar and bottom Resizebar — MountCharacter's
|
||||
// ResizeX=false matches this exactly). MinHeight == the host's own
|
||||
// authored default height (372) — retail's Character/Skills window
|
||||
// can only grow taller, never shrink below its own authored extent.
|
||||
Assert.Equal(310, host.MinWidth);
|
||||
Assert.Equal(310, host.MaxWidth);
|
||||
Assert.Equal(372, host.MinHeight);
|
||||
Assert.Equal(1000, host.MaxHeight);
|
||||
|
||||
// The Resizebar and Dragbar are DIRECT CHILDREN of the host, not the
|
||||
// content parent (0x10000180) — the load-bearing parentage fact
|
||||
// StartMouseResizing's GetParent() call depends on.
|
||||
Assert.Contains(host.Children, c => c.Id == 0x10000660u && c.Type == 9u); // Resizebar
|
||||
Assert.Contains(host.Children, c => c.Id == 0x1000065Cu && c.Type == 2u); // Dragbar
|
||||
Assert.Contains(host.Children, c => c.Id == 0x10000180u); // content parent, a SIBLING
|
||||
|
||||
// The Character/Skills slot itself authors no constraints of its
|
||||
// own — the clamp is exclusively the host's, not layered again on
|
||||
// the slot.
|
||||
ElementInfo? slot = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Eu);
|
||||
Assert.NotNull(slot);
|
||||
Assert.Equal(300f, slot!.Width);
|
||||
Assert.Equal(362f, slot.Height);
|
||||
Assert.Null(slot.MinWidth);
|
||||
Assert.Null(slot.MinHeight);
|
||||
Assert.Null(slot.MaxWidth);
|
||||
Assert.Null(slot.MaxHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The title-string table chain
|
||||
/// (<c>CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0</c>):
|
||||
|
|
|
|||
|
|
@ -389,8 +389,10 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// Rows are UiClickablePanel which inherits UiPanel, so OfType<UiPanel> matches.
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
// CT6: rows now stack inside a UiScrollablePanel viewport child of
|
||||
// the ListBox (same shrink-and-scroll contract Skills already had),
|
||||
// so Descendants (not Children) finds them.
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
}
|
||||
|
||||
|
|
@ -402,7 +404,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
// All rows must have an OnClick wired (not null) and ClickThrough = false.
|
||||
foreach (var row in rows)
|
||||
|
|
@ -420,7 +422,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
|
|
@ -439,7 +441,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
|
||||
string[] expectedNames =
|
||||
|
|
@ -465,7 +467,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
|
||||
string ValueOf(UiPanel row) => row.Children.OfType<UiText>().ToList()[^1].LinesProvider()[0].Text;
|
||||
|
||||
|
|
@ -489,7 +491,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
|
||||
var iconEl = rows[0].Children.OfType<UiText>().First();
|
||||
|
|
@ -507,7 +509,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
|
||||
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
|
|
@ -532,7 +534,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var row = list.Children.OfType<UiPanel>().First();
|
||||
var row = Descendants(list).OfType<UiClickablePanel>().First();
|
||||
AssertRowGeometry(row, expectedWidth: 282f);
|
||||
}
|
||||
|
||||
|
|
@ -547,7 +549,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var row = list.Children.OfType<UiPanel>().ToList()[6];
|
||||
var row = Descendants(list).OfType<UiClickablePanel>().ToList()[6];
|
||||
AssertRowGeometry(row, expectedWidth: 282f);
|
||||
}
|
||||
|
||||
|
|
@ -699,7 +701,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// Simulate a click on row 4 (Focus).
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
rows[4].OnClick!();
|
||||
|
||||
|
|
@ -717,7 +719,7 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text);
|
||||
}
|
||||
|
|
@ -732,7 +734,7 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
// Focus raise cost = 110 (SampleData fixture).
|
||||
Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text);
|
||||
|
|
@ -748,7 +750,7 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
|
||||
}
|
||||
|
|
@ -763,7 +765,7 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
// UnassignedXp = 87_757_321_741L
|
||||
var expected = (87_757_321_741L).ToString("N0");
|
||||
|
|
@ -783,7 +785,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
|
||||
// Select Focus (row 4).
|
||||
rows[4].OnClick!();
|
||||
|
|
@ -805,7 +807,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
|
||||
// Select Endurance (row 1, value=10).
|
||||
rows[1].OnClick!();
|
||||
|
|
@ -826,7 +828,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
|
||||
// All rows start transparent.
|
||||
Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W));
|
||||
|
|
@ -846,7 +848,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
rows[2].OnClick!(); // select
|
||||
rows[2].OnClick!(); // deselect
|
||||
|
||||
|
|
@ -871,7 +873,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: FakeResolve);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
rows[2].OnClick!();
|
||||
|
||||
Assert.Equal(0x06000F93u, rows[2].BackgroundSprite); // selected → sprite
|
||||
|
|
@ -891,7 +893,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: FakeResolve);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
rows[2].OnClick!(); // select
|
||||
rows[2].OnClick!(); // deselect
|
||||
|
||||
|
|
@ -933,7 +935,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // select Focus
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!(); // select Focus
|
||||
|
||||
Assert.True(btn1.Visible, "raise×1 visible on selection");
|
||||
Assert.True(btn10.Visible, "raise×10 visible on selection");
|
||||
|
|
@ -960,7 +962,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, () => sheet);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
Assert.Equal("Ghosted", btn10.ActiveState);
|
||||
|
|
@ -980,7 +982,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // select Strength (cost=0)
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[0].OnClick!(); // select Strength (cost=0)
|
||||
|
||||
Assert.True(btn1.Visible, "raise button visible even when disabled");
|
||||
Assert.Equal("Ghosted", btn1.ActiveState);
|
||||
|
|
@ -1000,7 +1002,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
btn1.OnClick!();
|
||||
|
||||
var request = Assert.Single(requests);
|
||||
|
|
@ -1023,7 +1025,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
|
||||
btn1.OnClick!();
|
||||
|
|
@ -1045,7 +1047,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[6].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[6].OnClick!();
|
||||
btn1.OnClick!();
|
||||
|
||||
var request = Assert.Single(requests);
|
||||
|
|
@ -1074,7 +1076,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, () => sheet,
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
btn10.OnClick!();
|
||||
|
||||
Assert.Empty(requests);
|
||||
|
|
@ -1091,7 +1093,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
rows[4].OnClick!(); // select
|
||||
Assert.True(btn1.Visible);
|
||||
rows[4].OnClick!(); // deselect
|
||||
|
|
@ -1308,7 +1310,7 @@ public class CharacterStatControllerTests
|
|||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
var attributeRows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var attributeRows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.NotEmpty(attributeRows);
|
||||
Assert.All(attributeRows, row =>
|
||||
{
|
||||
|
|
@ -1363,7 +1365,7 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(12, SkillRows(list).Count);
|
||||
|
||||
ClickTab(layout, left: 0f);
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
var rows = Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
Assert.Equal("Strength", rows[0].Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text);
|
||||
}
|
||||
|
|
@ -1835,7 +1837,7 @@ public class CharacterStatControllerTests
|
|||
CharacterSheet Sheet() => new() { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
|
||||
CharacterStatController.Bind(layout, Sheet);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength = index 0
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength = index 0
|
||||
|
||||
Assert.Equal("Strength: 220 (+20)", title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
|
@ -1852,7 +1854,7 @@ public class CharacterStatControllerTests
|
|||
CharacterSheet Sheet() => new() { Endurance = 180, AttributeBaseValues = [0, 200, 0, 0, 0, 0] };
|
||||
CharacterStatController.Bind(layout, Sheet);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[1].OnClick!(); // Endurance = index 1
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[1].OnClick!(); // Endurance = index 1
|
||||
|
||||
Assert.Equal("Endurance: 180 (-20)", title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
|
@ -1869,7 +1871,7 @@ public class CharacterStatControllerTests
|
|||
CharacterSheet Sheet() => new() { Strength = 200, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
|
||||
CharacterStatController.Bind(layout, Sheet);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!();
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[0].OnClick!();
|
||||
|
||||
Assert.Equal("Strength: 200", title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
|
@ -2145,7 +2147,7 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength
|
||||
|
||||
Assert.Equal("Infinity!", val.LinesProvider()[0].Text);
|
||||
}
|
||||
|
|
@ -2161,7 +2163,7 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // Focus
|
||||
Descendants(list).OfType<UiClickablePanel>().ToList()[4].OnClick!(); // Focus
|
||||
|
||||
var color = title.LinesProvider()[0].Color;
|
||||
Assert.Equal(1f, color.X, precision: 3);
|
||||
|
|
@ -2308,10 +2310,20 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal((510f, 7f), (divider.Top, divider.Height));
|
||||
Assert.Equal(3, footers.Count);
|
||||
Assert.All(footers, footer => Assert.Equal((520f, 55f), (footer.Top, footer.Height)));
|
||||
Assert.False(
|
||||
// CT6 (2026-08-25): the shared scrollbar is now always bound + visible,
|
||||
// on Attributes as well as Skills — retail's authored gutter
|
||||
// (0x1000023E) is reserved regardless of content, and UiScrollbar's
|
||||
// own IsPresentationVisible/IsModelDisabled already draw the correct
|
||||
// full-track "disabled" thumb when content fits (HideWhenDisabled
|
||||
// defaults to false). The 9 attribute/vital rows (180px content) fit
|
||||
// comfortably inside this fixture's 398px view, so HasOverflow is
|
||||
// false here — a shrunk window is what flips it true (see the
|
||||
// window-level resize test).
|
||||
Assert.True(
|
||||
scrollbar.Visible,
|
||||
$"pre-skills scrollbar: model={scrollbar.Model is not null}, " +
|
||||
$"attributes-tab scrollbar: model={scrollbar.Model is not null}, " +
|
||||
$"resolve={scrollbar.SpriteResolve is not null}, track=0x{scrollbar.TrackSprite:X8}");
|
||||
Assert.NotNull(scrollbar.Model);
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
Assert.True(scrollbar.Visible);
|
||||
|
|
@ -2326,6 +2338,160 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT6 (2026-08-25): the window-level resize contract. Live-probed ground
|
||||
/// truth (against layout <c>0x2100006E</c>, host <c>0x100005FE</c>):
|
||||
/// <c>UIElement_Resizebar::StartMouseResizing @0x0046B7E0</c> calls
|
||||
/// <c>UIElement::StartResizing(this->GetParent(), ...)</c> — the bottom
|
||||
/// Resizebar (<c>0x10000660</c>) is a DIRECT CHILD of the shared
|
||||
/// <c>gmPanelUI</c> host, not of the content parent — and
|
||||
/// <c>UIElement::MouseResizeElement @0x00461130</c> reads
|
||||
/// <c>GetAttribute_Int(this, 0x3C..0x3F)</c> off that SAME element on every
|
||||
/// mouse-move. The host authors MinWidth=310 MinHeight=372 MaxWidth=310
|
||||
/// (fixed — no horizontal Resizebar authored) MaxHeight=1000. This test
|
||||
/// mounts the real character content through <see cref="RetailWindowFrame"/>
|
||||
/// with a synthetic <see cref="RetailWindowFrame.Options.DatConstraintSource"/>
|
||||
/// carrying exactly those four probed values (the same synthetic-<see cref="ElementInfo"/>
|
||||
/// pattern <see cref="RetailWindowFrameTests"/> already uses), matching
|
||||
/// production's actual wire-up in <c>RetailUiRuntime.MountCharacter</c>
|
||||
/// (which sources the SAME host element live from the DAT).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar()
|
||||
{
|
||||
var layout = FixtureLoader.LoadCharacter();
|
||||
CharacterStatController.Bind(
|
||||
layout,
|
||||
SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
// Tall enough that ConstrainResizeToParent (production's own setting)
|
||||
// never becomes the binding constraint below — this test isolates the
|
||||
// AUTHORED host clamp, not the desktop-edge clamp.
|
||||
var root = new UiRoot { Width = 1280, Height = 1400 };
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
root,
|
||||
layout.Root,
|
||||
id => (id, 16, 16),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Character,
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = 540f,
|
||||
Top = 18f,
|
||||
ResizeX = false,
|
||||
ResizeY = true,
|
||||
ResizableEdges = ResizeEdges.Bottom,
|
||||
ConstrainResizeToParent = true,
|
||||
DatConstraintSource = HostConstraints(),
|
||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||
});
|
||||
|
||||
ApplyLayoutPass(handle.OuterFrame);
|
||||
|
||||
var page = layout.Root.Children.Single(
|
||||
e => e.DatElementId == CharacterStatController.AttributesPageId);
|
||||
var list = Descendants(page).Single(
|
||||
e => e.DatElementId == CharacterStatController.ListBoxId);
|
||||
var statLayout = list.Parent!;
|
||||
var scrollbar = statLayout.Children.OfType<UiScrollbar>().Single(
|
||||
e => e.DatElementId == CharacterStatController.ListScrollbarId);
|
||||
var footer = statLayout.Children.First(
|
||||
e => e.DatElementId == CharacterStatController.FooterStateAId);
|
||||
var viewport = Assert.IsType<UiScrollablePanel>(list.Children.Single());
|
||||
|
||||
// Chrome inset = 2 * RetailChromeSprites.Border (5px) = 10; the DAT's
|
||||
// 310/372/310/1000 host values become 320/382/320/1010 on the
|
||||
// NineSlice-wrapped outer frame.
|
||||
Assert.Equal(320f, handle.OuterFrame.MinWidth);
|
||||
Assert.Equal(320f, handle.OuterFrame.MaxWidth);
|
||||
Assert.Equal(382f, handle.OuterFrame.MinHeight);
|
||||
Assert.Equal(1010f, handle.OuterFrame.MaxHeight);
|
||||
|
||||
float originalOuterHeight = handle.Height;
|
||||
float originalListHeight = list.Height;
|
||||
float originalFooterBottomGap = statLayout.Height - (footer.Top + footer.Height);
|
||||
Assert.NotNull(scrollbar.Model);
|
||||
viewport.LayoutScrollableChildren();
|
||||
Assert.False(
|
||||
scrollbar.Model!.HasOverflow,
|
||||
"the fixture's authored default height fits all 9 rows without scrolling");
|
||||
|
||||
// Request far below the authored minimum — the clamp must hold at
|
||||
// 382, not the requested value.
|
||||
handle.ResizeTo(handle.Width, 50f);
|
||||
Assert.Equal(382f, handle.Height);
|
||||
|
||||
ApplyLayoutPass(handle.OuterFrame);
|
||||
viewport.LayoutScrollableChildren();
|
||||
|
||||
Assert.True(list.Height < originalListHeight, "the stat list must shrink with the window");
|
||||
Assert.Equal((int)MathF.Floor(viewport.Height), scrollbar.Model!.ViewHeight);
|
||||
Assert.True(
|
||||
scrollbar.Model!.HasOverflow,
|
||||
"9 rows (180px content) must overflow the shrunk view");
|
||||
|
||||
// The footer stays bottom-docked: same distance from the stat
|
||||
// layout's own bottom edge before and after the shrink.
|
||||
float shrunkFooterBottomGap = statLayout.Height - (footer.Top + footer.Height);
|
||||
Assert.Equal(originalFooterBottomGap, shrunkFooterBottomGap, precision: 2);
|
||||
|
||||
// Request far above the authored maximum — the clamp must hold at
|
||||
// 1010, not the requested value.
|
||||
handle.ResizeTo(handle.Width, 5000f);
|
||||
Assert.Equal(1010f, handle.Height);
|
||||
|
||||
ApplyLayoutPass(handle.OuterFrame);
|
||||
viewport.LayoutScrollableChildren();
|
||||
Assert.False(
|
||||
scrollbar.Model!.HasOverflow,
|
||||
"growing well past the content height restores no-overflow");
|
||||
|
||||
// Growing back to the ORIGINAL authored size restores the original
|
||||
// list height and the no-overflow state.
|
||||
handle.ResizeTo(handle.Width, originalOuterHeight);
|
||||
ApplyLayoutPass(handle.OuterFrame);
|
||||
viewport.LayoutScrollableChildren();
|
||||
|
||||
Assert.Equal(originalOuterHeight, handle.Height);
|
||||
Assert.Equal(originalListHeight, list.Height, precision: 2);
|
||||
Assert.False(scrollbar.Model!.HasOverflow);
|
||||
}
|
||||
|
||||
/// <summary>Synthetic authored 0x3C..0x3F constraints matching the
|
||||
/// 2026-08-25 live probe of the shared <c>gmPanelUI</c> host
|
||||
/// (<c>0x100005FE</c> in LayoutDesc <c>0x2100006E</c>): MinWidth=310
|
||||
/// MinHeight=372 MaxWidth=310 (fixed — no horizontal Resizebar authored)
|
||||
/// MaxHeight=1000. Same synthetic-<see cref="ElementInfo"/> shape as
|
||||
/// <see cref="RetailWindowFrameTests"/>'s own private helper.</summary>
|
||||
private static ElementInfo HostConstraints()
|
||||
{
|
||||
var info = new ElementInfo();
|
||||
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||
direct.Properties.Values[0x3Fu] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.Integer,
|
||||
IntegerValue = 310,
|
||||
};
|
||||
direct.Properties.Values[0x3Eu] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.Integer,
|
||||
IntegerValue = 372,
|
||||
};
|
||||
direct.Properties.Values[0x3Du] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.Integer,
|
||||
IntegerValue = 310,
|
||||
};
|
||||
direct.Properties.Values[0x3Cu] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.Integer,
|
||||
IntegerValue = 1000,
|
||||
};
|
||||
info.States[UiStateInfo.DirectStateId] = direct;
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-24 owner report: "hovering the scrollbar and arrows does
|
||||
/// nothing" — root-level hover through the REAL mounted character
|
||||
|
|
|
|||
|
|
@ -512,6 +512,101 @@ public sealed class CharacterTitlesControllerTests
|
|||
Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text);
|
||||
}
|
||||
|
||||
// ── CT6 resize/scrollbar contract ──────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// CT6 (2026-08-25): the Titles list (authored H=455 inside the 575px
|
||||
/// page — CT1 ground truth §3) must shrink with the window and hand off
|
||||
/// to its scrollbar the same way <c>CharacterStatController</c>'s
|
||||
/// attribute/skill list does. Mounts the real fixture through
|
||||
/// <see cref="RetailWindowFrame"/> (same shape as
|
||||
/// <c>RetailUiRuntime.MountCharacter</c>) and shrinks the window well
|
||||
/// below the list's authored height.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips()
|
||||
{
|
||||
// 15 rows at the authored 24px pitch = 360px content — comfortably
|
||||
// under the list's own authored 455px height (fits at the fixture's
|
||||
// default mounted size, matching how CharacterStatController's own
|
||||
// attribute list fits its default size), but well over what remains
|
||||
// once the window is shrunk below.
|
||||
uint[] earnedIds = Enumerable.Range(1, 15).Select(i => (uint)i).ToArray();
|
||||
var names = earnedIds.ToDictionary(id => id, id => $"Title {id}");
|
||||
Harness h = BindWithEarnedTitles(earnedIds, displayTitleId: 0u, names: names);
|
||||
Assert.Equal(15, h.Rows.Count);
|
||||
|
||||
var root = new UiRoot { Width = 1280, Height = 1400 };
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
root,
|
||||
h.Layout.Root,
|
||||
id => (id, 16, 16),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Character,
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = 540f,
|
||||
Top = 18f,
|
||||
ResizeX = false,
|
||||
ResizeY = true,
|
||||
ResizableEdges = ResizeEdges.Bottom,
|
||||
// No DatConstraintSource: this test exercises the LIST'S OWN
|
||||
// reflow/scrollbar contract given an already-permitted
|
||||
// resize, not the authored host clamp value itself (that is
|
||||
// CharacterStatControllerTests.
|
||||
// CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar's
|
||||
// job) — an explicit generous MinHeight lets the window
|
||||
// actually shrink instead of defaulting to its own mounted
|
||||
// height (RetailWindowFrame.Mount's fallback when neither
|
||||
// MinHeight nor DatConstraintSource is supplied).
|
||||
MinHeight = 40f,
|
||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||
});
|
||||
|
||||
ApplyAnchors(handle.OuterFrame);
|
||||
|
||||
var scrollbar = Assert.IsType<UiScrollbar>(
|
||||
h.Layout.FindElement(h.ListBox.ScrollbarElementId));
|
||||
Assert.Same(h.ListBox.Scroll, scrollbar.Model);
|
||||
|
||||
float originalListHeight = h.ListBox.Height;
|
||||
h.ListBox.ViewportForTest!.LayoutScrollableChildren();
|
||||
Assert.False(
|
||||
h.ListBox.Scroll.HasOverflow,
|
||||
"the fixture's authored default height fits all 15 rows without scrolling");
|
||||
|
||||
// Shrink the window well below the list's own authored 455px height.
|
||||
// (The Titles page container 0x10000539 carries its own authored
|
||||
// LayoutPolicy that already stretches with the mounted content's
|
||||
// height — verified live during this test's development — so only
|
||||
// the ListBox's OWN Anchors, set above by CharacterTitlesController,
|
||||
// were the missing piece.)
|
||||
handle.ResizeTo(handle.Width, 200f);
|
||||
ApplyAnchors(handle.OuterFrame);
|
||||
h.ListBox.ViewportForTest!.LayoutScrollableChildren();
|
||||
|
||||
Assert.True(h.ListBox.Height < originalListHeight, "the Titles list must shrink with the window");
|
||||
Assert.True(h.ListBox.Scroll.HasOverflow, "360px of rows must overflow the shrunk view");
|
||||
Assert.Equal((int)MathF.Floor(h.ListBox.ViewportForTest!.Height), h.ListBox.Scroll.ViewHeight);
|
||||
|
||||
// Growing back restores the original height and the no-overflow state.
|
||||
handle.ResizeTo(handle.Width, handle.AuthoredHeight);
|
||||
ApplyAnchors(handle.OuterFrame);
|
||||
h.ListBox.ViewportForTest!.LayoutScrollableChildren();
|
||||
|
||||
Assert.Equal(originalListHeight, h.ListBox.Height, precision: 2);
|
||||
Assert.False(h.ListBox.Scroll.HasOverflow);
|
||||
}
|
||||
|
||||
private static void ApplyAnchors(UiElement parent)
|
||||
{
|
||||
foreach (UiElement child in parent.Children)
|
||||
{
|
||||
child.ApplyAnchor(parent.Width, parent.Height);
|
||||
ApplyAnchors(child);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -137,6 +137,60 @@ public sealed class RetailWindowFrameTests
|
|||
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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue