Item 3 (#364): every honesty marker is now gone from user-visible /help text. AllegianceOverview/HouseOverview's "[IMPLEMENTED]" tags and trailing "Subcommands NOT marked..." sentences, and Day/Log/Render/Motd's appended "NOT YET IMPLEMENTED in acdream" tails, are removed; the underlying retail text is corrected/completed against the pseudo-C's own pristine consolidated data dumps (Log and Motd had been silently truncated; Render was entirely acdream-authored and is replaced with the real retail usage string). The three PARTIAL /help group topics (channels/chatting/commands) are now COMPLETE verbatim listings: HelpStupidChannelHack's three "vtable slot" operands, previously believed undecodable, are the same pooled/mislabeled-data artifact this campaign has hit before (AP-113's precedent) — reading the function's own disassembly for the push imm32 preceding each constructor call resolves all three directly. messagetypes is now a real ported construction (IsLegalChannel's 14-id whitelist + LogTextTypeToString's name table + the exact join/wrap format) instead of an acdream summary. Register row AP-184 retired. Item 5: the main window's 1/2/3/4 indicator buttons now toggle their floating chat window on click, per the user's retail memory overruling the earlier decomp-only reading. UIElement_Button::HandleButtonClick has its own generic click-driven action dispatch (property 0x12) reaching the same DoVisibilityToggleAction the Alt+1..4 keybinds use; the button fixture confirms this half is genuinely armed, but the floating-window fixture authors no matching listener-registration property, so the generic mechanism has no proven target in the data on hand. Per CLAUDE.md, the user's retail memory is the axiom regardless: ChatWindowController.BindIndicatorClicks wires each indicator's click through the same ToggleFloatingChatWindow chokepoint the keybinds use, as explicit user-directed retail behavior. SetIndicatorOpen stays the sole writer of the Selected mirror so the visual stays consistent through the click round trip. Full reconciliation in docs/research/2026-08-09-chat-retail-window-shell.md §1.4. Campaign plan gets the round-4 findings section; items 1+2 (text-style) are under parallel research, item 4 passed, item 6 deferred to the settings track. Suite: 12,579 passed / 4 skipped / 0 failed (Release, complete solution), up from baseline 12,553/4/0 — net +26 tests, zero regressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
622 lines
27 KiB
C#
622 lines
27 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 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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|