acdream/tests/AcDream.Core.Tests/Chat/ChatWindowStateTests.cs
Erik 1aa7709988 fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:09:32 +02:00

281 lines
10 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 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);
}
}