Four of the five owner-reported chat deltas (2026-08-24), each traced to
its retail mechanism:
1. Missing gold separator left of the input: the chat input authors two
1px Type-3 rail CHILDREN (0x10000017 at X=0, 0x10000018
right-anchored) whose only media is Normal_focussed (0x06004D67,
live-DAT probed). UiField consumes its DAT children, so the rails
were swallowed and never drawn. The factory now folds them into the
field, which draws both while focused.
2. Button says "General", retail says "Gen": the talk button's short
caption comes from per-target ID_Chat_ChatTargetMenu* strings
(HandleSelection @0x004cd540, StringTable 0x23000001 via
compute_str_hash — recovered from the raw binary after BN elided the
ids into name-hash globals). Authored set: Chat/Tell/Fell/Pat/Mon/
Vas/Alg/Gen/Trade/LFG/RP/Soc/Olt. Menu rows + squelch/tell specials
resolve from the same table (ID_Chat_TellTo*); production resolves
through DatStringResolver, fallbacks ARE the authored EoR English.
ChatStringsLiveDatTests pins the whole set against the installed DAT.
3. Channel button stayed green while the popup was open: retail's
pressed face is the momentary physical press ("flicks"); the OPEN
state drives only the arrow-cap child's StateDesc swap
(UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe).
UiMenu now keys the face on the press, not on IsOpen.
4. Window-title/button text "vibrates" while dragging windows:
DrawStringDatPass snapped glyphs with MathF.Round — banker's
rounding. A centered label with a constant .5 fraction alternates
round-up/round-down across successive integers, double-stepping then
sticking while the background glides. Half-up Floor(v+0.5) snaps
every tie one way: uniform 1px steps in lock-step with sprites.
The fifth report (input row sticking out on window resize) did not
reproduce: a controller-bound fixture resize at 220/300/600px keeps the
whole input row inside the window (test added) — awaiting the owner's
exact gesture.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
737 lines
32 KiB
C#
737 lines
32 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Dat-free conformance tests for the committed chat_2100006f.json golden fixture —
|
|
/// retail's ACTUAL main chat window (LayoutDesc <c>0x2100006F</c>, window root
|
|
/// <c>0x10000600</c>, authored 410x100; Campaign CH slice CH6a). Verifies that
|
|
/// LayoutImporter.ImportInfos correctly resolves the BaseElement / BaseLayoutId
|
|
/// inheritance chain.
|
|
/// </summary>
|
|
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<UiButton>(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<int>();
|
|
controller!.BindIndicatorClicks(windowId =>
|
|
{
|
|
toggled.Add(windowId);
|
|
return true;
|
|
});
|
|
|
|
UiElement? indicator = layout.FindElement(indicatorId);
|
|
var button = Assert.IsType<UiButton>(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<UiButton>(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 BoundChatWindow_LockedTwinBorderArt_SeedsUnlockedUntilRegistration(uint lockedTwinId)
|
|
{
|
|
// Bind seeds the unlocked skin for standalone layouts. Once registered,
|
|
// RetailWindowLockPresentationController applies the canonical UiLocked
|
|
// state; its real-fixture swap is covered by that controller's tests.
|
|
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<UiText>(layout.FindElement(0x10000011u));
|
|
var input = Assert.IsType<UiField>(layout.FindElement(0x10000016u));
|
|
|
|
Assert.True(transcript.Selectable);
|
|
Assert.True(input.Selectable);
|
|
Assert.True(input.OneLine);
|
|
Assert.Equal(0x10000016u, input.DatElementId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign CH round 4 (<c>docs/research/2026-08-10-retail-ui-text-style.md</c>
|
|
/// §2.5/§2.6): the chat transcript's base style (<c>0x10000372</c> in layout
|
|
/// <c>0x2100003F</c>) authors NO property 0x21 (Outline) anywhere in its
|
|
/// inheritance chain — regenerated straight from the real installed DAT
|
|
/// (<c>RetailLayoutFixtureGenerator</c>), proving the property-0x21/0x22
|
|
/// importer (<see cref="ElementReader.ApplyCanonicalLegacyProjection"/>)
|
|
/// resolves this correctly end to end, not merely by a missing-field JSON
|
|
/// default.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChatFixture_TranscriptElementInfo_CarriesNoOutline()
|
|
{
|
|
var root = FixtureLoader.LoadChatInfos();
|
|
var transcript = Find(root, 0x10000011u)!;
|
|
|
|
Assert.False(transcript.Outline);
|
|
Assert.Null(transcript.OutlineColor);
|
|
}
|
|
|
|
/// <summary>Same fact at the built-widget level — <c>DatWidgetFactory.BuildText</c>
|
|
/// must not turn a false <c>ElementInfo.Outline</c> into a true <c>UiText.Outline</c>.</summary>
|
|
[Fact]
|
|
public void ChatFixture_TranscriptWidget_OutlineIsOff()
|
|
{
|
|
var layout = FixtureLoader.LoadChat();
|
|
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
|
|
|
|
Assert.False(transcript.Outline);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The transcript's default fill resolves to the authored
|
|
/// <c>ARGB(255,204,204,204)</c> (style <c>0x10000372</c> property 0x1B) —
|
|
/// Fix 5's target. Pins the value at the built-widget level, on the SAME
|
|
/// regenerated fixture the outline test above uses.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChatFixture_TranscriptWidget_DefaultColorIsAuthoredOffWhite()
|
|
{
|
|
var layout = FixtureLoader.LoadChat();
|
|
var transcript = Assert.IsType<UiText>(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<UiScrollbar>(layout.FindElement(0x10000012u));
|
|
|
|
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
|
|
Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite);
|
|
Assert.Equal(0x06004C63u, scrollbar.ThumbSprite);
|
|
Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite);
|
|
// Retail seating (2026-08-24): UpdateScrollingArea @0x00470AA0 puts
|
|
// the INCREMENT designee (0x10000072, UP-arrow art 0x06004C6C) on the
|
|
// top button and the DECREMENT designee (0x10000071, DOWN-arrow
|
|
// 0x06004C69) on the bottom, ignoring authored Y.
|
|
Assert.Equal(0x06004C6Cu, scrollbar.UpSprite);
|
|
Assert.Equal(0x06004C69u, scrollbar.DownSprite);
|
|
Assert.Equal(0x06004C64u, scrollbar.ThumbRolloverSprite);
|
|
Assert.Equal(0x06004C65u, scrollbar.ThumbPressedSprite);
|
|
}
|
|
|
|
[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<UiButton>(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<UiResizeGrip>(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<UiResizeGrip>(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<UiResizeGrip>(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<UiResizeGrip>(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<UiResizeGrip>(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<UiResizeGrip>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-24 owner report: resizing the chat window let the text input
|
|
/// stick out past the window edge. The authored edge modes (input row
|
|
/// 0x10000013 L1/R1 = stretch, field 0x10000016 L1/R1 = stretch, Send
|
|
/// 0x10000019 L2/R1 = right-docked, menu button 0x10000014 L1/R2 =
|
|
/// left-docked) must keep the whole input row inside the window at every
|
|
/// size, narrower AND wider than the authored 410.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(300f, 100f)]
|
|
[InlineData(600f, 160f)]
|
|
[InlineData(220f, 80f)]
|
|
public void ResizingTheWindow_KeepsTheInputRowInsideIt(float width, float height)
|
|
{
|
|
var infos = FixtureLoader.LoadChatInfos();
|
|
ImportedLayout layout = LayoutImporter.Build(infos, NoTex, null);
|
|
// Bind the REAL controller — production geometry overrides included.
|
|
var controller = ChatWindowController.Bind(
|
|
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance,
|
|
new ChatWindowState(), null, null, NoTex);
|
|
Assert.NotNull(controller);
|
|
UiElement window = layout.FindElement(0x10000600u)!;
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
root.AddChild(window);
|
|
ApplyLayoutPassLocal(window);
|
|
|
|
window.Width = width;
|
|
window.Height = height;
|
|
window.ResetAnchorCapture();
|
|
ApplyLayoutPassLocal(window);
|
|
ApplyLayoutPassLocal(window); // second frame — policies settle
|
|
|
|
UiElement inputBar = layout.FindElement(0x10000013u)!;
|
|
UiElement input = layout.FindElement(0x10000016u)!;
|
|
UiElement send = layout.FindElement(0x10000019u)!;
|
|
UiElement menuButton = layout.FindElement(0x10000014u)!;
|
|
|
|
Assert.True(inputBar.Left >= 0f && inputBar.Left + inputBar.Width <= width + 0.5f,
|
|
$"input bar [{inputBar.Left},{inputBar.Left + inputBar.Width}] escapes window width {width}");
|
|
float inputRight = inputBar.Left + input.Left + input.Width;
|
|
Assert.True(inputRight <= width + 0.5f,
|
|
$"input field right {inputRight} escapes window width {width}");
|
|
float sendRight = inputBar.Left + send.Left + send.Width;
|
|
Assert.True(sendRight <= width + 0.5f,
|
|
$"send right {sendRight} escapes window width {width}");
|
|
Assert.True(menuButton.Left >= 0f, "menu button escaped left");
|
|
// The field must stay BETWEEN the menu button and the send button.
|
|
Assert.True(input.Left >= menuButton.Left + menuButton.Width - 0.5f,
|
|
$"input {input.Left} overlaps menu button ending {menuButton.Left + menuButton.Width}");
|
|
Assert.True(input.Left + input.Width <= send.Left + 0.5f,
|
|
$"input ends {input.Left + input.Width} past send start {send.Left}");
|
|
}
|
|
|
|
private static void ApplyLayoutPassLocal(UiElement parent)
|
|
{
|
|
foreach (var child in parent.Children)
|
|
{
|
|
child.ApplyAnchor(parent.Width, parent.Height);
|
|
ApplyLayoutPassLocal(child);
|
|
}
|
|
}
|
|
}
|