// @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('registers a PendingOp via socketHandler.registerPendingOp with correct shape', () => { // With self-confirm, _pendingOps is cleared synchronously after action(). // Verify the op was passed to registerPendingOp before being confirmed. 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('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 after self-confirm', () => { // Self-confirm calls _onEcho which fires the hook with source: 'echo'. controller.action('ui', 'user-1', 'hidden', 'op-1', 0); expect(hooksStub.callAll).toHaveBeenCalledWith( 'scrying-pool:controllerAction', expect.objectContaining({ participantId: 'user-1', targetState: 'hidden', source: 'echo', opId: 'op-1' }) ); }); it('sets previousState to "active" when participant is new (not yet in matrix)', () => { // With self-confirm, _pendingOps is cleared synchronously. Verify via registerPendingOp arg. controller.action('ui', 'new-user', 'hidden', 'op-1', 0); // 'active' is the render-time default for users not in the matrix. expect(socketHandler.registerPendingOp).toHaveBeenCalledWith( expect.objectContaining({ previousState: 'active' }), expect.any(String), expect.any(Object) ); }); }); // ── 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]; } // Helper: directly register a pending op (bypasses action() self-confirm) function seedPendingOp(userId, opId, targetState = 'hidden') { const op = { opId, userId, targetState, previousState: 'active' }; controller._pendingOps.set(userId, op); socketHandler.registerPendingOp(op, 'scrying-pool.visibility.set', {}); } it('calls socketHandler.confirmPendingOp with the opId', () => { const echoHandler = getEchoHandler(); seedPendingOp('user-1', 'op-1'); 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(); seedPendingOp('user-1', 'op-2'); echoHandler({ opId: 'op-2', 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(); seedPendingOp('user-1', 'op-3', 'active'); const setSpy = vi.spyOn(stateStore, 'setVisibility'); echoHandler({ opId: 'op-3', 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(); seedPendingOp('user-1', 'op-4'); echoHandler({ opId: 'op-4', 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-4' }) ); }); it('removes the participant from _pendingOps after echo', () => { const echoHandler = getEchoHandler(); seedPendingOp('user-1', 'op-1'); expect(controller._pendingOps.has('user-1')).toBe(true); 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(); seedPendingOp('user-1', 'op-1'); echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision expect(controller._revisions.get('user-1')).toBe(0); }); }); // ── teardown() — listener cleanup (T-debt deferred from 1.4) ────────────── describe('teardown()', () => { it('unregisters socket echo listener', () => { adapter.socket.off = vi.fn(); controller.init(); const handler = controller._echoHandler; controller.teardown(); expect(adapter.socket.off).toHaveBeenCalledWith( 'scrying-pool.visibility.updated', handler ); }); it('unregisters userConnected hook listener', () => { const fakeHookId = 42; adapter.hooks.on = vi.fn().mockReturnValue(fakeHookId); controller.init(); controller.teardown(); expect(adapter.hooks.off).toHaveBeenCalledWith('userConnected', fakeHookId); }); it('clears _pendingOps and _revisions', () => { controller.init(); controller._pendingOps.set('u1', {}); controller._revisions.set('u1', 5); controller.teardown(); expect(controller._pendingOps.size).toBe(0); expect(controller._revisions.size).toBe(0); }); it('nulls _echoHandler after teardown', () => { adapter.socket.off = vi.fn(); controller.init(); controller.teardown(); expect(controller._echoHandler).toBeNull(); }); it('is safe to call before init()', () => { adapter.socket.off = vi.fn(); expect(() => controller.teardown()).not.toThrow(); }); }); // ── userConnected disconnect cleanup (T-06 debt) ────────────────────────── describe('userConnected disconnect cleanup', () => { it('cleans up participant on disconnect', () => { // Capture the userConnected handler let capturedHandler; adapter.hooks.on = vi.fn((event, handler) => { if (event === 'userConnected') capturedHandler = handler; return Symbol(); }); controller.init(); controller._revisions.set('u1', 3); controller._pendingOps.set('u1', {}); capturedHandler({ id: 'u1' }, false); // user 'u1' disconnected expect(controller._revisions.has('u1')).toBe(false); expect(controller._pendingOps.has('u1')).toBe(false); }); it('does not clean up on connect (connected=true)', () => { let capturedHandler; adapter.hooks.on = vi.fn((event, handler) => { if (event === 'userConnected') capturedHandler = handler; return Symbol(); }); controller.init(); controller._revisions.set('u1', 3); capturedHandler({ id: 'u1' }, true); // user connected — should not clean up expect(controller._revisions.has('u1')).toBe(true); }); }); });