381 lines
14 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|