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;
using AcDream.Runtime.Chat;
namespace AcDream.App.Tests.UI.Layout;
///
/// Smoke tests for — no dats, no GL.
///
/// Building the Type-12 "skipped" elements via the pure
/// 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 to get the widget tree
/// (Type-12 children become property-driven text/field widgets), then call
/// which binds those widgets in place.
///
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 Published = new();
public void Publish(T cmd) where T : notnull => Published.Add(cmd!);
}
// ── Synthetic element tree matching the real chat layout topology ────────
///
/// 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]
///
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;
}
// The unseen-text indicator (CT-C1). Authors Normal and Ghosted media
// the way the real element does, so TrySetRetailState can resolve both
// — Ghosted is its authored DEFAULT and is what retail's own click
// handler sets (SetState(0xD)).
var unread = new ElementInfo
{
Id = 0x1000048Cu, Type = 1, X = 0, Y = 57, Width = 16, Height = 16,
};
unread.StateMedia["Normal"] = (0x3u, 1);
unread.StateMedia["Ghosted"] = (0x4u, 1);
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(unread);
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);
}
// ── CT-C1: the unseen-text indicator ────────────────────────────────
private static ChatWindowController BindController()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
return ctrl!;
}
[Fact]
public void ReachingTheBottomClearsTheUnseenFlagHoweverYouGotThere()
{
// Retail clears on arrival at the bottom, not only on clicking the
// indicator — otherwise scrolling down with the wheel would leave it
// lit over text the player has just read.
ChatWindowController ctrl = BindController();
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0); // scrolled up
Assert.False(ctrl.Transcript.Scroll.AtEnd);
ctrl.Transcript.Scroll.ScrollToEnd();
ctrl.UpdateUnreadIndicator();
Assert.True(ctrl.Transcript.Scroll.AtEnd);
}
[Fact]
public void TheIndicatorIsDrivenByAuthoredStateNotVisibility()
{
// Retail's own click handler ends in SetState(0xD) — Ghosted — which
// is also the element's authored default; the unread look is state 1
// (Normal), whose media list carries SIX frames and is where the
// flashing comes from. Hiding the element instead would look almost
// right and could never blink.
ChatWindowController ctrl = BindController();
UiElement indicator = Assert.IsAssignableFrom(
ctrl.UnreadIndicatorForTest);
// Visibility is NOT the mechanism: the element stays visible and
// changes STATE. Ghosted authors no media on the real element, so it
// draws nothing without being hidden.
Assert.True(indicator.Visible);
var stateful = Assert.IsAssignableFrom(indicator);
Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState);
// A line arriving while scrolled up flips it to Normal — the state
// whose authored media carries the six flash frames.
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
ctrl.SetUnreadForTest(true);
ctrl.UpdateUnreadIndicator();
Assert.Equal("Normal", ((UiButton)indicator).ActiveState);
// ...and returning to the bottom puts it back.
ctrl.Transcript.Scroll.ScrollToEnd();
ctrl.UpdateUnreadIndicator();
Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState);
_ = stateful;
}
[Fact]
public void ClickingTheIndicatorJumpsToTheNewestText()
{
ChatWindowController ctrl = BindController();
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
Assert.False(ctrl.Transcript.Scroll.AtEnd);
ctrl.ScrollToNewestAndClearUnread();
Assert.True(ctrl.Transcript.Scroll.AtEnd);
}
[Fact]
public void StartTell_PrefillsTheEntryAndPutsTheCaretAtTheEnd()
{
// Retail's ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, "
// into the chat entry and takes focus, so the player can type straight
// into a reply. The trailing space matters: without it the first thing
// they type joins the comma.
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
ctrl!.StartTell("Dww");
Assert.Equal("@tell Dww, ", ctrl.Input.Text);
// ...and the caret sits AFTER the prefix, ready to type into. It landed
// at column 0 in the first cut because an explicit MoveCaret(int.MaxValue)
// was stacked on top of SetText, which already ends there — MoveCaret
// takes a delta, so that overflowed negative and clamped to zero.
Assert.Equal("@tell Dww, ".Length, ctrl.Input.CaretPos);
}
// ── Talk-focus specials: "Tell to X" / "Squelch (ignore) X" ─────────────
///
/// Both entries act on the CURRENT SELECTION and carry its name, and both
/// used to be deliberate no-ops.
///
[Fact]
public void TalkFocusSpecials_CarryTheSelectedName_AndActOnIt()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
string? selected = "Dww";
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex,
selectedTargetName: () => selected);
Assert.NotNull(ctrl);
UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u));
// Retail substitutes the name into both labels
// (StringInfo::AddVariable_String @0x004CD91C / @0x004CD982).
menu.OnOpen!.Invoke();
Assert.Equal("Squelch (ignore) Dww", menu.Items[0].Label);
Assert.Equal("Tell to Dww", menu.Items[1].Label);
// Picking "Tell to Dww" aims the chat bar at them, so an ordinary typed
// line is sent as a tell rather than being dropped for want of a target.
menu.OnSelect!.Invoke(menu.Items[1].Payload);
ctrl!.Input.OnSubmit!.Invoke("hello");
SendChatCmd tell = Assert.IsType(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Tell, tell.Channel);
Assert.Equal("Dww", tell.TargetName);
Assert.Equal("hello", tell.Text);
// Squelch reaches the already-registered /squelch verb rather than
// reimplementing the request.
bus.Published.Clear();
menu.OnSelect.Invoke(menu.Items[0].Payload);
ExecuteClientCommandCmd squelch =
Assert.IsType(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.Squelch, squelch.Command);
Assert.Equal("Dww", squelch.Arguments);
}
[Fact]
public void TalkFocusSpecials_AreInertAndUnnamedWithNothingSelected()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex,
selectedTargetName: () => null);
Assert.NotNull(ctrl);
UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u));
menu.OnOpen!.Invoke();
Assert.Equal("Squelch (ignore)", menu.Items[0].Label);
Assert.Equal("Tell to Selected", menu.Items[1].Label);
// Retail arms the tell slot only once a talkable object is selected
// (SetTalkFocusEnabled(2, 1) @0x004CD9B0).
Assert.False(menu.EnabledProvider!(menu.Items[0].Payload));
Assert.False(menu.EnabledProvider(menu.Items[1].Payload));
menu.OnSelect!.Invoke(menu.Items[1].Payload);
menu.OnSelect.Invoke(menu.Items[0].Payload);
Assert.Empty(bus.Published);
}
// ── 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 first = ctrl.Transcript.LinesProvider();
IReadOnlyList unchanged = ctrl.Transcript.LinesProvider();
Assert.Same(first, unchanged);
Assert.Equal(1, ctrl.TranscriptLayoutBuildCount);
vm.ShowSystemMessage("second line");
IReadOnlyList appended = ctrl.Transcript.LinesProvider();
Assert.NotSame(first, appended);
Assert.Equal(2, ctrl.TranscriptLayoutBuildCount);
ctrl.Transcript.Width -= 40f;
IReadOnlyList 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 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(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(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(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(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(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(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(() => ctrl.SetIndicatorOpen(windowId, open: true));
}
}