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>
511 lines
21 KiB
C#
511 lines
21 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
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>
|
|
/// Smoke tests for <see cref="ChatWindowController.Bind"/> — no dats, no GL.
|
|
///
|
|
/// Building the Type-12 "skipped" elements via the pure <see cref="LayoutImporter"/>
|
|
/// path is the correct approach: we build a synthetic info tree that reflects the
|
|
/// real chat layout hierarchy (root → transcript panel + input bar as Type-3
|
|
/// containers, with Type-12 children for transcript + input, plus a Type-3 track
|
|
/// and menu), call <see cref="LayoutImporter.Build"/> to get the widget tree
|
|
/// (Type-12 children become property-driven text/field widgets), then call
|
|
/// <see cref="ChatWindowController.Bind"/> which binds those widgets in place.
|
|
/// </summary>
|
|
public class ChatWindowControllerTests
|
|
{
|
|
// ── Null-resolve helper (no GL needed) ─────────────────────────────────
|
|
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
|
|
|
// ── Capture bus — records every Publish call ────────────────────────────
|
|
private sealed class CaptureBus : ICommandBus
|
|
{
|
|
public readonly List<object> Published = new();
|
|
public void Publish<T>(T cmd) where T : notnull => Published.Add(cmd!);
|
|
}
|
|
|
|
// ── Synthetic element tree matching the real chat layout topology ────────
|
|
|
|
/// <summary>
|
|
/// Build a minimal synthetic ElementInfo tree that mirrors the real chat
|
|
/// layout (0x2100006F) with enough fidelity for Bind to succeed:
|
|
/// root (Type-3)
|
|
/// transcriptPanel (Type-3) [0x10000010]
|
|
/// transcript (Type-12, no media) [0x10000011] ← built as UiText by factory; Bind binds in place
|
|
/// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind
|
|
/// inputBar (Type-3) [0x10000013]
|
|
/// menu (Type-6) [0x10000014]
|
|
/// input (Type-12, Editable+Selectable) [0x10000016] ← built as UiField
|
|
/// send (Type-3) [0x10000019]
|
|
/// maxmin (Type-3) [0x1000046F]
|
|
/// </summary>
|
|
private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree(
|
|
ChatLog? log = null)
|
|
{
|
|
var transcriptNode = new ElementInfo
|
|
{
|
|
Id = 0x10000011u, Type = 12, // Type-12, no media → skipped by factory
|
|
X = 16, Y = 0, Width = 458, Height = 74,
|
|
};
|
|
var trackNode = new ElementInfo
|
|
{
|
|
Id = 0x10000012u, Type = 3,
|
|
X = 474, Y = 6, Width = 16, Height = 68,
|
|
};
|
|
var transcriptPanel = new ElementInfo
|
|
{
|
|
Id = 0x10000010u, Type = 3, X = 0, Y = 9, Width = 490, Height = 74,
|
|
};
|
|
transcriptPanel.Children.Add(transcriptNode);
|
|
transcriptPanel.Children.Add(trackNode);
|
|
|
|
var menuNode = new ElementInfo
|
|
{
|
|
Id = 0x10000014u, Type = 6, X = 0, Y = 0, Width = 46, Height = 17,
|
|
};
|
|
var inputNode = new ElementInfo
|
|
{
|
|
Id = 0x10000016u, Type = 12,
|
|
X = 46, Y = 0, Width = 398, Height = 17,
|
|
};
|
|
var inputState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
inputState.Properties.Values[0x16u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.Bool,
|
|
BoolValue = true,
|
|
};
|
|
inputState.Properties.Values[0x20u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.Bool,
|
|
BoolValue = true,
|
|
};
|
|
inputState.Properties.Values[0x27u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.Bool,
|
|
BoolValue = true,
|
|
};
|
|
inputNode.States[UiStateInfo.DirectStateId] = inputState;
|
|
var sendNode = new ElementInfo
|
|
{
|
|
Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17,
|
|
};
|
|
var inputBar = new ElementInfo
|
|
{
|
|
Id = 0x10000013u, Type = 3, X = 0, Y = 83, Width = 490, Height = 17,
|
|
};
|
|
inputBar.Children.Add(menuNode);
|
|
inputBar.Children.Add(inputNode);
|
|
inputBar.Children.Add(sendNode);
|
|
|
|
var maxMinNode = new ElementInfo
|
|
{
|
|
Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16,
|
|
};
|
|
|
|
// Type 1 -> UiButton (DatWidgetFactory.Create) so SetIndicatorOpen tests
|
|
// have a real IUiDatStateful to assert against. Normal/Highlight state
|
|
// MEDIA (not just a States entry) must be authored or TrySetRetailState
|
|
// can't resolve either name to an ActiveState (UiButton.HasStateMedia) —
|
|
// same fixture shape ToolbarControllerTests uses for its own
|
|
// SetPanelOpen(Normal/Highlight) mirror test.
|
|
ElementInfo MakeIndicator(uint id, float y)
|
|
{
|
|
var info = new ElementInfo { Id = id, Type = 1, X = 5, Y = y, Width = 16, Height = 16 };
|
|
info.StateMedia["Normal"] = (0x1u, 1);
|
|
info.StateMedia["Highlight"] = (0x2u, 1);
|
|
return info;
|
|
}
|
|
var indicator1 = MakeIndicator(0x10000522u, 5);
|
|
var indicator2 = MakeIndicator(0x10000523u, 22);
|
|
var indicator3 = MakeIndicator(0x10000524u, 39);
|
|
var indicator4 = MakeIndicator(0x10000525u, 56);
|
|
|
|
var root = new ElementInfo
|
|
{
|
|
Id = 0x10000600u, Type = 3, Width = 490, Height = 100,
|
|
};
|
|
root.Children.Add(transcriptPanel);
|
|
root.Children.Add(inputBar);
|
|
root.Children.Add(maxMinNode);
|
|
root.Children.Add(indicator1);
|
|
root.Children.Add(indicator2);
|
|
root.Children.Add(indicator3);
|
|
root.Children.Add(indicator4);
|
|
|
|
var layout = LayoutImporter.Build(root, NoTex, null);
|
|
var vm = new ChatVM(log ?? new ChatLog());
|
|
return (root, layout, vm);
|
|
}
|
|
|
|
// ── Test 1: Bind returns non-null with the minimal tree ──────────────────
|
|
|
|
[Fact]
|
|
public void Bind_Returns_NonNull_OnValidTree()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
}
|
|
|
|
// ── Test 2: Transcript is placed as a child of the transcript panel ──────
|
|
|
|
[Fact]
|
|
public void Bind_Transcript_IsChildOfTranscriptPanel()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
var panel = layout.FindElement(0x10000010u);
|
|
Assert.NotNull(panel);
|
|
// The transcript widget must be a child of the transcript panel.
|
|
Assert.Contains(ctrl!.Transcript, panel!.Children);
|
|
}
|
|
|
|
// ── Test 3: Input is placed as a child of the input bar ─────────────────
|
|
|
|
[Fact]
|
|
public void Bind_Transcript_ForcesScrollableTextMode()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
Assert.False(ctrl!.Transcript.Centered);
|
|
Assert.False(ctrl.Transcript.RightAligned);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_Input_IsChildOfInputBar()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
var bar = layout.FindElement(0x10000013u);
|
|
Assert.NotNull(bar);
|
|
Assert.Contains(ctrl!.Input, bar!.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void TranscriptLayout_IsReusedUntilContentOrWidthChanges()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
var ctrl = ChatWindowController.Bind(
|
|
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
|
|
|
|
vm.ShowSystemMessage("one wrapped transcript line");
|
|
IReadOnlyList<UiText.Line> first = ctrl.Transcript.LinesProvider();
|
|
IReadOnlyList<UiText.Line> unchanged = ctrl.Transcript.LinesProvider();
|
|
|
|
Assert.Same(first, unchanged);
|
|
Assert.Equal(1, ctrl.TranscriptLayoutBuildCount);
|
|
|
|
vm.ShowSystemMessage("second line");
|
|
IReadOnlyList<UiText.Line> appended = ctrl.Transcript.LinesProvider();
|
|
Assert.NotSame(first, appended);
|
|
Assert.Equal(2, ctrl.TranscriptLayoutBuildCount);
|
|
|
|
ctrl.Transcript.Width -= 40f;
|
|
IReadOnlyList<UiText.Line> resized = ctrl.Transcript.LinesProvider();
|
|
Assert.NotSame(appended, resized);
|
|
Assert.Equal(3, ctrl.TranscriptLayoutBuildCount);
|
|
|
|
vm.Clear();
|
|
Assert.Empty(ctrl.Transcript.LinesProvider());
|
|
Assert.Equal(4, ctrl.TranscriptLayoutBuildCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TranscriptLines_OutOfRangeLogTextType_CarriesPreviousLinesColor()
|
|
{
|
|
// Retail's SetFontColorHelper leaves m_curFontColor UNCHANGED for an
|
|
// out-of-range index instead of reverting to a default (research
|
|
// doc §3.2) — RetailChatColorTable.TryGetColor returns false for
|
|
// any index >= 0x22 and ChatWindowController.GetTranscriptLines
|
|
// carries the prior line's resolved color forward. Three entries;
|
|
// the middle one uses 0x22 (one past the last real retail slot,
|
|
// 0x21) so its rendered color must equal the first line's, not
|
|
// the third's.
|
|
var log = new ChatLog();
|
|
var (rootInfo, layout, vm) = BuildTestTree(log);
|
|
var bus = new CaptureBus();
|
|
// 0x22 is above the default main filter's low dword (retail's
|
|
// 0xFBFFFFFF only covers types 0-31) — accept everything so this
|
|
// test isolates the color-carry-forward behavior it's actually
|
|
// about from the (correctly) separate window-filter concern.
|
|
var filters = new ChatWindowState();
|
|
filters.SetFilter(ChatWindowState.MainWindowId, ulong.MaxValue);
|
|
var ctrl = ChatWindowController.Bind(
|
|
rootInfo, layout, vm, () => bus, filters, null, null, NoTex)!;
|
|
|
|
log.OnSystemMessage("first", chatType: 0x05u); // System, colorBrightPurple
|
|
log.OnSystemMessage("middle", chatType: 0x22u); // out of range — carries 0x05's color
|
|
log.OnSystemMessage("third", chatType: 0x00u); // Default, colorGreen
|
|
|
|
IReadOnlyList<UiText.Line> lines = ctrl.Transcript.LinesProvider();
|
|
|
|
Assert.Equal(3, lines.Count);
|
|
Assert.Equal(lines[0].Color, lines[1].Color);
|
|
Assert.NotEqual(lines[0].Color, lines[2].Color);
|
|
}
|
|
|
|
// ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ─────
|
|
|
|
[Fact]
|
|
public void Bind_InputSubmit_PublishesSendChatCmd()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
ctrl!.Input.OnSubmit!.Invoke("hello world");
|
|
|
|
// ChatCommandRouter.Submit should have published a SendChatCmd.
|
|
Assert.Single(bus.Published);
|
|
var cmd = Assert.IsType<SendChatCmd>(bus.Published[0]);
|
|
Assert.Equal("hello world", cmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_LifestoneSubmit_PublishesTypedClientCommand()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
ctrl!.Input.OnSubmit!.Invoke("/ls");
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
|
|
}
|
|
|
|
// ── Test 5: Channel change updates the channel used by subsequent submits ─
|
|
|
|
[Fact]
|
|
public void Bind_ChannelChange_UpdatesSubmitChannel()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
// Switch channel to General via the generic OnSelect (payload is ChatChannelKind).
|
|
ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General);
|
|
ctrl.Input.OnSubmit!.Invoke("hey all");
|
|
|
|
Assert.Single(bus.Published);
|
|
var cmd = Assert.IsType<SendChatCmd>(bus.Published[0]);
|
|
Assert.Equal(ChatChannelKind.General, cmd.Channel);
|
|
}
|
|
|
|
// ── Test 6: Bind returns null when required elements are absent ──────────
|
|
|
|
[Fact]
|
|
public void Bind_Returns_Null_WhenTranscriptPanelMissing()
|
|
{
|
|
// Build a layout that is missing the transcript panel entirely.
|
|
var root = new ElementInfo { Id = 0x10000600u, Type = 3, Width = 490, Height = 100 };
|
|
// No children → TranscriptPanelId and InputBarId are absent from the widget tree.
|
|
|
|
var layout = LayoutImporter.Build(root, NoTex, null);
|
|
var vm = new ChatVM(new ChatLog());
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.Null(ctrl);
|
|
}
|
|
|
|
// ── Input field resize: Campaign CH user-gate round 1, item G ────────────
|
|
// The input line overflowed the chat window's right edge on resize. Its
|
|
// right edge held a FIXED absolute pixel position (either the imported
|
|
// UiLayoutPolicy's mode-0 "frozen at current" fallback, or the
|
|
// AnchorEdges default with no Right bit) — nothing re-ran the
|
|
// Left/Width recompute on a plain window resize, only at bind time and
|
|
// on channel change. Bind now leaves the input's right edge tracking
|
|
// the live parent width either way.
|
|
|
|
[Fact]
|
|
public void Bind_InputField_WithNoImportedLayoutPolicy_NeverOverflowsOnNarrowerResize()
|
|
{
|
|
// BuildTestTree's synthetic ElementInfo nodes never set
|
|
// HasOriginalParentSize, so DatWidgetFactory.CreateLayoutPolicy
|
|
// returns null for every widget here — this exercises the
|
|
// AnchorEdges fallback branch of the fix.
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
Assert.NotNull(ctrl);
|
|
Assert.Null(ctrl!.Input.LayoutPolicy);
|
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, ctrl.Input.Anchors & (AnchorEdges.Left | AnchorEdges.Right));
|
|
|
|
// First frame after Bind(): the per-frame draw pass calls ApplyAnchor
|
|
// against the LIVE (still-authored, 490px) inputBar width — this is
|
|
// the lazy margin CAPTURE, matching UiElement.ApplyAnchor's
|
|
// "!_anchorCaptured" first-call semantics. Only THEN does a resize
|
|
// (a later frame, a smaller parent width) exercise the stretch.
|
|
const float authoredParentWidth = 490f;
|
|
ctrl.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height);
|
|
|
|
const float narrowerParentWidth = 300f;
|
|
ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height);
|
|
|
|
Assert.True(
|
|
ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth,
|
|
$"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})");
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_InputField_WithNoImportedLayoutPolicy_GrowsWithWiderResize()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
Assert.NotNull(ctrl);
|
|
|
|
const float authoredParentWidth = 490f;
|
|
ctrl!.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height);
|
|
float originalWidth = ctrl.Input.Width;
|
|
|
|
const float widerParentWidth = 800f;
|
|
ctrl.Input.ApplyAnchor(widerParentWidth, ctrl.Input.Height);
|
|
|
|
Assert.True(ctrl.Input.Width > originalWidth, "the input should widen when the window grows");
|
|
Assert.True(ctrl.Input.Left + ctrl.Input.Width <= widerParentWidth);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_InputField_WithImportedLayoutPolicy_RightEdgeTracksParentDeltaInsteadOfFreezing()
|
|
{
|
|
// Mirror the REAL production import path: the input field carries an
|
|
// authored UiLayoutPolicy (HasOriginalParentSize=true, as a real
|
|
// ImportInfos-resolved LayoutDesc element would). Right=0 here is
|
|
// retail's raw edge mode BEFORE the fix's upgrade — proving Bind
|
|
// replaces it with mode 1 rather than leaving mode 0's "frozen at
|
|
// current pixel position" behavior in place.
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var inputInfo = FindById(rootInfo, 0x10000016u)
|
|
?? throw new System.InvalidOperationException("test fixture missing the input node");
|
|
inputInfo.HasOriginalParentSize = true;
|
|
inputInfo.OriginalParentWidth = 490f;
|
|
inputInfo.OriginalParentHeight = 17f;
|
|
inputInfo.Left = 1u; // near-edge: fixed to current (retail mode 1)
|
|
inputInfo.Top = 1u;
|
|
inputInfo.Right = 0u; // far-edge mode this fix must upgrade away from
|
|
inputInfo.Bottom = 1u;
|
|
|
|
// Rebuild the widget tree now that the fixture carries the policy
|
|
// inputs (BuildTestTree already built one without them).
|
|
layout = LayoutImporter.Build(rootInfo, NoTex, null);
|
|
var bus = new CaptureBus();
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
Assert.NotNull(ctrl!.Input.LayoutPolicy);
|
|
Assert.Equal(1u, ctrl.Input.LayoutPolicy!.RightMode);
|
|
|
|
const float narrowerParentWidth = 300f;
|
|
ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height);
|
|
|
|
Assert.True(
|
|
ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth,
|
|
$"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})");
|
|
}
|
|
|
|
private static ElementInfo? FindById(ElementInfo node, uint id)
|
|
{
|
|
if (node.Id == id) return node;
|
|
foreach (var child in node.Children)
|
|
{
|
|
if (FindById(child, id) is { } found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// WrapText moved to ChatTranscriptRendererTests.cs (CH6a/b REJECT-review
|
|
// NIT 5 — WrapText itself moved from this class to ChatTranscriptRenderer).
|
|
|
|
// ── SetIndicatorOpen: Campaign CH slice CH6b — the button mirror ─────────
|
|
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80: State 6
|
|
// (Highlight) when the floating window is visible, State 1 (Normal)
|
|
// when it is not.
|
|
|
|
[Theory]
|
|
[InlineData(1, 0x10000522u)]
|
|
[InlineData(2, 0x10000523u)]
|
|
[InlineData(3, 0x10000524u)]
|
|
[InlineData(4, 0x10000525u)]
|
|
public void SetIndicatorOpen_Open_SetsHighlightState(int windowId, uint indicatorId)
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
|
|
var indicator = Assert.IsType<UiButton>(layout.FindElement(indicatorId));
|
|
|
|
ctrl.SetIndicatorOpen(windowId, open: true);
|
|
|
|
Assert.Equal(UiButtonStateMachine.Highlight, indicator.ActiveRetailStateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetIndicatorOpen_Closed_SetsNormalState()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
|
|
var indicator = Assert.IsType<UiButton>(layout.FindElement(0x10000522u));
|
|
|
|
ctrl.SetIndicatorOpen(1, open: true);
|
|
ctrl.SetIndicatorOpen(1, open: false);
|
|
|
|
Assert.Equal(UiButtonStateMachine.Normal, indicator.ActiveRetailStateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetIndicatorOpen_DoesNotAffectOtherWindowsIndicators()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
|
|
var indicator2 = Assert.IsType<UiButton>(layout.FindElement(0x10000523u));
|
|
|
|
ctrl.SetIndicatorOpen(1, open: true);
|
|
|
|
Assert.Equal(UiButtonStateMachine.Normal, indicator2.ActiveRetailStateId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(5)]
|
|
public void SetIndicatorOpen_OutOfRangeWindowId_Throws(int windowId)
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
|
|
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => ctrl.SetIndicatorOpen(windowId, open: true));
|
|
}
|
|
}
|