feat(D.2b): ChatWindowController — bind chat LayoutDesc, place widgets, route chat
Implements Task G2: binds the imported chat LayoutDesc (0x21000006) to live behavior, the acdream analogue of retail ChatInterface + gmMainChatUI::PostInit. - UiDatElement: add OnClick hook + OnEvent override so Send/max-min buttons can be wired by a controller without needing a dedicated widget type. - ChatWindowController.Bind: reads transcript (0x10000011) and input (0x10000016) rects from the raw ElementInfo tree (factory skips them as Type-12/no-media), places UiChatView under the transcript panel and UiChatInput under the input bar; replaces the imported scrollbar track (0x10000012) with UiChatScrollbar driving UiChatView.Scroll; replaces the channel menu placeholder (0x10000014) with UiChannelMenu; wires Send button and max/min toggle via the new OnClick hook. ChatCommandRouter.Submit routes all input through the existing pipeline. - 6 smoke tests: Bind returns non-null, Transcript is child of panel, Input is child of bar, Input.OnSubmit publishes SendChatCmd, channel change updates submit channel, returns null when panels missing. Build: 0 errors. Test suite: 392 passed / 1 skipped / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6e6339b026
commit
9d9e036e4c
3 changed files with 524 additions and 0 deletions
304
src/AcDream.App/UI/Layout/ChatWindowController.cs
Normal file
304
src/AcDream.App/UI/Layout/ChatWindowController.cs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
||||
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The transcript (<c>0x10000011</c>) and input (<c>0x10000016</c>) are Type-0
|
||||
/// elements whose base is a Type-12 prototype, so the importer factory skips them
|
||||
/// (returns null). This controller reads their rects from the raw
|
||||
/// <see cref="ElementInfo"/> tree (which contains everything) and adds the behavioral
|
||||
/// widgets as children of their parent container widgets (transcript panel
|
||||
/// <c>0x10000010</c> / input bar <c>0x10000013</c>) which ARE created as
|
||||
/// <see cref="UiDatElement"/> nodes. The scrollbar track (<c>0x10000012</c>) and
|
||||
/// channel menu (<c>0x10000014</c>) are created by the factory and are replaced
|
||||
/// with their behavioral counterparts here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatWindowController
|
||||
{
|
||||
public const uint LayoutId = 0x21000006u;
|
||||
|
||||
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
||||
private const uint RootId = 0x1000000Eu;
|
||||
private const uint TranscriptPanelId = 0x10000010u;
|
||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||
private const uint TrackId = 0x10000012u;
|
||||
private const uint InputBarId = 0x10000013u;
|
||||
private const uint MenuId = 0x10000014u;
|
||||
private const uint InputId = 0x10000016u; // Type-12 prototype — skipped by factory
|
||||
private const uint SendId = 0x10000019u;
|
||||
private const uint MaxMinId = 0x1000046Fu;
|
||||
|
||||
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
|
||||
private const uint TrackSprite = 0x06004C5Fu;
|
||||
private const uint ThumbSprite = 0x06004C63u;
|
||||
private const uint UpSprite = 0x06004C69u;
|
||||
private const uint DownSprite = 0x06004C6Cu;
|
||||
|
||||
// Channel menu sprite ids (confirmed in chat element dump).
|
||||
private const uint MenuNormal = 0x06004D65u;
|
||||
private const uint MenuPressed = 0x06004D66u;
|
||||
|
||||
// ── Public surface ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
|
||||
public UiElement Root { get; private set; } = null!;
|
||||
|
||||
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
|
||||
public UiChatView Transcript { get; private set; } = null!;
|
||||
|
||||
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
|
||||
public UiChatInput Input { get; private set; } = null!;
|
||||
|
||||
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
|
||||
public UiChatScrollbar Scrollbar { get; private set; } = null!;
|
||||
|
||||
/// <summary>Channel-selector menu widget.</summary>
|
||||
public UiChannelMenu Menu { get; private set; } = null!;
|
||||
|
||||
// ── Private state ──────────────────────────────────────────────────────
|
||||
|
||||
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
||||
|
||||
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
|
||||
private float _normalHeight;
|
||||
/// <summary>Window top before maximize.</summary>
|
||||
private float _normalTop;
|
||||
private bool _maximized;
|
||||
|
||||
// ── Factory ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Bind an imported chat layout to live behavior.
|
||||
///
|
||||
/// <paramref name="rootInfo"/> and <paramref name="layout"/> must come from the
|
||||
/// SAME <see cref="LayoutImporter"/> pass (<c>ImportInfos</c> then <c>Build</c>)
|
||||
/// so rects in the info tree match the widget geometry in the layout tree.
|
||||
///
|
||||
/// Returns <c>null</c> if the essential transcript/input panels are missing from
|
||||
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
|
||||
/// </summary>
|
||||
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from
|
||||
/// <see cref="LayoutImporter.ImportInfos"/>.</param>
|
||||
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
|
||||
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
|
||||
/// <param name="bus">Command bus for <c>SendChatCmd</c> publishes.</param>
|
||||
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
|
||||
/// <param name="debugFont">Fallback debug bitmap font (used when
|
||||
/// <paramref name="datFont"/> is null).</param>
|
||||
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
||||
/// Forwarded to <see cref="UiChatScrollbar"/> and <see cref="UiChannelMenu"/>.</param>
|
||||
public static ChatWindowController? Bind(
|
||||
ElementInfo rootInfo,
|
||||
ImportedLayout layout,
|
||||
ChatVM vm,
|
||||
ICommandBus bus,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
// The transcript + input nodes are Type-12 based and were skipped by the factory.
|
||||
// Find them in the raw ElementInfo tree to read their rects.
|
||||
var tInfo = FindInfo(rootInfo, TranscriptId);
|
||||
var iInfo = FindInfo(rootInfo, InputId);
|
||||
|
||||
// Their parent panels must exist as real widgets in the layout tree.
|
||||
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
||||
var inputBar = layout.FindElement(InputBarId);
|
||||
|
||||
if (tInfo is null || iInfo is null || transcriptPanel is null || inputBar is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatWindowController.Bind: missing required elements " +
|
||||
$"(tInfo={tInfo is not null}, iInfo={iInfo is not null}, " +
|
||||
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
||||
$"chat window will not be interactive.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var c = new ChatWindowController { Root = layout.Root };
|
||||
|
||||
// ── Transcript ───────────────────────────────────────────────────
|
||||
// Place the behavioral transcript widget inside the transcript panel at the
|
||||
// dat-rect of the (skipped) Type-12 transcript element.
|
||||
c.Transcript = new UiChatView
|
||||
{
|
||||
Left = tInfo.X,
|
||||
Top = tInfo.Y,
|
||||
Width = tInfo.Width,
|
||||
Height = tInfo.Height,
|
||||
Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom),
|
||||
DatFont = datFont,
|
||||
Font = debugFont,
|
||||
LinesProvider = () => BuildLines(vm),
|
||||
};
|
||||
transcriptPanel.AddChild(c.Transcript);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// Place the behavioral input widget inside the input bar.
|
||||
c.Input = new UiChatInput
|
||||
{
|
||||
Left = iInfo.X,
|
||||
Top = iInfo.Y,
|
||||
Width = iInfo.Width,
|
||||
Height = iInfo.Height,
|
||||
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
|
||||
DatFont = datFont,
|
||||
Font = debugFont,
|
||||
};
|
||||
inputBar.AddChild(c.Input);
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, bus, c._activeChannel);
|
||||
|
||||
// ── Scrollbar — replace the imported track placeholder ────────────
|
||||
// The factory created a UiDatElement for the track. Remove it and place a
|
||||
// behavioral UiChatScrollbar at the same position, driving the transcript's scroll.
|
||||
var track = layout.FindElement(TrackId);
|
||||
if (track?.Parent is { } trackParent)
|
||||
{
|
||||
c.Scrollbar = new UiChatScrollbar
|
||||
{
|
||||
Left = track.Left,
|
||||
Top = track.Top,
|
||||
Width = track.Width,
|
||||
Height = track.Height,
|
||||
Anchors = track.Anchors,
|
||||
Model = c.Transcript.Scroll,
|
||||
SpriteResolve = resolve,
|
||||
TrackSprite = TrackSprite,
|
||||
ThumbSprite = ThumbSprite,
|
||||
UpSprite = UpSprite,
|
||||
DownSprite = DownSprite,
|
||||
};
|
||||
trackParent.RemoveChild(track);
|
||||
trackParent.AddChild(c.Scrollbar);
|
||||
}
|
||||
|
||||
// ── Channel menu — replace the imported menu placeholder ──────────
|
||||
var menuEl = layout.FindElement(MenuId);
|
||||
if (menuEl?.Parent is { } menuParent)
|
||||
{
|
||||
c.Menu = new UiChannelMenu
|
||||
{
|
||||
Left = menuEl.Left,
|
||||
Top = menuEl.Top,
|
||||
Width = menuEl.Width,
|
||||
Height = menuEl.Height,
|
||||
Anchors = menuEl.Anchors,
|
||||
DatFont = datFont,
|
||||
Font = debugFont,
|
||||
SpriteResolve = resolve,
|
||||
NormalSprite = MenuNormal,
|
||||
PressedSprite = MenuPressed,
|
||||
};
|
||||
c.Menu.OnChannelChanged = k => c._activeChannel = k;
|
||||
menuParent.RemoveChild(menuEl);
|
||||
menuParent.AddChild(c.Menu);
|
||||
}
|
||||
|
||||
// ── Send button — Enter-alternate submit trigger ──────────────────
|
||||
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
|
||||
if (layout.FindElement(SendId) is UiDatElement sendEl)
|
||||
{
|
||||
sendEl.ClickThrough = false;
|
||||
sendEl.OnClick = () => c.Input.Submit();
|
||||
}
|
||||
|
||||
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
|
||||
if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)
|
||||
{
|
||||
maxMinEl.ClickThrough = false;
|
||||
maxMinEl.OnClick = c.ToggleMaximize;
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Max/min implementation ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Toggle between the normal chat window height and an expanded 320px height.
|
||||
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
|
||||
/// retail stores the pre-maximize height and restores it on a second click.
|
||||
/// The 320px expanded size is the approximate retail maximized chat height.
|
||||
/// </summary>
|
||||
private void ToggleMaximize()
|
||||
{
|
||||
if (!_maximized)
|
||||
{
|
||||
_normalHeight = Root.Height;
|
||||
_normalTop = Root.Top;
|
||||
// Expand upward: move the top edge up so the bottom stays anchored.
|
||||
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
|
||||
Root.Height = 320f;
|
||||
_maximized = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Root.Top = _normalTop;
|
||||
Root.Height = _normalHeight;
|
||||
_maximized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
|
||||
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
|
||||
/// </summary>
|
||||
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id) return node;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var found = FindInfo(child, id);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert the ChatVM's detailed lines to the transcript's
|
||||
/// <see cref="UiChatView.Line"/> record format, applying retail-faithful
|
||||
/// per-<see cref="ChatKind"/> colors.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UiChatView.Line> BuildLines(ChatVM vm)
|
||||
{
|
||||
var detailed = vm.RecentLinesDetailed();
|
||||
if (detailed.Count == 0) return Array.Empty<UiChatView.Line>();
|
||||
|
||||
var result = new UiChatView.Line[detailed.Count];
|
||||
for (int i = 0; i < detailed.Count; i++)
|
||||
result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-<see cref="ChatKind"/> text color matching retail AC's channel coloring
|
||||
/// (observed from retail client screenshots and holtburger's chat.rs coloring).
|
||||
/// </summary>
|
||||
private static Vector4 RetailChatColor(ChatKind kind) => kind switch
|
||||
{
|
||||
ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // white — spoken nearby
|
||||
ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout
|
||||
ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text
|
||||
ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper
|
||||
ChatKind.System => new(1f, 1f, 0.45f, 1f), // yellow — system messages
|
||||
ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast
|
||||
ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote
|
||||
ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote
|
||||
ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), // orange — combat feedback
|
||||
_ => new(0.9f, 0.9f, 0.9f, 1f), // light grey — fallback
|
||||
};
|
||||
}
|
||||
|
|
@ -76,6 +76,17 @@ public sealed class UiDatElement : UiElement
|
|||
: _info.StateMedia.TryGetValue("", out var d) ? d
|
||||
: (0u, 0);
|
||||
|
||||
/// <summary>Optional click handler. Set by a controller for interactive dat
|
||||
/// elements (e.g. the chat Send / max-min buttons). Requires
|
||||
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
var (file, _) = ActiveMedia();
|
||||
|
|
|
|||
209
tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
Normal file
209
tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
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 skipped, Type-3 parents created), then call
|
||||
/// <see cref="ChatWindowController.Bind"/> which reads rects from the info tree
|
||||
/// and places behavioral widgets under the parent containers.
|
||||
/// </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] ← skipped by factory
|
||||
/// track (Type-3) [0x10000012]
|
||||
/// inputBar (Type-3) [0x10000013]
|
||||
/// menu (Type-3) [0x10000014]
|
||||
/// input (Type-12, no media) [0x10000016] ← skipped by factory
|
||||
/// 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 = 3, X = 0, Y = 0, Width = 46, Height = 17,
|
||||
};
|
||||
var inputNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory
|
||||
X = 46, Y = 0, Width = 398, Height = 17,
|
||||
};
|
||||
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_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);
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
ctrl!.Menu.OnChannelChanged!.Invoke(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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue