Regression from cbab79d7, which I introduced: the indicator stopped showing at
all. Switching it from Visible to state-driven was half a correction — right
about retail's mechanism, wrong about what makes this element appear.
Measured, rather than reasoned about (LayoutDump gained --props for it):
0x1000048C state 13 Ghosted 0x3B = True -> hidden
state 1 Normal 0x3B = False -> shown
state 3 pressed 0x3B = False
Dat property 0x3B is "Invisible", authored PER STATE, and it is what puts this
element on screen. UiDatElement applies 0x3B on a state change; UiButton does
not, and this element builds as a button — so driving the state alone left it
hidden forever. The original Visible toggle was, by coincidence, exactly what
the authored data prescribes.
So the property is applied here rather than left unhonoured. That is the
authored data, not a visibility hack layered over the state machinery.
The state is still set, for the media it selects, but only on the way IN:
TrySetRetailState(Ghosted) means Enabled = false, and disabling the button
would also refuse the click that scrolls to the newest text — a second bug
waiting behind the first.
The test now pins VISIBILITY across the transitions instead of ActiveState.
The previous test passed while the feature was broken because the fixture
element carried no 0x3B, so the assertion could never see the property that
actually decides this. It fails now if the state is driven without the
visibility.
Proper fix noted for later: UiButton should honour per-state 0x3B the way
UiDatElement already does. That is a wider change than this regression wants.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
690 lines
29 KiB
C#
690 lines
29 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 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));
|
|
}
|
|
}
|