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>
294 lines
11 KiB
C#
294 lines
11 KiB
C#
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);
|
||
}
|
||
}
|