Campaign CT slice C1, completing Group C. The authored element was already in the layout and simply never bound: 0x1000048C, a 16x16 button at the transcript's bottom-left. It now lights when a line arrives while the transcript is scrolled up, and clicking it jumps to the newest text. Half of this slice turned out to be done already, and checking rather than assuming is what kept it that way. The plan called for porting retail's rule that IsAtVerticalEnd is sampled BEFORE the new line lands, so a player reading back is not yanked to the bottom. UiScrollable.SetExtents already does exactly that via preserveEnd, and chat gets it by default — so the scroll behaviour was untouched and only the indicator was missing. Rewriting it would have been churn on correct code. The flag clears on reaching the bottom by ANY means, not only by clicking the indicator. Clearing only on the click would leave it lit over text the player had already scrolled down and read, which is worse than not having it. Detection samples the scroll position before the rebuild, at the one moment we know new content arrived (the revision advancing). The first build after bind is deliberately excluded — a fresh window has not "missed" anything. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
649 lines
27 KiB
C#
649 lines
27 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;
|
|
}
|
|
var indicator1 = MakeIndicator(0x10000522u, 5);
|
|
var indicator2 = MakeIndicator(0x10000523u, 22);
|
|
var indicator3 = MakeIndicator(0x10000524u, 39);
|
|
var indicator4 = MakeIndicator(0x10000525u, 56);
|
|
|
|
var root = new ElementInfo
|
|
{
|
|
Id = 0x10000600u, Type = 3, Width = 490, Height = 100,
|
|
};
|
|
root.Children.Add(transcriptPanel);
|
|
root.Children.Add(inputBar);
|
|
root.Children.Add(maxMinNode);
|
|
root.Children.Add(indicator1);
|
|
root.Children.Add(indicator2);
|
|
root.Children.Add(indicator3);
|
|
root.Children.Add(indicator4);
|
|
|
|
var layout = LayoutImporter.Build(root, NoTex, null);
|
|
var vm = new ChatVM(log ?? new ChatLog());
|
|
return (root, layout, vm);
|
|
}
|
|
|
|
// ── Test 1: Bind returns non-null with the minimal tree ──────────────────
|
|
|
|
[Fact]
|
|
public void Bind_Returns_NonNull_OnValidTree()
|
|
{
|
|
var (rootInfo, layout, vm) = BuildTestTree();
|
|
var bus = new CaptureBus();
|
|
|
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
|
|
|
Assert.NotNull(ctrl);
|
|
}
|
|
|
|
// ── 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 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));
|
|
}
|
|
}
|