feat(chat): Campaign CH slice CH6b — floating chat windows 1-4

Mounts retail's four floating chat windows as always-resident, born-hidden
children per gmGamePlayUI::SetupChildren @0x004E9EC0, all sharing LayoutDesc
0x2100005B (window ids 0x10000505/0x1000050E/0x1000050F/0x10000510). New
FloatingChatWindowController (AcDream.App/UI/Layout) binds each window's own
widget tree — built fresh per instance from one shared imported ElementInfo
— reusing ChatWindowController's word-wrap + retail color-carry algorithm via
the extracted ChatTranscriptRenderer instead of duplicating it. A floaty
window has no talk-focus menu (research doc §2.2), so its entry field always
sends on Say; the mismatch against retail's possible shared-channel behavior
is UNVERIFIED and filed as #369/AP-188.

Runtime owns the per-window filter/open state: ChatWindowState (new,
AcDream.Core.Chat) seeds retail's exact PostInit defaults per window
(window 1 0x0000101C Speech/Tell/DirectSend/Emote, window 2 0x00040C00
Social/SocialSend/Allegiance, window 3 0x00080000 Fellowship, window 4
0x78000000 Turbine General/Trade/LFG/Roleplay) and implements the full
ShouldDisplay(windowId, targetWindowId, logTextType) display predicate from
ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640. It lives on
RuntimeCommunicationState.ChatWindows so every host borrows the same
instance. The main window's filter (0xFBFFFFFF, "no user filter") never
actually gates anything because its own explicit-address branch already
covers every broadcast line — that's why UpdateFromPlayerModule early-returns
for window 0 in retail, ported here by construction rather than a special
case.

Keybind wiring: InputAction.ToggleFloatingChatWindow1..4 and their
KeyBindings.RetailDefaults() chords already existed since Phase K.1c
(unwired until now). The MetaKeys table confirms retail's default is Alt+1
through Alt+4 (index 3 = bit 0x00000004, cross-checked against the same
file's Alt+A/D strafe and Alt+Enter/Tab/F4 rows). Routes through
GameplayInputCommandController -> RetainedGameplayWindowCommands ->
RetailUiRuntime.ToggleFloatingChatWindow -> the generic UiHost.ToggleWindow,
whose visibility-change event is the single chokepoint that syncs
ChatWindowState.SetOpen and mirrors the main window's 1-4 indicator button
regardless of what changed a window's visibility (keybind, close button, or
a restored layout).

A direct decomp read of gmMainChatUI::ListenToElementMessage @0x004CDA80 —
the only function in the whole binary that branches on a click message —
settles what the research doc had left as a hedge: it handles exactly
0x1000046f (max/min) and the talk-focus menu's selection message, with NO
case for 0x10000522-0x10000525. The four indicator buttons are PURE
one-directional mirrors in retail; clicking them does nothing.
ChatWindowController.SetIndicatorOpen ports this with no OnClick at all.
Corrected research doc §1.4 accordingly.

Persistence is local-only (register row AP-187; the retail 0x1000008C
GameplayOptions wire remains deferred to CH6f): window geometry and
open/visible state ride the existing generic RetailWindowLayoutPersistence
path for free once each window registers under its own WindowNames entry;
the four filter masks get a dedicated ChatSettings round-trip
(ChatWindow1Filter..ChatWindow4Filter, defaulting to the retail PostInit
constants) loaded at mount and saved alongside SaveLayout().

Tests: ChatWindowStateTests (defaults, TypeIsActive, the full display-rule
matrix, toggle/reset, revision counter), FloatingChatWindowControllerTests
(bind smoke tests against a synthetic 0x2100005B tree, per-window filter
routing, filter-change cache invalidation, fixed-Say submit), new
ChatWindowController.SetIndicatorOpen tests (Highlight/Normal state,
cross-window isolation, range validation), GameplayInputCommandController
routing for the four toggle actions, and a SettingsStore filter round-trip.
Full Release suite: 12,392 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 12:10:20 +02:00
parent 41b408f3e6
commit 22020ef2c4
21 changed files with 1699 additions and 46 deletions

View file

@ -17,6 +17,10 @@ public sealed class GameplayInputCommandControllerTests
[InlineData(InputAction.ToggleChatEntry, "chat")]
[InlineData(InputAction.ToggleOptionsPanel, "settings")]
[InlineData(InputAction.CombatToggleCombat, "combat")]
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
[InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")]
[InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")]
public void RecognizedCommand_RoutesToTypedOwner(
InputAction action,
string expected)
@ -119,6 +123,9 @@ public sealed class GameplayInputCommandControllerTests
: IRetainedGameplayWindowCommands
{
public void ToggleInventory() => calls.Add("inventory");
public void ToggleFloatingChatWindow(int windowId) =>
calls.Add($"chat-window-{windowId}");
}
private sealed class FakeDevTools(List<string> calls)

View file

@ -109,6 +109,24 @@ public class ChatWindowControllerTests
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,
@ -116,6 +134,10 @@ public class ChatWindowControllerTests
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());
@ -487,4 +509,65 @@ public class ChatWindowControllerTests
Assert.Equal(new[] { "first", "", "third" }, lines);
}
// ── 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, 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, 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, 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, null, null, NoTex)!;
Assert.Throws<ArgumentOutOfRangeException>(() => ctrl.SetIndicatorOpen(windowId, open: true));
}
}

View file

@ -0,0 +1,282 @@
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 + filter-routing tests for
/// <see cref="FloatingChatWindowController.Bind"/> — no dats, no GL,
/// mirroring <c>ChatWindowControllerTests</c>'s synthetic-tree approach but
/// against the floating layout's element topology
/// (<c>0x2100005B</c>, research doc §2.2): the input row id
/// (<c>0x10000509</c>) differs from the main window's
/// (<c>0x10000013</c>), and there is no talk-focus menu / max-min button /
/// indicator row.
/// </summary>
public class FloatingChatWindowControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
private sealed class CaptureBus : ICommandBus
{
public readonly List<object> Published = new();
public void Publish<T>(T cmd) where T : notnull => Published.Add(cmd!);
}
/// <summary>
/// Synthetic tree mirroring the floating chat layout topology:
/// root (Type-3) [0x100004F7]
/// transcriptPanel (Type-3) [0x10000010]
/// transcript (Type-12, no media) [0x10000011]
/// track (Type-3) [0x10000012]
/// inputRow (Type-3) [0x10000509] ← the floaty-specific id
/// input (Type-12, Editable+Selectable) [0x10000016]
/// send (Type-3) [0x10000019]
/// titleBar (Type-3) [0x100004D9]
/// closeButton (Type-3) [0x1000052A]
/// </summary>
private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree(
ChatLog? log = null)
{
var transcriptNode = new ElementInfo
{
Id = 0x10000011u, Type = 12,
X = 5, Y = 20, Width = 224, Height = 60,
};
var trackNode = new ElementInfo
{
Id = 0x10000012u, Type = 3,
X = 229, Y = 20, Width = 16, Height = 60,
};
var transcriptPanel = new ElementInfo
{
Id = 0x10000010u, Type = 3, X = 0, Y = 20, Width = 250, Height = 60,
};
transcriptPanel.Children.Add(transcriptNode);
transcriptPanel.Children.Add(trackNode);
var inputNode = new ElementInfo
{
Id = 0x10000016u, Type = 12,
X = 0, Y = 80, Width = 202, Height = 18,
};
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 = 202, Y = 80, Width = 38, Height = 18,
};
var inputRow = new ElementInfo
{
Id = 0x10000509u, Type = 3, X = 0, Y = 80, Width = 250, Height = 18,
};
inputRow.Children.Add(inputNode);
inputRow.Children.Add(sendNode);
var titleBar = new ElementInfo { Id = 0x100004D9u, Type = 3, X = 0, Y = 0, Width = 240, Height = 16 };
var closeButton = new ElementInfo { Id = 0x1000052Au, Type = 3, X = 225, Y = 1, Width = 14, Height = 14 };
var root = new ElementInfo { Id = 0x100004F7u, Type = 3, Width = 250, Height = 108 };
root.Children.Add(transcriptPanel);
root.Children.Add(inputRow);
root.Children.Add(titleBar);
root.Children.Add(closeButton);
var layout = LayoutImporter.Build(root, NoTex, null);
var vm = new ChatVM(log ?? new ChatLog());
return (root, layout, vm);
}
// ── Bind smoke tests ──────────────────────────────────────────────────
[Fact]
public void Bind_Returns_NonNull_OnValidTree()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var filters = new ChatWindowState();
var ctrl = FloatingChatWindowController.Bind(
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
Assert.NotNull(ctrl);
Assert.Equal(1, ctrl!.WindowId);
}
[Theory]
[InlineData(0)]
[InlineData(5)]
public void Bind_InvalidWindowId_Throws(int windowId)
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var filters = new ChatWindowState();
Assert.Throws<ArgumentOutOfRangeException>(() =>
FloatingChatWindowController.Bind(
windowId, rootInfo, layout, vm, () => bus, filters, null, null, NoTex));
}
[Fact]
public void Bind_Returns_Null_WhenTranscriptPanelMissing()
{
var root = new ElementInfo { Id = 0x100004F7u, Type = 3, Width = 250, Height = 108 };
var layout = LayoutImporter.Build(root, NoTex, null);
var vm = new ChatVM(new ChatLog());
var bus = new CaptureBus();
var filters = new ChatWindowState();
var ctrl = FloatingChatWindowController.Bind(
1, root, layout, vm, () => bus, filters, null, null, NoTex);
Assert.Null(ctrl);
}
[Fact]
public void Bind_Transcript_IsChildOfTranscriptPanel()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var filters = new ChatWindowState();
var ctrl = FloatingChatWindowController.Bind(
2, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
Assert.NotNull(ctrl);
var panel = layout.FindElement(0x10000010u);
Assert.NotNull(panel);
Assert.Contains(ctrl!.Transcript, panel!.Children);
}
[Fact]
public void Bind_Input_IsChildOfFloatyInputRow_NotMainWindowInputBar()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var filters = new ChatWindowState();
var ctrl = FloatingChatWindowController.Bind(
3, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
Assert.NotNull(ctrl);
var row = layout.FindElement(0x10000509u);
Assert.NotNull(row);
Assert.Contains(ctrl!.Input, row!.Children);
}
// ── Chat entry always sends on Say (no talk-focus menu authored) ───────
[Fact]
public void Bind_InputSubmit_AlwaysSendsOnSayChannel()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var filters = new ChatWindowState();
var ctrl = FloatingChatWindowController.Bind(
4, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
Assert.NotNull(ctrl);
ctrl!.Input.OnSubmit!.Invoke("hi everyone");
var cmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Say, cmd.Channel);
Assert.Equal("hi everyone", cmd.Text);
}
// ── Display rule matrix: this window's filter accepts/rejects a line ───
[Fact]
public void Transcript_ShowsOnlyLinesThisWindowsFilterAccepts()
{
var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log);
var bus = new CaptureBus();
var filters = new ChatWindowState(); // window 1 default: Speech/Tell/SpeechDirectSend/Emote
var ctrl = FloatingChatWindowController.Bind(
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
Assert.NotNull(ctrl);
log.OnSystemMessage("visible speech", chatType: 0x02u); // Speech — in window 1's filter
log.OnSystemMessage("hidden social", chatType: 0x0Au); // Social — NOT in window 1's filter
var lines = ctrl!.Transcript.LinesProvider();
Assert.Single(lines);
Assert.Equal("visible speech", lines[0].Text);
}
[Fact]
public void Transcript_DifferentWindow_AcceptsADifferentSubsetOfTypes()
{
var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log);
var bus = new CaptureBus();
var filters = new ChatWindowState(); // window 3 default: Fellowship only
var ctrl = FloatingChatWindowController.Bind(
3, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
Assert.NotNull(ctrl);
log.OnSystemMessage("speech line", chatType: 0x02u); // Speech — not window 3's filter
log.OnSystemMessage("fellowship line", chatType: 0x13u); // Fellowship — window 3's filter
var lines = ctrl!.Transcript.LinesProvider();
Assert.Single(lines);
Assert.Equal("fellowship line", lines[0].Text);
}
[Fact]
public void Transcript_FilterChange_IsReflectedOnNextRebuild()
{
var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log);
var bus = new CaptureBus();
var filters = new ChatWindowState();
var ctrl = FloatingChatWindowController.Bind(
2, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
Assert.NotNull(ctrl);
log.OnSystemMessage("speech line", chatType: 0x02u); // Speech — not window 2's default filter
Assert.Empty(ctrl!.Transcript.LinesProvider());
// Widen window 2's filter to include Speech (bit 0x02 = 0x4) without
// touching the chat log — the cached transcript must invalidate on
// the filter change alone.
filters.SetFilter(2, filters.GetFilter(2) | (1UL << 0x02));
var lines = ctrl.Transcript.LinesProvider();
Assert.Single(lines);
Assert.Equal("speech line", lines[0].Text);
}
[Fact]
public void Transcript_UnchangedFilterAndContent_ReusesCachedLayout()
{
var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log);
var bus = new CaptureBus();
var filters = new ChatWindowState();
var ctrl = FloatingChatWindowController.Bind(
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
Assert.NotNull(ctrl);
log.OnSystemMessage("speech line", chatType: 0x02u);
var first = ctrl!.Transcript.LinesProvider();
var unchanged = ctrl.Transcript.LinesProvider();
Assert.Same(first, unchanged);
Assert.Equal(1, ctrl.TranscriptLayoutBuildCount);
}
}

View file

@ -0,0 +1,253 @@
using AcDream.Core.Chat;
namespace AcDream.Core.Tests.Chat;
/// <summary>
/// Campaign CH slice CH6b: retail per-window text-type filter + open state
/// (<c>ChatInterface::PostInit @0x004F3DD0</c> defaults, color-table research
/// doc §4, and <c>RecvNotice_DisplayFinalStringInfo @0x004F4640</c>'s display
/// predicate).
/// </summary>
public sealed class ChatWindowStateTests
{
// ── Defaults (research doc §4's table, verbatim) ────────────────────────
[Theory]
[InlineData(0, 0xFBFFFFFFu)]
[InlineData(1, 0x0000101Cu)]
[InlineData(2, 0x00040C00u)]
[InlineData(3, 0x00080000u)]
[InlineData(4, 0x78000000u)]
public void Defaults_MatchRetailPostInitTable(int windowId, ulong expectedFilter)
{
var state = new ChatWindowState();
Assert.Equal(expectedFilter, state.GetFilter(windowId));
}
[Fact]
public void Defaults_MainWindowIsAlwaysOpen_FloatingWindowsStartClosed()
{
var state = new ChatWindowState();
Assert.True(state.IsOpen(0));
for (int windowId = 1; windowId <= 4; windowId++)
Assert.False(state.IsOpen(windowId));
}
[Fact]
public void Window1_DefaultFilter_MatchesSpeechTellDirectSendEmote()
{
var state = new ChatWindowState();
Assert.True(state.TypeIsActive(1, 0x02u)); // Speech
Assert.True(state.TypeIsActive(1, 0x03u)); // Tell
Assert.True(state.TypeIsActive(1, 0x04u)); // Speech_Direct_Send
Assert.True(state.TypeIsActive(1, 0x0Cu)); // Emote
Assert.False(state.TypeIsActive(1, 0x0Au)); // Social — not in window 1's default
}
[Fact]
public void Window2_DefaultFilter_MatchesSocialSocialSendAllegiance()
{
var state = new ChatWindowState();
Assert.True(state.TypeIsActive(2, 0x0Au)); // Social
Assert.True(state.TypeIsActive(2, 0x0Bu)); // Social_Send
Assert.True(state.TypeIsActive(2, 0x12u)); // Allegiance
Assert.False(state.TypeIsActive(2, 0x13u)); // Fellowship
}
[Fact]
public void Window3_DefaultFilter_MatchesFellowshipOnly()
{
var state = new ChatWindowState();
Assert.True(state.TypeIsActive(3, 0x13u)); // Fellowship
Assert.False(state.TypeIsActive(3, 0x0Au)); // Social
Assert.False(state.TypeIsActive(3, 0x02u)); // Speech
}
[Fact]
public void Window4_DefaultFilter_MatchesTurbineGeneralTradeLfgRoleplay()
{
var state = new ChatWindowState();
Assert.True(state.TypeIsActive(4, 0x1Bu)); // TurbineGeneral
Assert.True(state.TypeIsActive(4, 0x1Cu)); // TurbineTrade
Assert.True(state.TypeIsActive(4, 0x1Du)); // TurbineLFG
Assert.True(state.TypeIsActive(4, 0x1Eu)); // TurbineRoleplay
Assert.False(state.TypeIsActive(4, 0x20u)); // TurbineSociety — opt-in only
Assert.False(state.TypeIsActive(4, 0x12u)); // Allegiance
}
[Fact]
public void EveryWindow_NeverActivatesSocietyOrReservedByDefault()
{
var state = new ChatWindowState();
for (int windowId = 0; windowId <= 4; windowId++)
{
Assert.False(state.TypeIsActive(windowId, 0x20u)); // Society
Assert.False(state.TypeIsActive(windowId, 0x21u)); // Reserved
}
}
// ── TypeIsActive edge cases ──────────────────────────────────────────────
[Fact]
public void TypeIsActive_TypeAtOrAbove64_IsNeverActive()
{
var state = new ChatWindowState();
state.SetFilter(1, ulong.MaxValue);
Assert.False(state.TypeIsActive(1, 64u));
Assert.False(state.TypeIsActive(1, 1000u));
}
// ── Display rule matrix (windowId-addressed vs broadcast × filter hit/miss) ──
[Fact]
public void ShouldDisplay_ExplicitlyAddressed_AlwaysShowsRegardlessOfFilter()
{
var state = new ChatWindowState();
// Window 3's default filter has ONLY Fellowship (0x13) active — Speech
// (0x02) would fail the broadcast check, but explicit addressing wins.
Assert.True(state.ShouldDisplay(windowId: 3, targetWindowId: 3u, logTextType: 0x02u));
}
[Fact]
public void ShouldDisplay_ExplicitlyAddressedToAnotherWindow_NeverShowsHereEvenOnBroadcastFilterHit()
{
var state = new ChatWindowState();
// Addressed to window 2, evaluated from window 1's perspective: not a
// broadcast (targetWindowId != 0) and not addressed to window 1.
Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 2u, logTextType: 0x02u));
}
[Fact]
public void ShouldDisplay_Broadcast_FilterHit_Shows()
{
var state = new ChatWindowState();
Assert.True(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x02u)); // Speech
}
[Fact]
public void ShouldDisplay_Broadcast_FilterMiss_DoesNotShow()
{
var state = new ChatWindowState();
Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x0Au)); // Social
}
[Fact]
public void ShouldDisplay_MainWindow_ShowsEveryBroadcastRegardlessOfSeededFilter()
{
var state = new ChatWindowState();
// Window 0's own filter is 0xFBFFFFFF (excludes 0x1A) but that filter
// is never actually consulted for a broadcast message: targetWindowId
// (0) == windowId (0) is already true via the first branch.
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x1Au));
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x21u));
}
// ── SetFilter / SetOpen / Toggle ─────────────────────────────────────────
[Fact]
public void SetFilter_MainWindow_IsANoOp()
{
var state = new ChatWindowState();
ulong before = state.GetFilter(0);
state.SetFilter(0, 0u);
Assert.Equal(before, state.GetFilter(0));
}
[Fact]
public void SetFilter_FloatingWindow_Persists()
{
var state = new ChatWindowState();
state.SetFilter(2, 0x1u);
Assert.Equal(0x1u, state.GetFilter(2));
Assert.True(state.TypeIsActive(2, 0x00u));
}
[Fact]
public void SetOpen_MainWindow_IsANoOp_AlwaysOpen()
{
var state = new ChatWindowState();
state.SetOpen(0, false);
Assert.True(state.IsOpen(0));
}
[Fact]
public void Toggle_FloatingWindow_FlipsOpenState_AndReturnsNewValue()
{
var state = new ChatWindowState();
Assert.False(state.IsOpen(1));
bool afterFirst = state.Toggle(1);
Assert.True(afterFirst);
Assert.True(state.IsOpen(1));
bool afterSecond = state.Toggle(1);
Assert.False(afterSecond);
Assert.False(state.IsOpen(1));
}
[Fact]
public void Toggle_MainWindow_AlwaysReturnsTrue_NeverCloses()
{
var state = new ChatWindowState();
bool result = state.Toggle(0);
Assert.True(result);
Assert.True(state.IsOpen(0));
}
[Fact]
public void ResetToDefaults_RestoresSeededFiltersAndOpenState()
{
var state = new ChatWindowState();
state.SetFilter(1, 0u);
state.SetOpen(1, true);
state.ResetToDefaults();
Assert.Equal(0x0000101Cu, state.GetFilter(1));
Assert.False(state.IsOpen(1));
}
// ── Argument validation ───────────────────────────────────────────────
[Theory]
[InlineData(-1)]
[InlineData(5)]
public void OutOfRangeWindowId_Throws(int windowId)
{
var state = new ChatWindowState();
Assert.Throws<ArgumentOutOfRangeException>(() => state.GetFilter(windowId));
Assert.Throws<ArgumentOutOfRangeException>(() => state.IsOpen(windowId));
Assert.Throws<ArgumentOutOfRangeException>(() => state.TypeIsActive(windowId, 0u));
}
// ── Revision counter ─────────────────────────────────────────────────
[Fact]
public void Revision_AdvancesOnFilterAndOpenChange_NotOnNoOpWrites()
{
var state = new ChatWindowState();
long baseline = state.Revision;
state.SetFilter(1, 0x1u);
Assert.True(state.Revision > baseline);
long afterFilter = state.Revision;
// No-op: same value.
state.SetFilter(1, 0x1u);
Assert.Equal(afterFilter, state.Revision);
state.SetOpen(1, true);
Assert.True(state.Revision > afterFilter);
}
}

View file

@ -254,6 +254,35 @@ public sealed class RuntimeCommunicationStateTests
Assert.Equal(0, state.SpewBox.Count);
}
// ── Campaign CH slice CH6b: ChatWindows is the canonical per-window
// filter/open owner every host borrows — no presentation-owned copy. ──
[Fact]
public void ChatWindows_SeededWithRetailPostInitDefaults_OnConstruction()
{
using var state = new RuntimeCommunicationState();
Assert.True(state.ChatWindows.IsOpen(0));
Assert.False(state.ChatWindows.IsOpen(1));
Assert.Equal(0x0000101Cu, state.ChatWindows.GetFilter(1));
Assert.Equal(0x00040C00u, state.ChatWindows.GetFilter(2));
Assert.Equal(0x00080000u, state.ChatWindows.GetFilter(3));
Assert.Equal(0x78000000u, state.ChatWindows.GetFilter(4));
}
[Fact]
public void Dispose_ResetsChatWindowsToRetailDefaults()
{
var state = new RuntimeCommunicationState();
state.ChatWindows.SetOpen(1, true);
state.ChatWindows.SetFilter(2, 0u);
state.Dispose();
Assert.False(state.ChatWindows.IsOpen(1));
Assert.Equal(0x00040C00u, state.ChatWindows.GetFilter(2));
}
private sealed class RecordingObserver : IRuntimeCommunicationObserver
{
public List<RuntimeCommunicationEvent> Events { get; } = [];

View file

@ -295,6 +295,41 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(original, store.LoadChat());
}
// -- Campaign CH slice CH6b: floating chat window filter round-trip ---
[Fact]
public void LoadChat_returns_retail_PostInit_filter_defaults_when_file_is_missing()
{
var store = new SettingsStore(_tempPath);
ChatSettings loaded = store.LoadChat();
Assert.Equal(0x0000101Cu, loaded.ChatWindow1Filter);
Assert.Equal(0x00040C00u, loaded.ChatWindow2Filter);
Assert.Equal(0x00080000u, loaded.ChatWindow3Filter);
Assert.Equal(0x78000000u, loaded.ChatWindow4Filter);
}
[Fact]
public void SaveChat_then_LoadChat_round_trips_floating_window_filters()
{
var store = new SettingsStore(_tempPath);
var original = ChatSettings.Default with
{
ChatWindow1Filter = 0x1u,
ChatWindow2Filter = 0xFFFFFFFFu,
ChatWindow3Filter = 0x8000000000000000u, // exercises the high dword (Society/reserved bits)
ChatWindow4Filter = 0ul,
};
store.SaveChat(original);
ChatSettings loaded = store.LoadChat();
Assert.Equal(original.ChatWindow1Filter, loaded.ChatWindow1Filter);
Assert.Equal(original.ChatWindow2Filter, loaded.ChatWindow2Filter);
Assert.Equal(original.ChatWindow3Filter, loaded.ChatWindow3Filter);
Assert.Equal(original.ChatWindow4Filter, loaded.ChatWindow4Filter);
}
[Fact]
public void All_four_sections_coexist_in_one_settings_json()
{