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; /// /// 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; } 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, 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, 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, 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, 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, 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(); var ctrl = ChatWindowController.Bind( rootInfo, layout, vm, () => bus, 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, 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, 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, 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, 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, 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, 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, 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: Campaign CH user-gate round 1, item F ────────────────────── // /help (and "probably many places") never split on embedded '\n' — the // whole multi-line blob rode the single early-out as ONE line. Split on // '\n' first, then word-wrap each segment; a single-segment text keeps // the pre-existing early-out behavior exactly. private static float MeasureByCharCount(string s) => s.Length; [Fact] public void WrapText_EmbeddedNewlines_ProduceOneRenderedLinePerSegment() { string text = "line one\nline two\nline three"; // maxW is generous — every segment fits without word-wrapping, so // this isolates the newline-split behavior specifically. var lines = new List(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount)); Assert.Equal(new[] { "line one", "line two", "line three" }, lines); } [Fact] public void WrapText_CarriageReturnNewline_NormalizesTheSameAsBareNewline() { string text = "line one\r\nline two"; var lines = new List(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount)); Assert.Equal(new[] { "line one", "line two" }, lines); } [Fact] public void WrapText_SegmentLongerThanMaxWidth_StillWordWraps() { // Each segment is independently word-wrapped by the SAME algorithm // the single-line path always used — a multi-line server message // whose second line overflows the window still wraps that line. string text = "short\nthis segment is much too long to fit on one line"; var lines = new List(ChatWindowController.WrapText(text, 10f, MeasureByCharCount)); Assert.Equal("short", lines[0]); Assert.True(lines.Count > 2, "the long second segment should have wrapped into multiple lines"); Assert.All(lines, line => Assert.True(MeasureByCharCount(line) <= 10f)); Assert.Equal( "this segment is much too long to fit on one line", string.Join(" ", lines.Skip(1))); } [Fact] public void WrapText_SingleSegmentText_KeepsTheEarlyOutBehavior() { // No '\n' at all — the pre-existing single-line early-out path // (whole text fits => returned verbatim as one fragment) is // unchanged. string text = "no newlines here"; var lines = new List(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount)); Assert.Equal(new[] { text }, lines); } [Fact] public void WrapText_ConsecutiveNewlines_ProduceABlankLine() { string text = "first\n\nthird"; var lines = new List(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount)); Assert.Equal(new[] { "first", "", "third" }, lines); } // ── 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, 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, 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, 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, null, null, NoTex)!; Assert.Throws(() => ctrl.SetIndicatorOpen(windowId, open: true)); } }