acdream/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
Erik 1aa7709988 fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:09:32 +02:00

489 lines
20 KiB
C#

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_ImportVisibleAndInert(uint indicatorId)
{
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);
// Inert: CH6a does not wire a click handler (CH6b's job — the
// one-directional visibility mirror has no button-press behavior of
// its own in retail either, per the research doc §1.4). UiButton
// structurally always reports HandlesClick=true (it can receive the
// event), but with no OnClick/OnClickAt bound, a click is a no-op.
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 "inert" — 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; a press
// and release must leave Selected unchanged.
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(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<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);
}
[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);
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<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);
}
[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);
}
}