acdream/tests/AcDream.Core.Tests/Chat/ChatWindowStateTests.cs
Erik e71e5a9614 feat(ui): Campaign OP slice OP5 — the Chat tab
Binds LayoutDesc 0x2100005C through OP2's template-list mechanism and
OP3's per-page OptionPage model: the General Options header + two
DualHash-linked opacity sliders (Option_DefaultOpacity_Property
0x10000080 / Option_ActiveOpacity_Property 0x10000081, live-apply on
drag through RetailWindowOpacityController, defaults read from the
installed DAT's DBProperties collection at DID 0x78000001 via
ChatOptionsDatDefaults), and the five per-window text-filter blocks
(main window 12 rows minus Gameplay, four floaties 13 rows each — the
byte-verified authored order cross-checked against the raw
gmChatOptionsUI::InitOptions/AddCheckboxBitfield64Option pseudo-C, not
just the research doc's own table) writing AcDream.Core.Chat.
ChatWindowState directly, the same state CH6's chat windows already
read.

AP-195 retired: ported both halves left open at the OP2 re-review —
the ALL-set LED media swap (new UiButton.FaceFileOverride, driven by
the block-level P0x10000082/P0x10000083 sprites now threaded through
ElementInfo/DatWidgetFactory) and the CreateChildren self-sizing tail
(UiCheckboxBitfield64.Height grows with its stacked row content; the
enclosing ListBox reflows around the block's FINAL height via the new
UiTemplateListBox.AddPrebuiltRow, reusing the ListBox's own stacking
rather than a third stacking path). AP-187 broadened to cover the main
window's own filter (previously only the four floaties) and the new
live-editing write path.

The main chat window's filter (retail window id 8, ChatWindowState id
0) gains its own settings.json persistence (ChatSettings.
ChatWindowMainFilter) alongside the pre-existing floaty 1-4 fields;
opacity persistence is now wired on every live slider change, not only
through the old dev-scaffold Settings panel.

Fixture regeneration (ACDREAM_REGENERATE_UI_FIXTURES=1) picked up the
new ElementInfo.LedCheckedSprite/LedUncheckedSprite fields across all
19 committed layout fixtures — purely additive, confirmed against the
live installed DAT (0x10000520's own 0x82/0x83 properties resolve to
0x06004D17/0x06004D19 exactly as AP-195 documented).

Conformance: FilterRows/FilterBlocks pinned against the byte-verified
authored order and ChatWindowState's own default constants; the AP-195
LED swap and self-sizing behavior; the DAT opacity-default extraction
against the live installed DAT; live filter/opacity writes reaching
ChatWindowState/RetailWindowOpacityController; OnShown re-seed and
Reset/Defaults ghosting per the OP4 binding-pattern discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 06:25:59 +02:00

294 lines
11 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 NamedDefaultConstants_MatchTheSameTable()
{
// Campaign OP slice OP5: the named constants ResetToDefaults() itself now
// uses — and that the Chat tab's Defaults button reuses directly — must
// never drift from this table.
Assert.Equal(0xFBFFFFFFu, ChatWindowState.MainWindowDefaultFilter);
Assert.Equal(0x0000101Cu, ChatWindowState.Floaty1DefaultFilter);
Assert.Equal(0x00040C00u, ChatWindowState.Floaty2DefaultFilter);
Assert.Equal(0x00080000u, ChatWindowState.Floaty3DefaultFilter);
Assert.Equal(0x78000000u, ChatWindowState.Floaty4DefaultFilter);
}
[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 is a real window id, not
// BroadcastTargetWindow) 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: ChatWindowState.BroadcastTargetWindow, logTextType: 0x02u)); // Speech
}
[Fact]
public void ShouldDisplay_Broadcast_FilterMiss_DoesNotShow()
{
var state = new ChatWindowState();
Assert.False(state.ShouldDisplay(
windowId: 1, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x0Au)); // Social
}
[Fact]
public void ShouldDisplay_MainWindow_BroadcastRespectsItsOwnFilter()
{
// CH6a/b REJECT-review BLOCKER (SHOULD-FIX 2): before the fix, window
// 0's explicit-addressing branch (targetWindowId == windowId) used the
// SAME literal 0 as the broadcast sentinel, so it short-circuited true
// for EVERY broadcast line regardless of window 0's own filter. Now
// that BroadcastTargetWindow is a distinct sentinel, the main
// window's filter (0xFBFFFFFF — excludes 0x1A, Society opt-in) is
// genuinely consulted for a broadcast line, exactly like every other
// window.
var state = new ChatWindowState();
Assert.False(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x1Au)); // excluded
Assert.False(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x20u)); // Society, opt-in
Assert.True(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x02u)); // Speech
}
[Fact]
public void ShouldDisplay_MainWindow_ExplicitlyAddressed_AlwaysShows()
{
// Window id 0 (main) is still a valid EXPLICIT address, distinct from
// BroadcastTargetWindow — this is what keeps a future
// targetWindowId == MainWindowId (AP-180's per-window echo)
// expressible: explicit addressing to main always wins, even for a
// type main's own filter would otherwise reject.
var state = new ChatWindowState();
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x1Au));
}
// ── SetFilter / SetOpen / Toggle ─────────────────────────────────────────
[Fact]
public void SetFilter_MainWindow_Persists()
{
// CH6a/b REJECT-review SHOULD-FIX 2: dropped the old no-op — the main
// window's filter is now genuinely settable, matching retail's own
// options-page-driven main-window filter.
var state = new ChatWindowState();
state.SetFilter(0, 0x1u);
Assert.Equal(0x1u, state.GetFilter(0));
Assert.True(state.TypeIsActive(0, 0x00u));
Assert.False(state.TypeIsActive(0, 0x02u));
}
[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);
}
}