using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.Tests.UI.Layout;
///
/// Dat-free conformance tests for the committed chat_2100006f.json golden fixture —
/// retail's ACTUAL main chat window (LayoutDesc 0x2100006F, window root
/// 0x10000600, authored 410x100; Campaign CH slice CH6a). Verifies that
/// LayoutImporter.ImportInfos correctly resolves the BaseElement / BaseLayoutId
/// inheritance chain.
///
public class ChatLayoutConformanceTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
private static ElementInfo? Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (var c in n.Children)
{
var f = Find(c, id);
if (f is not null) return f;
}
return null;
}
[Fact]
public void ChatFixture_ResolvesKnownElements()
{
var root = FixtureLoader.LoadChatInfos();
Assert.NotNull(Find(root, 0x10000011u)); // transcript
Assert.NotNull(Find(root, 0x10000016u)); // input
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
Assert.NotNull(Find(root, 0x10000014u)); // channel menu
Assert.NotNull(Find(root, 0x10000019u)); // send button
Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
}
[Fact]
public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
{
var root = FixtureLoader.LoadChatInfos();
Assert.Equal(6u, Find(root, 0x10000014u)!.Type); // Menu
Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar
Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send)
Assert.Equal(1u, Find(root, 0x1000046Fu)!.Type); // Button (Max/Min)
Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript)
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
}
[Theory]
[InlineData(0x10000522u)]
[InlineData(0x10000523u)]
[InlineData(0x10000524u)]
[InlineData(0x10000525u)]
public void ChatFixture_ChatWindowIndicatorButtons_ImportFromTheAuthoredTree(uint indicatorId)
{
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80 writes these
// four state-mirror buttons for floating chat windows 1-4. CH6a imports
// them generically (visible, inert) and leaves wiring their live/pressed
// state to CH6b's floating-window slice.
var root = FixtureLoader.LoadChatInfos();
Assert.NotNull(Find(root, indicatorId));
}
[Theory]
[InlineData(0x10000522u)]
[InlineData(0x10000523u)]
[InlineData(0x10000524u)]
[InlineData(0x10000525u)]
public void MountedChatWindow_IndicatorButtons_BindAloneLeavesClickUnwiredButSelfFlipSuppressed(
uint indicatorId)
{
// Bind() alone (no BindIndicatorClicks call) leaves OnClick unwired —
// the click half is a separate opt-in step RetailUiRuntime performs
// once its own ToggleFloatingChatWindow method is available (round 4,
// 2026-08-10 — see ChatWindowController.SetIndicatorOpen's doc for the
// full retail mechanism reconciliation). This test only proves the
// Bind-alone state; MountedChatWindow_IndicatorButtons_ClickTogglesWindow
// below exercises the full click round trip through BindIndicatorClicks.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement? indicator = layout.FindElement(indicatorId);
Assert.NotNull(indicator);
Assert.True(indicator!.Visible);
var button = Assert.IsType(indicator);
Assert.Null(button.OnClick);
Assert.Null(button.OnClickAt);
// CH6a/b REJECT-review SHOULD-FIX 3: OnClick==null is the wrong level
// to prove "self-flip suppressed" — the fixture's own 0x0B
// (ToggleBehavior) = true means a plain press/release would still
// flip UiButton's internal Selected mirror even with no OnClick
// bound. SuppressSelfToggle (set by ChatWindowController.Bind) is
// the actual guard, and it stays true even after round 4 wires a
// real click handler — see SetIndicatorOpen's doc for why the blind
// self-flip must stay suppressed regardless.
Assert.True(button.SuppressSelfToggle);
bool before = button.Selected;
button.OnEvent(new UiEvent(0, button, UiEventType.MouseDown, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.MouseUp, Data1: 2, Data2: 2));
Assert.Equal(before, button.Selected);
}
[Theory]
[InlineData(0x10000522u, 1)]
[InlineData(0x10000523u, 2)]
[InlineData(0x10000524u, 3)]
[InlineData(0x10000525u, 4)]
public void MountedChatWindow_IndicatorButtons_ClickTogglesWindow_ThroughBindIndicatorClicks(
uint indicatorId, int expectedWindowId)
{
// Round 4 (2026-08-10): clicking an indicator now toggles its floating
// chat window through the SAME chokepoint the Alt+1..4 keybinds use
// (user-directed retail behavior — see SetIndicatorOpen's doc for the
// full mechanism reconciliation: the generic UIElement_Button
// action-dispatch system is real and armed on the button side, but
// the shipped floating-window fixture authors no matching listener
// registration, so this wiring cannot be proven purely from the DAT).
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
var toggled = new List();
controller!.BindIndicatorClicks(windowId =>
{
toggled.Add(windowId);
return true;
});
UiElement? indicator = layout.FindElement(indicatorId);
var button = Assert.IsType(indicator);
Assert.NotNull(button.OnClick);
button.OnEvent(new UiEvent(0, button, UiEventType.MouseDown, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.MouseUp, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.Click, Data1: 2, Data2: 2));
Assert.Equal(new[] { expectedWindowId }, toggled);
}
[Fact]
public void MountedChatWindow_IndicatorButtons_ClickRoundTrip_KeepsMirrorConsistent()
{
// The full round trip: click -> BindIndicatorClicks' delegate calls the
// real toggle -> the caller (standing in for RetailUiRuntime's
// WindowVisibilityChanged plumbing) calls SetIndicatorOpen with the
// window's NEW state -> Selected matches that new state, not a blind
// self-flip. SuppressSelfToggle staying true is what makes this
// authoritative-writer invariant hold even though the button now has
// a real OnClick.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
bool windowOpen = false;
controller!.BindIndicatorClicks(windowId =>
{
windowOpen = !windowOpen;
controller.SetIndicatorOpen(windowId, windowOpen);
return true;
});
var button = Assert.IsType(layout.FindElement(0x10000522u));
Assert.False(button.Selected);
button.OnEvent(new UiEvent(0, button, UiEventType.MouseDown, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.MouseUp, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.Click, Data1: 2, Data2: 2));
Assert.True(button.Selected);
button.OnEvent(new UiEvent(0, button, UiEventType.MouseDown, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.MouseUp, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.Click, Data1: 2, Data2: 2));
Assert.False(button.Selected);
}
[Theory]
[InlineData(0x10000693u)]
[InlineData(0x10000694u)]
[InlineData(0x10000695u)]
[InlineData(0x10000696u)]
[InlineData(0x10000697u)]
[InlineData(0x10000698u)]
[InlineData(0x10000699u)]
[InlineData(0x1000069Au)]
public void MountedChatWindow_LockedTwinBorderArt_DefaultsHidden(uint lockedTwinId)
{
// Register row AP-185: CH6a shows only the live (unlocked) grip/dragbar
// border-art set by default, matching UiRoot.UiLocked's own false
// default and avoiding a double-rendered border.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement? twin = layout.FindElement(lockedTwinId);
Assert.NotNull(twin);
Assert.False(twin!.Visible);
}
[Fact]
public void ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType(layout.FindElement(0x10000011u));
var input = Assert.IsType(layout.FindElement(0x10000016u));
Assert.True(transcript.Selectable);
Assert.True(input.Selectable);
Assert.True(input.OneLine);
Assert.Equal(0x10000016u, input.DatElementId);
}
///
/// Campaign CH round 4 (docs/research/2026-08-10-retail-ui-text-style.md
/// §2.5/§2.6): the chat transcript's base style (0x10000372 in layout
/// 0x2100003F) authors NO property 0x21 (Outline) anywhere in its
/// inheritance chain — regenerated straight from the real installed DAT
/// (ChatLayoutFixtureGenerator), proving the property-0x21/0x22
/// importer ()
/// resolves this correctly end to end, not merely by a missing-field JSON
/// default.
///
[Fact]
public void ChatFixture_TranscriptElementInfo_CarriesNoOutline()
{
var root = FixtureLoader.LoadChatInfos();
var transcript = Find(root, 0x10000011u)!;
Assert.False(transcript.Outline);
Assert.Null(transcript.OutlineColor);
}
/// Same fact at the built-widget level — DatWidgetFactory.BuildText
/// must not turn a false ElementInfo.Outline into a true UiText.Outline.
[Fact]
public void ChatFixture_TranscriptWidget_OutlineIsOff()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType(layout.FindElement(0x10000011u));
Assert.False(transcript.Outline);
}
///
/// The transcript's default fill resolves to the authored
/// ARGB(255,204,204,204) (style 0x10000372 property 0x1B) —
/// Fix 5's target. Pins the value at the built-widget level, on the SAME
/// regenerated fixture the outline test above uses.
///
[Fact]
public void ChatFixture_TranscriptWidget_DefaultColorIsAuthoredOffWhite()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType(layout.FindElement(0x10000011u));
var expected = new System.Numerics.Vector4(204f / 255f, 204f / 255f, 204f / 255f, 1f);
Assert.Equal(expected, transcript.DefaultColor);
}
[Fact]
public void ChatFixture_ScrollbarImportsInheritedMediaRoles()
{
var layout = FixtureLoader.LoadChat();
var scrollbar = Assert.IsType(layout.FindElement(0x10000012u));
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite);
Assert.Equal(0x06004C63u, scrollbar.ThumbSprite);
Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite);
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
}
[Fact]
public void ChatFixture_WindowRootCarriesRetailHeightConstraints()
{
var root = FixtureLoader.LoadChatInfos();
var window = Find(root, 0x10000600u)!;
Assert.True(window.TryGetEffectiveInteger(0x3Eu, out int minHeight));
Assert.True(window.TryGetEffectiveInteger(0x3Cu, out int maxHeight));
Assert.Equal(100, minHeight);
Assert.Equal(2000, maxHeight);
}
[Fact]
public void ChatFixture_WindowRootCarriesRetailWidthConstraints()
{
var root = FixtureLoader.LoadChatInfos();
var window = Find(root, 0x10000600u)!;
Assert.True(window.TryGetEffectiveInteger(0x3Fu, out int minWidth));
Assert.True(window.TryGetEffectiveInteger(0x3Du, out int maxWidth));
Assert.Equal(300, minWidth);
Assert.Equal(2000, maxWidth);
}
[Fact]
public void ChatFixture_RootIsAuthored410x100_NoCropNeeded()
{
// Campaign CH slice CH6a: 0x2100006F's window root is already the exact
// mounted extent — the 490px content-width crop that the wrong
// 0x21000006 import needed no longer applies to anything.
var root = FixtureLoader.LoadChatInfos();
Assert.Equal(410f, root.Width);
Assert.Equal(100f, root.Height);
}
[Fact]
public void ChatFixture_DockedRoot_ReflowsContentAcrossMountedWidth()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
Assert.NotNull(controller);
// No crop-then-rebase dance needed: the root's OWN authored extent
// (410x100) already matches the design width children's imported
// LayoutPolicy captured, so growing it directly exercises the same
// raw-edge reflow the crop workaround needed a rebase step for.
var root = controller!.Root;
Assert.Equal(410f, root.Width);
root.Width = 615;
// Both panels are authored with a 5px margin to BOTH the left and right
// window edges (X=5, right-edge width = 410-(5+400) = 5) — a stretch
// grows the panel by the delta MINUS both margins: 615 - 5 - 5 = 605.
var transcriptPanel = layout.FindElement(0x10000010u)!;
var inputBar = layout.FindElement(0x10000013u)!;
transcriptPanel.ApplyAnchor(root.Width, root.Height);
inputBar.ApplyAnchor(root.Width, root.Height);
Assert.Equal(605f, transcriptPanel.Width);
Assert.Equal(605f, inputBar.Width);
}
[Fact]
public void ChatFixture_ChannelCaptionWidth_SurvivesImportedLayoutPass()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
Assert.NotNull(controller);
controller!.Menu.OnSelect!.Invoke(ChatChannelKind.General);
float fittedWidth = controller.Menu.Width;
controller.Menu.ApplyAnchor(controller.Menu.Parent!.Width, controller.Menu.Parent.Height);
Assert.True(fittedWidth > 46f);
Assert.Equal(fittedWidth, controller.Menu.Width);
Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left);
}
[Fact]
public void ChatMaximize_ResizesOuterFrameByHalfParent_AndRestores()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
var root = new UiRoot { Width = 800, Height = 600 };
// Campaign CH slice CH6a: 0x2100006F's own border art IS the window
// chrome — Chrome=Imported, no content-width crop, no MinWidth override
// (the DAT's own 0x3D/0x3F=2000/300 govern). Frame == content, so the
// maximize math below operates directly on the authored 410x100 extent
// with the DAT's real minH=100/maxH=2000 (not the wrong layout's 360).
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.Imported,
Left = 12f,
Top = 390f,
DatConstraintSource = controller.DatWindowInfo,
});
controller.AttachWindow(handle);
var maxMin = Assert.IsType(layout.FindElement(0x1000046Fu));
// frame.Height=100, parentHeight=600, expansion=300 → targetHeight =
// clamp(min(400,600),100,2000) = 400. growUp because Top(390)+400>600.
// targetTop = max(0, 390-(400-100)) = 90. Lower edge fixed at 490.
maxMin.OnClick!();
Assert.True(controller.IsMaximized);
Assert.Equal(90f, handle.Top);
Assert.Equal(400f, handle.Height);
Assert.Equal(490f, handle.Top + handle.Height); // lower edge stays fixed while growing upward
Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId);
maxMin.OnClick!();
Assert.False(controller.IsMaximized);
Assert.Equal(390f, handle.Top);
Assert.Equal(100f, handle.Height);
Assert.Equal(RetailUiStateIds.Minimized, maxMin.ActiveRetailStateId);
}
[Theory]
[InlineData(0x1000069Bu, UiResizeGrip.Border.UpperLeft)]
[InlineData(0x1000069Du, UiResizeGrip.Border.UpperRight)]
[InlineData(0x1000069Fu, UiResizeGrip.Border.LowerLeft)]
[InlineData(0x100006A1u, UiResizeGrip.Border.LowerRight)]
public void MountedChatWindow_CornerGrip_ImportsWithCorrectBorderLocation(
uint elementId, UiResizeGrip.Border expected)
{
// Campaign CH slice CH6a, user-gate round 2 item 6: pins that the 4
// corner grips from 0x2100006F import with the RIGHT BorderLocation —
// the prerequisite for the reported "no diagonal cursor" / "can't grow
// in Y from the bottom-right corner" symptoms to be fixed at all.
var layout = FixtureLoader.LoadChat();
var grip = Assert.IsType(layout.FindElement(elementId));
Assert.Equal(expected, grip.BorderLocation);
}
[Fact]
public void MountedChatWindow_TopStrip_IsAMoveHandleNotAGrip()
{
// 0x1000069C (the plain top edge, between the two top corners) is a
// Type-2 Dragbar in the real DAT, not a Resizebar — retail's main chat
// window has no title bar, so the top strip moves the window while its
// two corners (above) resize it.
var layout = FixtureLoader.LoadChat();
var topStrip = layout.FindElement(0x1000069Cu);
Assert.NotNull(topStrip);
Assert.IsNotType(topStrip);
Assert.True(topStrip!.WindowMoveHandle);
}
[Theory]
[InlineData(0x1000069Bu)] // TL corner
[InlineData(0x1000069Du)] // TR corner
[InlineData(0x1000069Eu)] // left edge
[InlineData(0x1000069Fu)] // BL corner
[InlineData(0x100006A0u)] // bottom edge
[InlineData(0x100006A1u)] // BR corner
[InlineData(0x100006A2u)] // right edge
public void MountedChatWindow_LiveGrip_ResolvesNonZeroSprite(uint elementId)
{
// CH6a/b REJECT-review BLOCKER 1: pre-fix, UiResizeGrip drew nothing —
// it was constructed without its ElementInfo/resolve pair, so all seven
// live Type-9 grips rendered NOTHING even though the fixture proves
// every one of them carries real authored art (the 0x06006129 family).
// This is the regression guard: every live grip must resolve a non-zero
// DirectState sprite id, not just decode the right BorderLocation.
var layout = FixtureLoader.LoadChat();
var grip = Assert.IsType(layout.FindElement(elementId));
Assert.NotEqual(0u, grip.SpriteFile);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
[Theory]
[InlineData(0x1000069Bu)] // TL corner
[InlineData(0x1000069Du)] // TR corner
[InlineData(0x1000069Eu)] // left edge
[InlineData(0x1000069Fu)] // BL corner
[InlineData(0x100006A0u)] // bottom edge
[InlineData(0x100006A1u)] // BR corner
[InlineData(0x100006A2u)] // right edge
public void MountedChatWindow_LiveGrip_ActuallyEmitsASpriteDraw_NotJustResolvesSpriteFile(uint elementId)
{
// CH6a/b re-review rider: MountedChatWindow_LiveGrip_ResolvesNonZeroSprite
// (above) only proves the ElementInfo carries a non-zero DirectState
// sprite id — it never calls OnDraw, so a media-less regression (a grip
// constructed WITHOUT its resolve delegate, or one whose resolve always
// returns a zero handle/dimension — exactly the CH6a/b BLOCKER 1 bug)
// would still pass it. This drives the grip through a REAL
// UiRenderContext over a REAL TextRenderer (backed by the in-memory
// RecordingGpuDevice test double, no live GPU) and asserts the draw
// call chain actually queued sprite geometry for that grip's texture.
var infos = FixtureLoader.LoadChatInfos();
// Distinct from FixtureLoader's own null-returning resolver: echoes the
// sprite id as a nonzero fake texture handle with nonzero dimensions,
// so UiResizeGrip.OnDraw's `tex == 0 || tw == 0 || th == 0` guard does
// not short-circuit before reaching ctx.DrawSprite.
var layout = LayoutImporter.Build(infos, id => (id, 8, 8), null);
var grip = Assert.IsType(layout.FindElement(elementId));
Assert.NotEqual(0u, grip.SpriteFile);
Assert.True(grip.Visible);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
grip.DrawSelfAndChildren(ctx);
var seg = Assert.Single(
renderer.DebugSpriteSegments,
s => s.Texture == grip.SpriteFile);
Assert.True(seg.VertexCount > 0);
}
[Fact]
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
{
// The literal user-gate round 2 report: "the window cannot grow in the
// Y axis when dragging from the bottom-right corner." Exercises the
// FULL production path (ChatWindowController.Bind + RetailWindowFrame.Mount
// with the real Chrome=Imported options and the real DAT min/max
// constraints from 0x2100006F, minH=100/maxH=2000/minW=300/maxW=2000)
// rather than just the pure ResizeRect math.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
var root = new UiRoot { Width = 1600, Height = 1200 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 440f,
DatConstraintSource = controller.DatWindowInfo,
});
controller.AttachWindow(handle);
var brGrip = Assert.IsType(layout.FindElement(0x100006A1u));
Assert.Equal(UiResizeGrip.Border.LowerRight, brGrip.BorderLocation);
var gs = brGrip.ScreenPosition;
int pressX = (int)(gs.X + 2), pressY = (int)(gs.Y + 2);
// Shrink first (this direction was never in question per the report).
// Height is already AT the DAT's authored minimum (minH=100 == the
// authored 100px height), so it clamps rather than shrinking further —
// width has headroom (minW=300 < 410) and does shrink.
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX - 20, pressY - 20);
root.OnMouseUp(UiMouseButton.Left, pressX - 20, pressY - 20);
Assert.Equal(390f, handle.Width);
Assert.Equal(100f, handle.Height);
// Now grow from the shrunken state — this is the reported-broken direction.
var brGripAfterShrink = Assert.IsType(layout.FindElement(0x100006A1u));
var gs2 = brGripAfterShrink.ScreenPosition;
int pressX2 = (int)(gs2.X + 2), pressY2 = (int)(gs2.Y + 2);
root.OnMouseDown(UiMouseButton.Left, pressX2, pressY2);
root.OnMouseMove(pressX2 + 70, pressY2 + 70);
root.OnMouseUp(UiMouseButton.Left, pressX2 + 70, pressY2 + 70);
Assert.Equal(460f, handle.Width);
Assert.Equal(170f, handle.Height);
}
[Fact]
public void MountedChatScrollbar_ButtonsAndThumbDragDriveTranscriptModel()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 440f,
DatConstraintSource = controller.DatWindowInfo,
});
controller.AttachWindow(handle);
UiScrollbar bar = controller.Scrollbar;
UiScrollable scroll = controller.Transcript.Scroll;
scroll.LineHeight = 16;
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(350, scroll.ScrollY);
var screen = bar.ScreenPosition;
int centerX = (int)(screen.X + bar.Width / 2f);
// Top/decrement button.
int upY = (int)(screen.Y + 8f);
root.OnMouseDown(UiMouseButton.Left, centerX, upY);
root.OnMouseUp(UiMouseButton.Left, centerX, upY);
Assert.Equal(334, scroll.ScrollY);
// Re-layout must retain the position chosen through the scrollbar.
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(334, scroll.ScrollY);
// Drag the thumb toward the top of the track.
float trackTop = 16f;
float trackLength = bar.Height - 32f;
var (thumbY, thumbHeight) = UiScrollbar.ThumbRect(scroll, trackTop, trackLength);
int dragStartY = (int)(screen.Y + thumbY + thumbHeight / 2f);
int dragEndY = (int)(screen.Y + trackTop + thumbHeight / 2f);
root.OnMouseDown(UiMouseButton.Left, centerX, dragStartY);
root.OnMouseMove(centerX, dragEndY);
root.OnMouseUp(UiMouseButton.Left, centerX, dragEndY);
Assert.True(scroll.ScrollY < 334);
int draggedPosition = scroll.ScrollY;
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(draggedPosition, scroll.ScrollY);
}
}