Files
scrying-pool/tests/unit/core/ScryingPoolController.test.js
T
uberwald 5dc9b3b8d4
CI / ci (push) Failing after 7s
Module cleanup and tests
2026-05-24 23:13:45 +02:00

381 lines
14 KiB
JavaScript

// @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);
});
});
});