The blink is not code. It is data, and we were throwing it away. A retail UI state's media is a small program: images interleaved with timed pauses, branches, and a terminal hand-off to another state. Our importer kept the FIRST image per state and dropped the rest, so nothing authored could ever animate — the indicator was correct in every other respect and simply sat still. Measured from the installed dats (LayoutDump --media 0x1000048C), the chat unseen-text indicator's Normal state authors thirteen steps: two frames alternating every half second, three times, then `State 13` — Ghosted, whose authored 0x3B is Invisible. So retail's indicator is a three-second attention FLASH that hides itself, not a badge that stays lit until you scroll to the bottom. Nobody would guess that from the code, because there is no blink code anywhere; the behaviour lives entirely in the authored sequence. Our shipped version stayed lit, which is the one thing the data says it must not do. Sampling is a pure function of (steps, elapsed) rather than a playback object holding a cursor, so an element only has to remember WHEN its state began and the whole thing is testable without a clock, a GPU or a frame loop. One shared UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has no tick of its own. The controller change is the other half: it starts the flash on the rising edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero and it would never blink at all — which is the failure mode the second new test exists to catch, and which no "is it visible?" assertion would notice. When the sequence reaches its terminal step the controller follows it down instead of re-lighting it. Two guesses are refused rather than made, and both are registered: a Pause's max duration (every sequence measured sets min == max, and what the range MEANS is not in the decomp) and a sub-1 branch probability (falls through, the direction where a malformed sequence stops rather than animates forever). A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin inside a frame. Kept `Other` steps in the list rather than filtering them, so a jump's authored index still lands on the entry it names. Register: CT-3, CT-4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
741 lines
31 KiB
C#
741 lines
31 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;
|
|
using AcDream.Runtime.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;
|
|
}
|
|
// 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 TheIndicatorFollowsItsAuthoredPerStateVisibility()
|
|
{
|
|
// The element authors dat property 0x3B ("Invisible") per state, and
|
|
// it is what decides whether the thing is on screen:
|
|
// state 13 Ghosted 0x3B = True -> hidden
|
|
// state 1 Normal 0x3B = False -> shown
|
|
// (measured with LayoutDump --props on 0x2100006F).
|
|
//
|
|
// UiDatElement applies 0x3B on a state change; UiButton does not, and
|
|
// this element builds as a button — so driving the state ALONE leaves
|
|
// it hidden forever, which is exactly the regression this pins.
|
|
ChatWindowController ctrl = BindController();
|
|
UiElement indicator = Assert.IsAssignableFrom<UiElement>(
|
|
ctrl.UnreadIndicatorForTest);
|
|
|
|
Assert.False(indicator.Visible); // Ghosted at rest
|
|
|
|
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
|
|
ctrl.Transcript.Scroll.SetScrollY(0);
|
|
ctrl.SetUnreadForTest(true);
|
|
ctrl.UpdateUnreadIndicator();
|
|
Assert.True(indicator.Visible); // Normal once text is unseen
|
|
|
|
ctrl.Transcript.Scroll.ScrollToEnd();
|
|
ctrl.UpdateUnreadIndicator();
|
|
Assert.False(indicator.Visible); // back to Ghosted
|
|
}
|
|
|
|
[Fact]
|
|
public void TheAuthoredHandOffEndsTheFlashEvenWhileStillScrolledUp()
|
|
{
|
|
// The blink is not code, it is DATA: the Normal state's media authors
|
|
// six image frames, three pauses' worth of alternation, and then a
|
|
// State step back to Ghosted (measured with LayoutDump --media on
|
|
// 0x1000048C). So retail's indicator is a three-second attention
|
|
// FLASH that hides itself — not a badge that stays lit until you
|
|
// scroll down. The controller has to let the sequence finish rather
|
|
// than re-lighting it every frame.
|
|
ChatWindowController ctrl = BindController();
|
|
var indicator = Assert.IsType<UiButton>(ctrl.UnreadIndicatorForTest);
|
|
|
|
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
|
|
ctrl.Transcript.Scroll.SetScrollY(0);
|
|
ctrl.SetUnreadForTest(true);
|
|
ctrl.UpdateUnreadIndicator();
|
|
Assert.True(indicator.Visible);
|
|
|
|
// Stand in for the sequence reaching its terminal State step.
|
|
indicator.TrySetRetailState(UiButtonStateMachine.Ghosted);
|
|
ctrl.UpdateUnreadIndicator();
|
|
|
|
Assert.False(indicator.Visible);
|
|
Assert.False(ctrl.Transcript.Scroll.AtEnd); // still scrolled up
|
|
}
|
|
|
|
[Fact]
|
|
public void TheFlashIsStartedOnceRatherThanEveryFrame()
|
|
{
|
|
// Re-setting Normal every frame would restart the sequence, pinning it
|
|
// on frame zero — it would sit there lit and never blink at all. That
|
|
// is the whole bug this pins, and it is invisible to a test that only
|
|
// checks the indicator is showing.
|
|
ChatWindowController ctrl = BindController();
|
|
var indicator = Assert.IsType<UiButton>(ctrl.UnreadIndicatorForTest);
|
|
|
|
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
|
|
ctrl.Transcript.Scroll.SetScrollY(0);
|
|
ctrl.SetUnreadForTest(true);
|
|
ctrl.UpdateUnreadIndicator();
|
|
|
|
// Mid-sequence the player owns the state; the controller must not
|
|
// touch it again until the unseen flag is cleared and re-raised.
|
|
indicator.TrySetRetailState(UiButtonStateMachine.Ghosted);
|
|
for (int frame = 0; frame < 5; frame++)
|
|
ctrl.UpdateUnreadIndicator();
|
|
|
|
Assert.Equal("Ghosted", indicator.ActiveState);
|
|
}
|
|
|
|
[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" ─────────────
|
|
|
|
/// <summary>
|
|
/// Both entries act on the CURRENT SELECTION and carry its name, and both
|
|
/// used to be deliberate no-ops.
|
|
/// </summary>
|
|
[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<UiMenu>(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<SendChatCmd>(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<ExecuteClientCommandCmd>(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<UiMenu>(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<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));
|
|
}
|
|
}
|