Separate retail client actions, ACE server commands, and ordinary chat at the shared router. Port lifestone/lif/ls from the named retail registry through a typed App controller to game action 0x0063, keep unknown verbs on ACE Talk, and cover both UI backends plus exact outbound bytes. Co-Authored-By: Codex <codex@openai.com>
253 lines
9.4 KiB
C#
253 lines
9.4 KiB
C#
using System.Collections.Generic;
|
|
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;
|
|
|
|
/// <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 (0x21000006) 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()
|
|
{
|
|
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,
|
|
};
|
|
|
|
var root = new ElementInfo
|
|
{
|
|
Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100,
|
|
};
|
|
root.Children.Add(transcriptPanel);
|
|
root.Children.Add(inputBar);
|
|
root.Children.Add(maxMinNode);
|
|
|
|
var layout = LayoutImporter.Build(root, NoTex, null);
|
|
var vm = new ChatVM(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);
|
|
}
|
|
|
|
// ── 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<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, 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, 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 = 0x1000000Eu, 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);
|
|
}
|
|
}
|