acdream/tests/AcDream.Core.Tests/Chat/ChatWindowStateTests.cs
Erik 22020ef2c4 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>
2026-08-10 12:10:20 +02:00

253 lines
8.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}