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>
This commit is contained in:
parent
ec50455a63
commit
996cd73675
11 changed files with 552 additions and 120 deletions
|
|
@ -305,6 +305,38 @@ public sealed class CharacterPanelLiveDatTests
|
|||
Assert.Equal("Ghosted", setDisplayButton.DefaultStateName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT6 fix round (S3, 2026-08-25): guards the deletion of
|
||||
/// <c>CharacterTitlesController.Bind</c>'s Anchors fallback
|
||||
/// (<c>if (listBox.LayoutPolicy is null) listBox.Anchors = ...</c>)
|
||||
/// against DAT drift. That fallback was proven UNREACHABLE — both the
|
||||
/// Titles page container (<c>0x10000539</c>) and its ListBox
|
||||
/// (<c>0x10000532</c>) author <c>HasOriginalParentSize=true</c>, the
|
||||
/// field <c>DatWidgetFactory</c> gates real <c>LayoutPolicy</c>
|
||||
/// assignment on (<c>DatWidgetFactory.cs</c>: <c>if
|
||||
/// (info.HasOriginalParentSize) e.LayoutPolicy =
|
||||
/// CreateLayoutPolicy(info)</c>), so <c>LayoutPolicy</c> is never
|
||||
/// null for either element and the fallback branch never ran, on
|
||||
/// either the installed DAT or the committed fixture. If a future
|
||||
/// DAT revision ever authors either element WITHOUT
|
||||
/// OriginalParentSize, this pin fails loudly — the signal to restore
|
||||
/// a real fallback rather than silently losing Titles-list resize
|
||||
/// behavior.
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void TitlesListAndPage_AuthorHasOriginalParentSize()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
||||
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu);
|
||||
Assert.NotNull(tree);
|
||||
|
||||
ElementInfo page = Assert.Single(Flatten(tree!), e => e.Id == 0x10000539u);
|
||||
Assert.True(page.HasOriginalParentSize);
|
||||
|
||||
ElementInfo listBox = Assert.Single(page.Children, c => c.Id == 0x10000532u);
|
||||
Assert.True(listBox.HasOriginalParentSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The title row template (<c>0x10000536</c> in LayoutDesc
|
||||
/// <c>0x2100005E</c>) — a single-line text row, no icon column, 24px
|
||||
|
|
|
|||
|
|
@ -2310,15 +2310,21 @@ 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)));
|
||||
// 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).
|
||||
// CT6 (2026-08-25): the shared scrollbar is now always BOUND
|
||||
// (.Model set, .Visible = true), on Attributes as well as Skills —
|
||||
// retail's authored gutter (0x1000023E) is reserved regardless of
|
||||
// content, so no per-tab visibility toggle is needed. CT6 fix round
|
||||
// (S2 correction): 0x1000023E authors 0x79 (HideWhenDisabled) TRUE
|
||||
// (fixture-verified), so a FITTING list actually HIDES the bar
|
||||
// entirely via IsPresentationVisible — it does not draw a full-track
|
||||
// "disabled" thumb the way the previous wording here implied. This
|
||||
// fixture mounts the raw 600px-tall content directly (no
|
||||
// RetailWindowFrame.Mount, no S4 372px default), so the 9
|
||||
// attribute/vital rows (180px content) fit comfortably inside its
|
||||
// 398px view and HasOverflow is false here — a shrunk window (or the
|
||||
// real S4-corrected 372px mount default) is what flips it true and
|
||||
// makes the bar presentation-visible (see the window-level resize
|
||||
// test, which asserts IsPresentationVisible both ways).
|
||||
Assert.True(
|
||||
scrollbar.Visible,
|
||||
$"attributes-tab scrollbar: model={scrollbar.Model is not null}, " +
|
||||
|
|
@ -2355,6 +2361,14 @@ public class CharacterStatControllerTests
|
|||
/// 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).
|
||||
///
|
||||
/// <para>CT6 fix round (2026-08-25, BLOCKER B1): the host source IS
|
||||
/// retail's own outer frame — its authored 310/372/310/1000 are already
|
||||
/// chrome-inclusive (5px bevel + 300x362 content + 5px = 310x372) — so
|
||||
/// this test now sets <see cref="RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame"/>
|
||||
/// the same way production's <c>MountCharacter</c> does, and the mounted
|
||||
/// clamp equals the host's four raw values EXACTLY, with no chrome inset
|
||||
/// composed on top.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar()
|
||||
|
|
@ -2384,6 +2398,7 @@ public class CharacterStatControllerTests
|
|||
ResizableEdges = ResizeEdges.Bottom,
|
||||
ConstrainResizeToParent = true,
|
||||
DatConstraintSource = HostConstraints(),
|
||||
DatConstraintSourceIsOuterFrame = true,
|
||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||
});
|
||||
|
||||
|
|
@ -2400,13 +2415,14 @@ public class CharacterStatControllerTests
|
|||
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);
|
||||
// CT6 fix round (BLOCKER B1): the host source IS the outer frame
|
||||
// already (DatConstraintSourceIsOuterFrame = true above), so no
|
||||
// chrome inset is composed on top — the mounted clamp is EXACTLY the
|
||||
// DAT's own 310/372/310/1000.
|
||||
Assert.Equal(310f, handle.OuterFrame.MinWidth);
|
||||
Assert.Equal(310f, handle.OuterFrame.MaxWidth);
|
||||
Assert.Equal(372f, handle.OuterFrame.MinHeight);
|
||||
Assert.Equal(1000f, handle.OuterFrame.MaxHeight);
|
||||
|
||||
float originalOuterHeight = handle.Height;
|
||||
float originalListHeight = list.Height;
|
||||
|
|
@ -2418,9 +2434,9 @@ public class CharacterStatControllerTests
|
|||
"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.
|
||||
// 372, not the requested value.
|
||||
handle.ResizeTo(handle.Width, 50f);
|
||||
Assert.Equal(382f, handle.Height);
|
||||
Assert.Equal(372f, handle.Height);
|
||||
|
||||
ApplyLayoutPass(handle.OuterFrame);
|
||||
viewport.LayoutScrollableChildren();
|
||||
|
|
@ -2430,6 +2446,12 @@ public class CharacterStatControllerTests
|
|||
Assert.True(
|
||||
scrollbar.Model!.HasOverflow,
|
||||
"9 rows (180px content) must overflow the shrunk view");
|
||||
// S2 correction: 0x1000023E authors 0x79 (HideWhenDisabled) TRUE —
|
||||
// an overflowing list must show its scrollbar VISIBLE and
|
||||
// interactive, not merely bound-but-invisible.
|
||||
Assert.True(
|
||||
scrollbar.IsPresentationVisible,
|
||||
"an overflowing list's scrollbar must be presentation-visible (interactive), not hidden");
|
||||
|
||||
// The footer stays bottom-docked: same distance from the stat
|
||||
// layout's own bottom edge before and after the shrink.
|
||||
|
|
@ -2437,15 +2459,21 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(originalFooterBottomGap, shrunkFooterBottomGap, precision: 2);
|
||||
|
||||
// Request far above the authored maximum — the clamp must hold at
|
||||
// 1010, not the requested value.
|
||||
// 1000, not the requested value.
|
||||
handle.ResizeTo(handle.Width, 5000f);
|
||||
Assert.Equal(1010f, handle.Height);
|
||||
Assert.Equal(1000f, handle.Height);
|
||||
|
||||
ApplyLayoutPass(handle.OuterFrame);
|
||||
viewport.LayoutScrollableChildren();
|
||||
Assert.False(
|
||||
scrollbar.Model!.HasOverflow,
|
||||
"growing well past the content height restores no-overflow");
|
||||
// S2 correction: HideWhenDisabled TRUE means a fitting list HIDES its
|
||||
// scrollbar entirely — it does NOT leave a full-track "disabled"
|
||||
// thumb visible.
|
||||
Assert.False(
|
||||
scrollbar.IsPresentationVisible,
|
||||
"content that fits after growing large must HIDE the scrollbar (0x79 HideWhenDisabled), not show a disabled thumb");
|
||||
|
||||
// Growing back to the ORIGINAL authored size restores the original
|
||||
// list height and the no-overflow state.
|
||||
|
|
|
|||
|
|
@ -576,11 +576,15 @@ public sealed class CharacterTitlesControllerTests
|
|||
"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.)
|
||||
// (CT6 fix round, S3 correction: BOTH the Titles page container
|
||||
// 0x10000539 AND its own ListBox 0x10000532 carry a real authored
|
||||
// LayoutPolicy — HasOriginalParentSize=true on both, pinned by
|
||||
// CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize
|
||||
// — that already stretches with the mounted content's height. The
|
||||
// reflow this test proves comes entirely from that authored
|
||||
// LayoutPolicy; CharacterTitlesController.Bind sets no Anchors of
|
||||
// its own on the ListBox, since the "if LayoutPolicy is null"
|
||||
// fallback it used to carry was dead code and has been deleted.)
|
||||
handle.ResizeTo(handle.Width, 200f);
|
||||
ApplyAnchors(handle.OuterFrame);
|
||||
h.ListBox.ViewportForTest!.LayoutScrollableChildren();
|
||||
|
|
|
|||
|
|
@ -146,17 +146,25 @@ public sealed class RetailWindowFrameTests
|
|||
/// 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.
|
||||
///
|
||||
/// <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_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds()
|
||||
public void NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize()
|
||||
{
|
||||
var root = new UiRoot { Width = 1920, Height = 1080 };
|
||||
var content = new UiPanel { Width = 490, Height = 100 };
|
||||
|
|
@ -168,7 +176,7 @@ public sealed class RetailWindowFrameTests
|
|||
NoTex,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = "chat",
|
||||
WindowName = "chat-shaped",
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = 10,
|
||||
Top = 20,
|
||||
|
|
@ -191,6 +199,59 @@ public sealed class RetailWindowFrameTests
|
|||
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()
|
||||
{
|
||||
|
|
@ -312,4 +373,37 @@ public sealed class RetailWindowFrameTests
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue