# Blind Hunter Review Prompt - Story 1-5 Group 1 (Core Logic) **Story:** 1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration **Group:** Core Logic (Group 1 of 4) **Files:** ScryingPoolController.js, VisibilityManager.js, and their tests **Diff lines:** 804 --- ## YOUR ROLE: Blind Hunter You are a **Blind Hunter** code reviewer. You receive **ONLY** the diff below — no spec, no context, no project access. Your job is to find security issues, bugs, anti-patterns, performance problems, and code quality issues using ONLY what you see in the diff. ### Rules: - You have NO access to the spec file - You have NO access to the project repository - You have NO context about the project's goals - You MUST find at least 5 issues - Be adversarial: assume the code has problems and find them - Focus on: security vulnerabilities, race conditions, error handling gaps, performance issues, code smells, test coverage gaps ### Output Format: Output findings as a Markdown list. Each finding: ```markdown - **SEV-XX: [One-line title]** — [Evidence from diff] — [Impact] ``` Classify severity: - **SEV-CRITICAL**: Security vulnerability, data loss, crash - **SEV-HIGH**: Race condition, resource leak, incorrect behavior - **SEV-MEDIUM**: Code smell, maintainability issue - **SEV-LOW**: Style, minor improvement --- ## DIFF TO REVIEW diff --git a/src/core/ScryingPoolController.js b/src/core/ScryingPoolController.js new file mode 100644 index 0000000..fc013f0 --- /dev/null +++ b/src/core/ScryingPoolController.js @@ -0,0 +1,181 @@ +/** + * ScryingPoolController — Orchestrates GM visibility actions with optimistic state updates. + * + * Handles: GM authorization, latest-revision-wins guard, last-intent guard, PendingOp + * lifecycle, optimistic setVisibility, socket emit, and echo reconciliation. + * + * Import rule: may only import from src/contracts/ and src/utils/. + * Constructors are side-effect free — call init() from module.js Hooks.once('ready'). + * + * @module core/ScryingPoolController + */ + +import { createPendingOp } from '../contracts/pending-op.js'; +import { createSocketIntentMessage, SOCKET_EVENTS } from '../contracts/socket-message.js'; + +/** + * Orchestrates GM visibility actions: auth, optimistic state, socket emit, echo reconciliation. + */ +export class ScryingPoolController { + /** + * @param {import('./StateStore.js').StateStore} stateStore + * @param {{ emit(event: string, payload: object): void, registerPendingOp(op: object, event: string, payload: object): void, confirmPendingOp(opId: string): void, setReady(handler: object): void }} socketHandler + * @param {{ users: { isGM(): boolean }, socket: { on(event: string, handler: (...args: unknown[]) => void): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void, callAll(event: string, data: unknown): void } }} adapter + */ + constructor(stateStore, socketHandler, adapter) { + this._stateStore = stateStore; + this._socketHandler = socketHandler; + this._adapter = adapter; + /** @type {Map} participantId → PendingOp */ + this._pendingOps = new Map(); + /** @type {Map} participantId → last-confirmed revision */ + this._revisions = new Map(); + } + + /** + * Registers the socket echo listener. + * Called from module.js Hooks.once('ready') — NOT from constructor. + */ + init() { + this._adapter.socket.on( + SOCKET_EVENTS.VISIBILITY_UPDATED, + (payload) => this._onEcho(/** @type {any} */ (payload)) + ); + } + + /** + * Returns the last confirmed revision for a participant (0 if unknown). + * @param {string} participantId + * @returns {number} + */ + getRevision(participantId) { + return this._revisions.get(participantId) ?? 0; + } + + /** + * Returns true if a pending op is currently in-flight for the given participant. + * @param {string} participantId + * @returns {boolean} + */ + hasPendingOp(participantId) { + return this._pendingOps.has(participantId); + } + + /** + * Cleans up a pending operation by userId. + * Called by SocketHandler timeout callback via composite handler in module.js. + * @param {string} userId + */ + cleanupPendingOp(userId) { + this._pendingOps.delete(userId); + } + + /** + * Processes a GM visibility toggle request. + * Guards: isGM, latest-revision-wins, last-intent (idempotent). + * + * @param {string} source - Who triggered the action (e.g. 'ui', 'preset'). + * @param {string} participantId - Target userId. + * @param {string} targetState - Desired VisibilityState. + * @param {string} opId - Unique operation ID (supplied by caller — Story 1.5 UI). + * @param {number} baseRevision - StateStore revision at time of intent. + */ + action(source, participantId, targetState, opId, baseRevision) { + // 0. Input validation + if (!participantId || typeof participantId !== 'string') { + console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid participantId'); + return; + } + if (!targetState || typeof targetState !== 'string') { + console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid targetState'); + return; + } + if (!opId || typeof opId !== 'string') { + console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid opId'); + return; + } + if (typeof baseRevision !== 'number' || !Number.isFinite(baseRevision) || baseRevision < 0) { + console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid baseRevision'); + return; + } + + // 1. Authorization + if (!this._adapter.users.isGM()) { + console.warn('[ScryingPool]', 'ScryingPoolController.action: non-GM call rejected'); + return; + } + + // 2. Latest-revision-wins guard + const currentRevision = this._revisions.get(participantId) ?? 0; + if (baseRevision < currentRevision) return; + + // 3. Last-intent guard (idempotent) + const currentState = this._stateStore.getState(participantId); + if (currentState === targetState) return; + + // 4. Register PendingOp + const previousState = currentState ?? 'never-connected'; + const pendingOp = createPendingOp(opId, participantId, targetState, previousState); + this._pendingOps.set(participantId, pendingOp); + + // 5. Optimistic state update + this._stateStore.setVisibility(participantId, targetState); + + // 6. Socket emit + const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision); + this._socketHandler.emit(msg.event, msg.payload); + + // 7. Start acknowledgement timer + this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload); + + // 8. Notify UI subscribers + try { + this._adapter.hooks.callAll('scrying-pool:controllerAction', { participantId, targetState, source, opId }); + } catch (hookErr) { + console.error('[ScryingPool] ScryingPoolController.action: hook emission failed', hookErr); + } + } + + /** + * Processes an authoritative echo from the socket server. + * Confirms the pending op, updates revision, and sets the authoritative state. + * @private + * @param {{ opId: string, userId: string, state: string, revision?: number }} payload + */ + _onEcho(payload) { + // Validate payload fields + if (!payload || typeof payload !== 'object') { + console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: invalid payload'); + return; + } + const { opId, userId, state, revision } = payload; + if (!opId || typeof opId !== 'string') { + console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid opId'); + return; + } + if (!userId || typeof userId !== 'string') { + console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid userId'); + return; + } + if (!state || typeof state !== 'string') { + console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid state'); + return; + } + + this._socketHandler.confirmPendingOp(opId); + this._revisions.set(userId, revision ?? 0); + this._pendingOps.delete(userId); + this._stateStore.setVisibility(userId, state); + + try { + this._adapter.hooks.callAll('scrying-pool:controllerAction', { + participantId: userId, + targetState: state, + source: 'echo', + opId, + }); + } catch (hookErr) { + console.error('[ScryingPool] ScryingPoolController._onEcho: hook emission failed', hookErr); + } + } +} diff --git a/src/core/VisibilityManager.js b/src/core/VisibilityManager.js new file mode 100644 index 0000000..0e465f2 --- /dev/null +++ b/src/core/VisibilityManager.js @@ -0,0 +1,104 @@ +/** + * VisibilityManager — WebRTC strategy applier and SocketHandler revert handler. + * + * Listens to `scrying-pool:stateChanged` hook events (emitted by StateStore) and + * applies the appropriate webrtcMode strategy: + * - 'track-disable' + non-null adapter.webrtc → call disableTrack / enableTrack + * - 'css-fallback' / 'unsupported' / null webrtc → no-op (CSS handled by RoleRenderer) + * + * Also implements onRevert(pendingOp) for SocketHandler timeout callbacks. + * + * Import rule: may only import from src/contracts/ and src/utils/. + * Constructors are side-effect free — call init() from module.js Hooks.once('ready'). + * + * @module core/VisibilityManager + */ + +/** + * Applies webrtcMode strategy on state changes and reverts failed operations. + */ +export class VisibilityManager { + /** + * @param {import('./StateStore.js').StateStore} stateStore + * @param {{ settings: { get(key: string): unknown }, webrtc: { disableTrack(userId: string): void, enableTrack(userId: string): void } | null, notifications: { warn(msg: string): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void } }} adapter + */ + constructor(stateStore, adapter) { + this._stateStore = stateStore; + this._adapter = adapter; + } + + /** + * Registers the Hooks.on('scrying-pool:stateChanged') listener. + * Called from module.js Hooks.once('ready') — NOT from constructor. + */ + init() { + this._adapter.hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(/** @type {any} */ (data))); + } + + /** + * Handles a state change by applying the webrtcMode strategy. + * css-fallback / unsupported → no-op (CSS applied by RoleRenderer in Story 1.5). + * track-disable + non-null webrtc → disable/enable the participant's track. + * Always safe with null adapter.webrtc (OQ-1 spike result for v14). + * + * @private + * @param {{ userId: string, state: string }} data + */ + _onStateChanged(data) { + const { userId, state } = data; + // Input validation + if (!userId || typeof userId !== 'string') { + console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid userId'); + return; + } + if (!state || typeof state !== 'string') { + console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid state'); + return; + } + + const mode = this._adapter.settings.get('webrtcMode'); + if (mode !== 'track-disable' || !this._adapter.webrtc) return; + if (state === 'hidden') { + this._adapter.webrtc.disableTrack(userId); + } else { + this._adapter.webrtc.enableTrack(userId); + } + } + + /** + * Called by SocketHandler after retry exhaustion — reverts the optimistic state + * and notifies the GM that the operation could not be confirmed. + * + * @param {{ userId: string, previousState: string, opId: string }} pendingOp + */ + onRevert(pendingOp) { + // Input validation + if (!pendingOp || typeof pendingOp !== 'object') { + console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid pendingOp'); + return; + } + const { userId, previousState } = pendingOp; + if (!userId || typeof userId !== 'string') { + console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid userId in pendingOp'); + return; + } + if (!previousState || typeof previousState !== 'string') { + console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid previousState in pendingOp'); + return; + } + + try { + this._stateStore.setVisibility(userId, previousState); + } catch (err) { + console.error('[ScryingPool] VisibilityManager.onRevert: setVisibility failed', err); + } + + try { + this._adapter.notifications.warn( + `[ScryingPool] Visibility change for ${userId} could not be confirmed — reverting to ${previousState}` + ); + } catch (err) { + console.error('[ScryingPool] VisibilityManager.onRevert: notification failed', err); + } + } +} diff --git a/tests/unit/core/ScryingPoolController.test.js b/tests/unit/core/ScryingPoolController.test.js new file mode 100644 index 0000000..eb4f4ad --- /dev/null +++ b/tests/unit/core/ScryingPoolController.test.js @@ -0,0 +1,277 @@ +// @ts-nocheck +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ScryingPoolController } from '../../../src/core/ScryingPoolController.js'; +import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js'; +import { StateStore } from '../../../src/core/StateStore.js'; + +/** @returns {{ emit: Function, registerPendingOp: Function, confirmPendingOp: Function, setReady: Function }} */ +function makeSocketHandler() { + return { + emit: vi.fn(), + registerPendingOp: vi.fn(), + confirmPendingOp: vi.fn(), + setReady: vi.fn(), + }; +} + +/** @returns {StateStore} */ +function makeStateStore() { + const settingsMock = { + get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }), + set: vi.fn().mockResolvedValue(undefined), + register: vi.fn(), + }; + return new StateStore(settingsMock); +} + +describe('ScryingPoolController', () => { + let adapter; + let stateStore; + let socketHandler; + let controller; + let hooksStub; + + beforeEach(() => { + hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() }; + vi.stubGlobal('Hooks', hooksStub); + + adapter = createFoundryAdapterMock({ + users: { isGM: () => true }, + hooks: hooksStub + }); + adapter.socket.on = vi.fn(); + + stateStore = makeStateStore(); + socketHandler = makeSocketHandler(); + controller = new ScryingPoolController(stateStore, socketHandler, adapter); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + // ── AC-1: Construction ──────────────────────────────────────────────────── + + describe('constructor (AC-1)', () => { + it('initialises _pendingOps as an empty Map', () => { + expect(controller._pendingOps).toBeInstanceOf(Map); + expect(controller._pendingOps.size).toBe(0); + }); + + it('initialises _revisions as an empty Map', () => { + expect(controller._revisions).toBeInstanceOf(Map); + expect(controller._revisions.size).toBe(0); + }); + + it('does NOT register socket listener in constructor (side-effect free)', () => { + expect(adapter.socket.on).not.toHaveBeenCalled(); + }); + }); + + // ── AC-1: init() ───────────────────────────────────────────────────────── + + describe('init() (AC-1)', () => { + it('registers socket echo listener for scrying-pool.visibility.updated', () => { + controller.init(); + expect(adapter.socket.on).toHaveBeenCalledWith( + 'scrying-pool.visibility.updated', + expect.any(Function) + ); + }); + }); + + // ── AC-2: action() happy path ───────────────────────────────────────────── + + describe('action() happy path (AC-2)', () => { + it('stores a PendingOp in _pendingOps keyed by participantId', () => { + controller.action('ui', 'user-1', 'hidden', 'op-1', 0); + expect(controller._pendingOps.has('user-1')).toBe(true); + expect(controller._pendingOps.get('user-1')).toMatchObject({ + opId: 'op-1', + userId: 'user-1', + targetState: 'hidden', + }); + }); + + it('calls stateStore.setVisibility with the target state (optimistic update)', () => { + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + controller.action('ui', 'user-1', 'hidden', 'op-1', 0); + expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden'); + }); + + it('calls socketHandler.emit with VISIBILITY_SET event and correct payload', () => { + controller.action('ui', 'user-1', 'hidden', 'op-1', 0); + expect(socketHandler.emit).toHaveBeenCalledWith( + 'scrying-pool.visibility.set', + expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden', baseRevision: 0 }) + ); + }); + + it('calls socketHandler.registerPendingOp with the PendingOp, event, and payload', () => { + controller.action('ui', 'user-1', 'hidden', 'op-1', 0); + expect(socketHandler.registerPendingOp).toHaveBeenCalledWith( + expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden' }), + 'scrying-pool.visibility.set', + expect.objectContaining({ opId: 'op-1' }) + ); + }); + + it('fires Hooks.callAll scrying-pool:controllerAction with correct payload', () => { + controller.action('ui', 'user-1', 'hidden', 'op-1', 0); + expect(hooksStub.callAll).toHaveBeenCalledWith( + 'scrying-pool:controllerAction', + expect.objectContaining({ participantId: 'user-1', targetState: 'hidden', source: 'ui', opId: 'op-1' }) + ); + }); + + it('sets previousState to null-coalesced "never-connected" when participant is new', () => { + controller.action('ui', 'new-user', 'hidden', 'op-1', 0); + const op = controller._pendingOps.get('new-user'); + expect(op.previousState).toBe('never-connected'); + }); + }); + + // ── AC-5: non-GM authorization ──────────────────────────────────────────── + + describe('action() non-GM authorization (AC-5)', () => { + it('warns and silently drops the action when adapter.users.isGM() is false', () => { + const nonGmAdapter = createFoundryAdapterMock({ + users: { isGM: () => false }, + hooks: hooksStub + }); + nonGmAdapter.socket.on = vi.fn(); + const playerController = new ScryingPoolController(stateStore, socketHandler, nonGmAdapter); + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + playerController.action('ui', 'user-1', 'hidden', 'op-1', 0); + + expect(warnSpy).toHaveBeenCalledWith('[ScryingPool]', expect.stringContaining('non-GM')); + expect(setSpy).not.toHaveBeenCalled(); + expect(socketHandler.emit).not.toHaveBeenCalled(); + expect(socketHandler.registerPendingOp).not.toHaveBeenCalled(); + expect(hooksStub.callAll).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + }); + + // ── AC-3: latest-revision-wins guard ───────────────────────────────────── + + describe('action() latest-revision-wins guard (AC-3)', () => { + it('silently drops action when baseRevision < confirmed revision', () => { + controller._revisions.set('user-1', 5); + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + + controller.action('ui', 'user-1', 'hidden', 'op-2', 3); // 3 < 5 → stale + + expect(setSpy).not.toHaveBeenCalled(); + expect(socketHandler.emit).not.toHaveBeenCalled(); + expect(hooksStub.callAll).not.toHaveBeenCalled(); + }); + + it('allows action when baseRevision equals confirmed revision (not stale)', () => { + controller._revisions.set('user-1', 5); + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + + controller.action('ui', 'user-1', 'hidden', 'op-2', 5); // 5 == 5 → not stale + + expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden'); + }); + + it('allows action with baseRevision=0 when no revision confirmed yet', () => { + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + + controller.action('ui', 'user-1', 'hidden', 'op-1', 0); + + expect(setSpy).toHaveBeenCalled(); + }); + }); + + // ── AC-4: last-intent guard ─────────────────────────────────────────────── + + describe('action() last-intent guard (AC-4)', () => { + it('silently drops action when participant is already in targetState', () => { + // Seed the state store with the current state + stateStore.setVisibility('user-1', 'hidden'); + vi.clearAllMocks(); // reset all mock call counts + + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + + controller.action('ui', 'user-1', 'hidden', 'op-2', 0); + + expect(setSpy).not.toHaveBeenCalled(); + expect(socketHandler.emit).not.toHaveBeenCalled(); + }); + + it('allows action when targetState differs from current state', () => { + stateStore.setVisibility('user-1', 'active'); + vi.clearAllMocks(); + + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + + controller.action('ui', 'user-1', 'hidden', 'op-3', 0); + + expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden'); + }); + }); + + // ── AC-11: echo reconciliation (_onEcho) ────────────────────────────────── + + describe('_onEcho() echo reconciliation (AC-11)', () => { + // Helper: call init() and return the captured echo handler + function getEchoHandler() { + controller.init(); + return adapter.socket.on.mock.calls[0][1]; + } + + it('calls socketHandler.confirmPendingOp with the opId', () => { + const echoHandler = getEchoHandler(); + echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 }); + expect(socketHandler.confirmPendingOp).toHaveBeenCalledWith('op-1'); + }); + + it('stores the echo revision in _revisions for the userId', () => { + const echoHandler = getEchoHandler(); + echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 7 }); + expect(controller._revisions.get('user-1')).toBe(7); + }); + + it('calls stateStore.setVisibility with the authoritative state', () => { + const echoHandler = getEchoHandler(); + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + + echoHandler({ opId: 'op-1', userId: 'user-1', state: 'active', revision: 2 }); + + expect(setSpy).toHaveBeenCalledWith('user-1', 'active'); + }); + + it('fires Hooks.callAll scrying-pool:controllerAction with source: echo', () => { + const echoHandler = getEchoHandler(); + echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 }); + + expect(hooksStub.callAll).toHaveBeenCalledWith( + 'scrying-pool:controllerAction', + expect.objectContaining({ source: 'echo', participantId: 'user-1', targetState: 'hidden', opId: 'op-1' }) + ); + }); + + it('removes the participant from _pendingOps after echo', () => { + // Register a pending op first + controller.action('ui', 'user-1', 'hidden', 'op-1', 0); + expect(controller._pendingOps.has('user-1')).toBe(true); + + const echoHandler = getEchoHandler(); + echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 }); + + expect(controller._pendingOps.has('user-1')).toBe(false); + }); + + it('defaults revision to 0 when echo payload omits revision field', () => { + const echoHandler = getEchoHandler(); + echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision + expect(controller._revisions.get('user-1')).toBe(0); + }); + }); +}); diff --git a/tests/unit/core/VisibilityManager.test.js b/tests/unit/core/VisibilityManager.test.js new file mode 100644 index 0000000..36df70f --- /dev/null +++ b/tests/unit/core/VisibilityManager.test.js @@ -0,0 +1,218 @@ +// @ts-nocheck +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { VisibilityManager } from '../../../src/core/VisibilityManager.js'; +import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js'; +import { StateStore } from '../../../src/core/StateStore.js'; + +/** @returns {StateStore} */ +function makeStateStore() { + const settingsMock = { + get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }), + set: vi.fn().mockResolvedValue(undefined), + register: vi.fn(), + }; + return new StateStore(settingsMock); +} + +describe('VisibilityManager', () => { + let adapter; + let stateStore; + let manager; + let hooksStub; + + beforeEach(() => { + hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() }; + vi.stubGlobal('Hooks', hooksStub); + + adapter = createFoundryAdapterMock({ hooks: hooksStub }); + stateStore = makeStateStore(); + manager = new VisibilityManager(stateStore, adapter); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + // ── AC-1 (construction side-effect free) ───────────────────────────────── + + describe('constructor (side-effect free)', () => { + it('does NOT register Hooks.on listener in constructor', () => { + expect(hooksStub.on).not.toHaveBeenCalled(); + }); + }); + + // ── init() ──────────────────────────────────────────────────────────────── + + describe('init()', () => { + it('registers Hooks.on for scrying-pool:stateChanged', () => { + manager.init(); + expect(hooksStub.on).toHaveBeenCalledWith( + 'scrying-pool:stateChanged', + expect.any(Function) + ); + }); + }); + + // ── AC-6: _onStateChanged — track-disable strategy ──────────────────────── + + describe('_onStateChanged() track-disable strategy (AC-6)', () => { + let webrtcMock; + + beforeEach(() => { + webrtcMock = { disableTrack: vi.fn(), enableTrack: vi.fn() }; + const trackDisableAdapter = createFoundryAdapterMock({ + webrtc: webrtcMock, + settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, trackDisableAdapter); + manager.init(); + }); + + it('calls disableTrack(userId) when state is hidden', () => { + const handler = hooksStub.on.mock.calls[0][1]; + handler({ userId: 'user-1', state: 'hidden' }); + expect(webrtcMock.disableTrack).toHaveBeenCalledWith('user-1'); + expect(webrtcMock.enableTrack).not.toHaveBeenCalled(); + }); + + it('calls enableTrack(userId) when state is active', () => { + const handler = hooksStub.on.mock.calls[0][1]; + handler({ userId: 'user-1', state: 'active' }); + expect(webrtcMock.enableTrack).toHaveBeenCalledWith('user-1'); + expect(webrtcMock.disableTrack).not.toHaveBeenCalled(); + }); + }); + + // ── AC-7: _onStateChanged — css-fallback / unsupported ──────────────────── + + describe('_onStateChanged() css-fallback strategy (AC-7)', () => { + it('performs no webrtc call and throws no error when mode is css-fallback', () => { + const cssFallbackAdapter = createFoundryAdapterMock({ + settings: { get: (key) => (key === 'webrtcMode' ? 'css-fallback' : null) }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, cssFallbackAdapter); + manager.init(); + + const handler = hooksStub.on.mock.calls[0][1]; + expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow(); + }); + + it('performs no webrtc call and throws no error when mode is unsupported', () => { + const unsupportedAdapter = createFoundryAdapterMock({ + settings: { get: (key) => (key === 'webrtcMode' ? 'unsupported' : null) }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, unsupportedAdapter); + manager.init(); + + const handler = hooksStub.on.mock.calls[0][1]; + expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow(); + }); + }); + + // ── AC-10: null webrtc guard ────────────────────────────────────────────── + + describe('_onStateChanged() null webrtc guard (AC-10)', () => { + it('does not throw when adapter.webrtc is null in track-disable mode', () => { + const nullWebrtcAdapter = createFoundryAdapterMock({ + webrtc: null, + settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, nullWebrtcAdapter); + manager.init(); + + const handler = hooksStub.on.mock.calls[0][1]; + expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow(); + }); + + it('does not throw when adapter.webrtc is null with state active', () => { + const nullWebrtcAdapter = createFoundryAdapterMock({ + webrtc: null, + settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, nullWebrtcAdapter); + manager.init(); + + const handler = hooksStub.on.mock.calls[0][1]; + expect(() => handler({ userId: 'user-1', state: 'active' })).not.toThrow(); + }); + }); + + // ── AC-9: onRevert() ───────────────────────────────────────────────────── + + describe('onRevert() (AC-9)', () => { + /** @type {import('../../../src/contracts/pending-op.js').PendingOp} */ + const pendingOp = { + opId: 'op-1', + userId: 'user-1', + targetState: 'hidden', + previousState: 'active', + issuedAt: 1000000, + timeoutId: null, + }; + + it('calls stateStore.setVisibility with previousState to revert', () => { + const setSpy = vi.spyOn(stateStore, 'setVisibility'); + manager.onRevert(pendingOp); + expect(setSpy).toHaveBeenCalledWith('user-1', 'active'); + }); + + it('calls adapter.notifications.warn with a [ScryingPool]-prefixed message', () => { + const warnMock = vi.fn(); + const warnAdapter = createFoundryAdapterMock({ + notifications: { warn: warnMock, info: () => {}, error: () => {} }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, warnAdapter); + + manager.onRevert(pendingOp); + + expect(warnMock).toHaveBeenCalledOnce(); + expect(warnMock.mock.calls[0][0]).toMatch(/^\[ScryingPool\]/); + }); + + it('includes userId in the warning message', () => { + const warnMock = vi.fn(); + const warnAdapter = createFoundryAdapterMock({ + notifications: { warn: warnMock, info: () => {}, error: () => {} }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, warnAdapter); + + manager.onRevert(pendingOp); + + expect(warnMock.mock.calls[0][0]).toContain('user-1'); + }); + + it('does NOT call notifications.info (no success notification on revert)', () => { + const infoMock = vi.fn(); + const noInfoAdapter = createFoundryAdapterMock({ + notifications: { warn: () => {}, info: infoMock, error: () => {} }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, noInfoAdapter); + + manager.onRevert(pendingOp); + + expect(infoMock).not.toHaveBeenCalled(); + }); + + it('does NOT call notifications.error', () => { + const errorMock = vi.fn(); + const noErrorAdapter = createFoundryAdapterMock({ + notifications: { warn: () => {}, info: () => {}, error: errorMock }, + hooks: hooksStub, + }); + manager = new VisibilityManager(stateStore, noErrorAdapter); + + manager.onRevert(pendingOp); + + expect(errorMock).not.toHaveBeenCalled(); + }); + }); +});