Fix Story 1.3: StateStore spec compliance and minor cleanup
Critical Fix: - StateStore now uses global Hooks.callAll directly (per spec) - Removed hooks parameter from StateStore constructor - Updated module.js to pass only adapter.settings - Updated tests to stub globalThis.Hooks Minor Cleanup: - Fixed misleading warning in SocketHandler.registerPendingOp - Added clarifying comment for setMatrix _revision behavior Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -11,12 +11,13 @@ import { SOCKET_PAYLOADS } from "../../fixtures/socket-payloads.js";
|
||||
describe("socket-message contract", () => {
|
||||
describe("createSocketIntentMessage()", () => {
|
||||
it("creates a valid intent message", () => {
|
||||
const msg = createSocketIntentMessage("op-1", "user-1", "hidden");
|
||||
const msg = createSocketIntentMessage("op-1", "user-1", "hidden", 0);
|
||||
expect(msg.event).toBe(SOCKET_EVENTS.VISIBILITY_SET);
|
||||
const p = /** @type {any} */ (msg.payload);
|
||||
expect(p.opId).toBe("op-1");
|
||||
expect(p.userId).toBe("user-1");
|
||||
expect(p.targetState).toBe("hidden");
|
||||
expect(p.baseRevision).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,10 +60,14 @@ describe("socket-message contract", () => {
|
||||
|
||||
it("throws on unknown top-level keys", () => {
|
||||
expect(() =>
|
||||
isValidSocketMessage({ event: SOCKET_EVENTS.VISIBILITY_SET, payload: { opId: "x", userId: "y", targetState: "active" }, extra: true })
|
||||
isValidSocketMessage({ event: SOCKET_EVENTS.VISIBILITY_SET, payload: { opId: "x", userId: "y", targetState: "active", baseRevision: 0 }, extra: true })
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("throws on missing baseRevision in intent", () => {
|
||||
expect(() => isValidSocketMessage(SOCKET_PAYLOADS.missingBaseRevision)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("throws on empty event string", () => {
|
||||
expect(() =>
|
||||
isValidSocketMessage({ event: "", payload: {} })
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* tests/unit/core/SocketHandler.test.js
|
||||
*
|
||||
* Story 1.3 — Full unit coverage for SocketHandler.
|
||||
* Uses vitest fake timers for timeout/retry/revert paths.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SocketHandler, SOCKET_TIMEOUT_MS } from '../../../src/core/SocketHandler.js';
|
||||
import { MAX_PAYLOAD_BYTES } from '../../../src/contracts/socket-message.js';
|
||||
|
||||
// ─── Stubs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSocket() {
|
||||
return { emit: vi.fn(), on: vi.fn(), off: vi.fn() };
|
||||
}
|
||||
|
||||
function makeHooks() {
|
||||
return { on: vi.fn(), once: vi.fn() };
|
||||
}
|
||||
|
||||
function makeHandler() {
|
||||
return { onRevert: vi.fn() };
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal PendingOp-like object.
|
||||
* @param {string} [opId]
|
||||
* @returns {object}
|
||||
*/
|
||||
function makePendingOp(opId = 'op-test-1') {
|
||||
return { opId, userId: 'user-1', targetState: 'hidden', baseRevision: 0, timeoutId: null };
|
||||
}
|
||||
|
||||
// ─── Constructor ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SocketHandler constructor', () => {
|
||||
it('creates instance in not-ready state', () => {
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
expect(h._isReady).toBe(false);
|
||||
expect(h._pendingOps.size).toBe(0);
|
||||
expect(h._messageQueue).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setReady() ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SocketHandler.setReady()', () => {
|
||||
it('marks handler as ready', () => {
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
h.setReady(makeHandler());
|
||||
expect(h._isReady).toBe(true);
|
||||
});
|
||||
|
||||
it('drains queued messages in order', () => {
|
||||
const socket = makeSocket();
|
||||
const h = new SocketHandler(socket, makeHooks());
|
||||
h.emit('event-a', { id: 1 });
|
||||
h.emit('event-b', { id: 2 });
|
||||
expect(socket.emit).not.toHaveBeenCalled();
|
||||
h.setReady(makeHandler());
|
||||
expect(socket.emit).toHaveBeenCalledTimes(2);
|
||||
expect(socket.emit.mock.calls[0]).toEqual(['event-a', { id: 1 }]);
|
||||
expect(socket.emit.mock.calls[1]).toEqual(['event-b', { id: 2 }]);
|
||||
});
|
||||
|
||||
it('accepts null handler and logs warn', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
h.setReady(null);
|
||||
expect(h._isReady).toBe(true);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[ScryingPool]'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── emit() ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SocketHandler.emit()', () => {
|
||||
it('queues message when not ready', () => {
|
||||
const socket = makeSocket();
|
||||
const h = new SocketHandler(socket, makeHooks());
|
||||
h.emit('test-event', { a: 1 });
|
||||
expect(socket.emit).not.toHaveBeenCalled();
|
||||
expect(h._messageQueue).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('emits directly when ready', () => {
|
||||
const socket = makeSocket();
|
||||
const h = new SocketHandler(socket, makeHooks());
|
||||
h.setReady(makeHandler());
|
||||
h.emit('test-event', { a: 1 });
|
||||
expect(socket.emit).toHaveBeenCalledWith('test-event', { a: 1 });
|
||||
});
|
||||
|
||||
it('throws when payload exceeds MAX_PAYLOAD_BYTES', () => {
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
h.setReady(makeHandler());
|
||||
const bigPayload = { data: 'x'.repeat(MAX_PAYLOAD_BYTES) };
|
||||
expect(() => h.emit('test-event', bigPayload)).toThrow(Error);
|
||||
});
|
||||
|
||||
it('does NOT throw for payload just under MAX_PAYLOAD_BYTES', () => {
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
h.setReady(makeHandler());
|
||||
const payload = { data: 'x'.repeat(MAX_PAYLOAD_BYTES - 20) };
|
||||
expect(() => h.emit('test-event', payload)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── registerPendingOp() + confirmPendingOp() ─────────────────────────────────
|
||||
|
||||
describe('SocketHandler.registerPendingOp() and confirmPendingOp()', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('registers a pending op', () => {
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
const op = makePendingOp();
|
||||
h.registerPendingOp(op);
|
||||
expect(h._pendingOps.has('op-test-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('confirms a pending op — clears it and cancels timer', () => {
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
const op = makePendingOp();
|
||||
h.registerPendingOp(op);
|
||||
h.confirmPendingOp('op-test-1');
|
||||
expect(h._pendingOps.has('op-test-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('confirmPendingOp with unknown opId is a no-op (stale echo)', () => {
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
expect(() => h.confirmPendingOp('unknown-op-id')).not.toThrow();
|
||||
});
|
||||
|
||||
it('confirms op before timeout — no timeout fires', () => {
|
||||
const socket = makeSocket();
|
||||
const h = new SocketHandler(socket, makeHooks());
|
||||
h.setReady(makeHandler());
|
||||
const op = makePendingOp();
|
||||
h.registerPendingOp(op);
|
||||
h.confirmPendingOp('op-test-1');
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 3);
|
||||
// No retry emit should have occurred
|
||||
expect(socket.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Timeout → retry → revert ────────────────────────────────────────────────
|
||||
|
||||
describe('SocketHandler timeout / retry / revert', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('retries once after first timeout (re-emits the op)', () => {
|
||||
const socket = makeSocket();
|
||||
const h = new SocketHandler(socket, makeHooks());
|
||||
h.setReady(makeHandler());
|
||||
const op = makePendingOp();
|
||||
h.registerPendingOp(op);
|
||||
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS);
|
||||
expect(socket.emit).toHaveBeenCalledTimes(1);
|
||||
// Expect cleaned payload without timeoutId
|
||||
expect(socket.emit).toHaveBeenCalledWith('scrying-pool.visibility.set', {
|
||||
opId: 'op-test-1',
|
||||
userId: 'user-1',
|
||||
targetState: 'hidden',
|
||||
baseRevision: 0,
|
||||
});
|
||||
expect(h._pendingOps.has('op-test-1')).toBe(true); // not removed yet
|
||||
});
|
||||
|
||||
it('calls handler.onRevert and warns after second timeout', () => {
|
||||
const socket = makeSocket();
|
||||
const handler = makeHandler();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const h = new SocketHandler(socket, makeHooks());
|
||||
h.setReady(handler);
|
||||
const op = makePendingOp();
|
||||
h.registerPendingOp(op);
|
||||
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // first timeout → retry
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // second timeout → revert
|
||||
expect(handler.onRevert).toHaveBeenCalledWith(op);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[ScryingPool]'), 'op-test-1');
|
||||
expect(h._pendingOps.has('op-test-1')).toBe(false);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does NOT call handler.onRevert when handler is null', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
h.setReady(null); // no handler
|
||||
const op = makePendingOp();
|
||||
h.registerPendingOp(op);
|
||||
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 2);
|
||||
// No error thrown is the assertion
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('ignores second timeout if op was confirmed between retry and second timeout', () => {
|
||||
const handler = makeHandler();
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
h.setReady(handler);
|
||||
const op = makePendingOp();
|
||||
h.registerPendingOp(op);
|
||||
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // first timeout → retry
|
||||
h.confirmPendingOp('op-test-1'); // confirmed during retry window
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // second timeout fires but op is gone
|
||||
expect(handler.onRevert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stale echo (confirmPendingOp after second revert) is a no-op', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const h = new SocketHandler(makeSocket(), makeHooks());
|
||||
h.setReady(makeHandler());
|
||||
const op = makePendingOp();
|
||||
h.registerPendingOp(op);
|
||||
|
||||
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 2); // full revert
|
||||
expect(() => h.confirmPendingOp('op-test-1')).not.toThrow();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SOCKET_TIMEOUT_MS constant ──────────────────────────────────────────────
|
||||
|
||||
describe('SOCKET_TIMEOUT_MS', () => {
|
||||
it('is 3000ms', () => {
|
||||
expect(SOCKET_TIMEOUT_MS).toBe(3000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* tests/unit/core/StateStore.test.js
|
||||
*
|
||||
* Story 1.3 — Full unit coverage for StateStore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { StateStore, VISIBILITY_MATRIX_KEY } from '../../../src/core/StateStore.js';
|
||||
import { VISIBILITY_STATES } from '../../../src/contracts/visibility-matrix.js';
|
||||
import { STATE_STORE_SNAPSHOTS } from '../../fixtures/state-store-snapshots.js';
|
||||
|
||||
// ─── Settings stub ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeSettings(initial = null) {
|
||||
let stored = initial;
|
||||
return {
|
||||
get: vi.fn(() => stored),
|
||||
set: vi.fn((key, value) => { stored = value; return Promise.resolve(value); }),
|
||||
_getStored: () => stored,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hooks stub ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makeHooksStub() {
|
||||
return { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
|
||||
}
|
||||
|
||||
// ─── Setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function setupHooksGlobal() {
|
||||
globalThis.Hooks = makeHooksStub();
|
||||
}
|
||||
|
||||
function cleanupHooksGlobal() {
|
||||
delete globalThis.Hooks;
|
||||
}
|
||||
|
||||
// ─── constructor ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StateStore constructor', () => {
|
||||
beforeEach(() => {
|
||||
setupHooksGlobal();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanupHooksGlobal();
|
||||
});
|
||||
|
||||
it('creates instance with empty matrix and revision 0', () => {
|
||||
const settings = makeSettings();
|
||||
const store = new StateStore(settings);
|
||||
expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} });
|
||||
});
|
||||
|
||||
it('throws TypeError if settings is null', () => {
|
||||
expect(() => new StateStore(null)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('throws TypeError if settings is not an object', () => {
|
||||
expect(() => new StateStore('not-an-object')).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── init() ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StateStore.init()', () => {
|
||||
beforeEach(() => {
|
||||
setupHooksGlobal();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanupHooksGlobal();
|
||||
});
|
||||
|
||||
it('hydrates from a valid persisted matrix', () => {
|
||||
const settings = makeSettings(STATE_STORE_SNAPSHOTS.threeParticipants);
|
||||
const store = new StateStore(settings);
|
||||
store.init();
|
||||
expect(store.getMatrix().matrix).toEqual(STATE_STORE_SNAPSHOTS.threeParticipants.matrix);
|
||||
});
|
||||
|
||||
it('starts fresh when settings.get returns null', () => {
|
||||
const settings = makeSettings(null);
|
||||
const store = new StateStore(settings);
|
||||
store.init();
|
||||
expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} });
|
||||
});
|
||||
|
||||
it('starts fresh and logs warn on corrupt data', () => {
|
||||
const settings = makeSettings({ _version: 1, matrix: { 'user-1': 'INVALID_STATE' } });
|
||||
const store = new StateStore(settings);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
store.init();
|
||||
expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} });
|
||||
// Warn is called with message and error details
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('[ScryingPool]');
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('is idempotent — second call wins', () => {
|
||||
const settings = makeSettings(STATE_STORE_SNAPSHOTS.threeParticipants);
|
||||
const store = new StateStore(settings);
|
||||
store.init();
|
||||
// Override stored value
|
||||
settings.get.mockReturnValue(STATE_STORE_SNAPSHOTS.empty);
|
||||
store.init();
|
||||
expect(store.getMatrix()).toEqual(STATE_STORE_SNAPSHOTS.empty);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getState() ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StateStore.getState()', () => {
|
||||
beforeEach(() => {
|
||||
setupHooksGlobal();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanupHooksGlobal();
|
||||
});
|
||||
|
||||
it('returns null for unknown userId', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
expect(store.getState('no-such-user')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the current state after setVisibility', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setVisibility('user-1', 'active');
|
||||
expect(store.getState('user-1')).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getMatrix() ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StateStore.getMatrix()', () => {
|
||||
beforeEach(() => {
|
||||
setupHooksGlobal();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanupHooksGlobal();
|
||||
});
|
||||
|
||||
it('returns a deep copy — mutations do not affect internal state', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setVisibility('user-1', 'active');
|
||||
const snapshot = store.getMatrix();
|
||||
snapshot.matrix['user-1'] = 'hidden';
|
||||
expect(store.getState('user-1')).toBe('active'); // internal state unchanged
|
||||
});
|
||||
|
||||
it('includes _version field', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
expect(store.getMatrix()._version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setVisibility() ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('StateStore.setVisibility()', () => {
|
||||
beforeEach(() => {
|
||||
setupHooksGlobal();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanupHooksGlobal();
|
||||
});
|
||||
|
||||
it('updates in-memory matrix immediately', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setVisibility('user-1', 'hidden');
|
||||
expect(store.getState('user-1')).toBe('hidden');
|
||||
});
|
||||
|
||||
it('calls settings.set with namespaced key, full matrix, and revision', () => {
|
||||
const settings = makeSettings();
|
||||
const store = new StateStore(settings);
|
||||
store.setVisibility('user-1', 'active');
|
||||
expect(settings.set).toHaveBeenCalledWith(
|
||||
VISIBILITY_MATRIX_KEY,
|
||||
{ _version: 1, _revision: 1, matrix: { 'user-1': 'active' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('emits scrying-pool:stateChanged via Hooks.callAll', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setVisibility('user-1', 'active');
|
||||
expect(globalThis.Hooks.callAll).toHaveBeenCalledWith(
|
||||
'scrying-pool:stateChanged',
|
||||
expect.objectContaining({ userId: 'user-1', state: 'active', revision: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('increments revision on each call', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setVisibility('user-1', 'active');
|
||||
store.setVisibility('user-2', 'hidden');
|
||||
const event = globalThis.Hooks.callAll.mock.calls[1][1];
|
||||
expect(event.revision).toBe(2);
|
||||
});
|
||||
|
||||
it('provides previousState = null for first-time set', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setVisibility('user-1', 'active');
|
||||
const event = globalThis.Hooks.callAll.mock.calls[0][1];
|
||||
expect(event.previousState).toBeNull();
|
||||
});
|
||||
|
||||
it('provides previousState = old value on update', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setVisibility('user-1', 'active');
|
||||
store.setVisibility('user-1', 'hidden');
|
||||
const event = globalThis.Hooks.callAll.mock.calls[1][1];
|
||||
expect(event.previousState).toBe('active');
|
||||
});
|
||||
|
||||
it('is a no-op for empty userId', () => {
|
||||
const settings = makeSettings();
|
||||
const store = new StateStore(settings);
|
||||
store.setVisibility('', 'active');
|
||||
expect(settings.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op for non-string userId', () => {
|
||||
const settings = makeSettings();
|
||||
const store = new StateStore(settings);
|
||||
store.setVisibility(null, 'active');
|
||||
expect(settings.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op for invalid targetState', () => {
|
||||
const settings = makeSettings();
|
||||
const store = new StateStore(settings);
|
||||
store.setVisibility('user-1', 'NONSENSE_STATE');
|
||||
expect(settings.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(VISIBILITY_STATES)('accepts valid state "%s"', (state) => {
|
||||
const store = new StateStore(makeSettings());
|
||||
expect(() => store.setVisibility('user-test', state)).not.toThrow();
|
||||
expect(store.getState('user-test')).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setMatrix() ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StateStore.setMatrix()', () => {
|
||||
beforeEach(() => {
|
||||
setupHooksGlobal();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanupHooksGlobal();
|
||||
});
|
||||
|
||||
it('replaces the entire matrix from a valid snapshot', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants);
|
||||
expect(store.getMatrix().matrix).toEqual(STATE_STORE_SNAPSHOTS.threeParticipants.matrix);
|
||||
});
|
||||
|
||||
it('persists via settings.set', () => {
|
||||
const settings = makeSettings();
|
||||
const store = new StateStore(settings);
|
||||
store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants);
|
||||
expect(settings.set).toHaveBeenCalledWith(
|
||||
VISIBILITY_MATRIX_KEY,
|
||||
expect.objectContaining({ matrix: STATE_STORE_SNAPSHOTS.threeParticipants.matrix }),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits scrying-pool:stateChanged via Hooks.callAll with full matrix', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants);
|
||||
expect(globalThis.Hooks.callAll).toHaveBeenCalledWith(
|
||||
'scrying-pool:stateChanged',
|
||||
expect.objectContaining({ matrix: expect.any(Object), revision: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws TypeError on invalid matrix', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
expect(() => store.setMatrix({ _version: 1, matrix: { 'user-1': 'INVALID' } })).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('deep-copies input — external mutations do not affect internal state', () => {
|
||||
const store = new StateStore(makeSettings());
|
||||
const input = { _version: 1, matrix: { 'user-1': 'active' } };
|
||||
store.setMatrix(input);
|
||||
input.matrix['user-1'] = 'hidden';
|
||||
expect(store.getState('user-1')).toBe('active');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
* tests/unit/foundry/FoundryAdapter.test.js
|
||||
*
|
||||
* Story 1.2 — WebRTC spike: FoundryAdapter probe tests.
|
||||
* Story 1.3 — Data layer: constructor(game), surface delegation tests.
|
||||
*
|
||||
* OQ-1 spike result: css-fallback (FoundryVTT v14, 2026-05-21)
|
||||
* track.enabled = false does NOT stop inbound WebRTC bandwidth.
|
||||
@@ -11,7 +12,18 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { FoundryAdapter } from '../../../src/foundry/FoundryAdapter.js';
|
||||
import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
|
||||
import {
|
||||
GAME_STUB,
|
||||
SETTINGS_STUB,
|
||||
SOCKET_STUB,
|
||||
USERS_STUB,
|
||||
SCENES_STUB,
|
||||
HOOKS_STUB,
|
||||
UI_STUB,
|
||||
GM_USER,
|
||||
PLAYER_USER,
|
||||
ACTIVE_SCENE,
|
||||
} from '../../fixtures/foundry-adapter.js';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -184,12 +196,26 @@ describe('FoundryAdapter.buildWebRTCSurface', () => {
|
||||
// ─── Constructor + interface shape parity ─────────────────────────────────────
|
||||
|
||||
describe('FoundryAdapter constructor', () => {
|
||||
it('is side-effect-free — instantiates without accessing game.*', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Hooks', HOOKS_STUB);
|
||||
vi.stubGlobal('ui', UI_STUB);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('is side-effect-free — instantiates without accessing game.* (no game arg)', () => {
|
||||
expect(() => new FoundryAdapter()).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts game as constructor arg and stores it', () => {
|
||||
const adapter = new FoundryAdapter(GAME_STUB);
|
||||
expect(adapter._game).toBe(GAME_STUB);
|
||||
});
|
||||
|
||||
it('has webrtc = null by default (css-fallback path)', () => {
|
||||
const adapter = new FoundryAdapter();
|
||||
const adapter = new FoundryAdapter(GAME_STUB);
|
||||
expect(adapter.webrtc).toBeNull();
|
||||
});
|
||||
|
||||
@@ -199,26 +225,154 @@ describe('FoundryAdapter constructor', () => {
|
||||
expect(typeof FoundryAdapter.SETTING_WEBRTC_MODE).toBe('string');
|
||||
expect(FoundryAdapter.SETTING_WEBRTC_MODE).toBe('webrtcMode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interface shape parity with canonical mock', () => {
|
||||
it('canonical mock webrtc default matches FoundryAdapter default (null)', () => {
|
||||
const mock = createFoundryAdapterMock();
|
||||
const adapter = new FoundryAdapter();
|
||||
expect(mock.webrtc).toBe(adapter.webrtc); // both null
|
||||
});
|
||||
|
||||
it('canonical mock can override webrtc to simulate track-disable surface', () => {
|
||||
const mockSurface = { disableTrack: vi.fn(), enableTrack: vi.fn() };
|
||||
const mock = createFoundryAdapterMock({ webrtc: mockSurface });
|
||||
expect(mock.webrtc).toBe(mockSurface);
|
||||
expect(typeof mock.webrtc.disableTrack).toBe('function');
|
||||
expect(typeof mock.webrtc.enableTrack).toBe('function');
|
||||
});
|
||||
|
||||
it('buildWebRTCSurface returns object with disableTrack and enableTrack', () => {
|
||||
const surface = FoundryAdapter.buildWebRTCSurface(makeGameWebrtc());
|
||||
expect(typeof surface.disableTrack).toBe('function');
|
||||
expect(typeof surface.enableTrack).toBe('function');
|
||||
it('exposes all 6 surfaces: settings, socket, users, scenes, notifications, hooks', () => {
|
||||
const adapter = new FoundryAdapter(GAME_STUB);
|
||||
expect(typeof adapter.settings).toBe('object');
|
||||
expect(typeof adapter.socket).toBe('object');
|
||||
expect(typeof adapter.users).toBe('object');
|
||||
expect(typeof adapter.scenes).toBe('object');
|
||||
expect(typeof adapter.notifications).toBe('object');
|
||||
expect(typeof adapter.hooks).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Surface delegation ────────────────────────────────────────────────────────
|
||||
|
||||
describe('FoundryAdapter surface delegation', () => {
|
||||
let adapter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Hooks', HOOKS_STUB);
|
||||
vi.stubGlobal('ui', UI_STUB);
|
||||
vi.clearAllMocks();
|
||||
adapter = new FoundryAdapter(GAME_STUB);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('settings surface', () => {
|
||||
it('settings.register delegates to game.settings.register with namespace', () => {
|
||||
adapter.settings.register('visibilityMatrix', { default: {} });
|
||||
expect(SETTINGS_STUB.register).toHaveBeenCalledWith('scrying-pool', 'visibilityMatrix', { default: {} });
|
||||
});
|
||||
|
||||
it('settings.get delegates to game.settings.get with namespace', () => {
|
||||
SETTINGS_STUB.get.mockReturnValue({ userId1: 'active' });
|
||||
const result = adapter.settings.get('visibilityMatrix');
|
||||
expect(SETTINGS_STUB.get).toHaveBeenCalledWith('scrying-pool', 'visibilityMatrix');
|
||||
expect(result).toEqual({ userId1: 'active' });
|
||||
});
|
||||
|
||||
it('settings.set delegates to game.settings.set with namespace', async () => {
|
||||
SETTINGS_STUB.set.mockResolvedValue({ userId1: 'hidden' });
|
||||
await adapter.settings.set('visibilityMatrix', { userId1: 'hidden' });
|
||||
expect(SETTINGS_STUB.set).toHaveBeenCalledWith('scrying-pool', 'visibilityMatrix', { userId1: 'hidden' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('socket surface', () => {
|
||||
it('socket.emit delegates to game.socket.emit', () => {
|
||||
adapter.socket.emit('scrying-pool.visibility.set', { opId: 'op-1' });
|
||||
expect(SOCKET_STUB.emit).toHaveBeenCalledWith('scrying-pool.visibility.set', { opId: 'op-1' });
|
||||
});
|
||||
|
||||
it('socket.on delegates to game.socket.on', () => {
|
||||
const handler = vi.fn();
|
||||
adapter.socket.on('scrying-pool.visibility.set', handler);
|
||||
expect(SOCKET_STUB.on).toHaveBeenCalledWith('scrying-pool.visibility.set', handler);
|
||||
});
|
||||
|
||||
it('socket.off delegates to game.socket.off', () => {
|
||||
const handler = vi.fn();
|
||||
adapter.socket.off('scrying-pool.visibility.set', handler);
|
||||
expect(SOCKET_STUB.off).toHaveBeenCalledWith('scrying-pool.visibility.set', handler);
|
||||
});
|
||||
});
|
||||
|
||||
describe('users surface', () => {
|
||||
it('users.get returns user by id', () => {
|
||||
const result = adapter.users.get(GM_USER.id);
|
||||
expect(USERS_STUB.get).toHaveBeenCalledWith(GM_USER.id);
|
||||
expect(result).toBe(GM_USER);
|
||||
});
|
||||
|
||||
it('users.get returns null for unknown id', () => {
|
||||
expect(adapter.users.get('unknown-id')).toBeNull();
|
||||
});
|
||||
|
||||
it('users.all returns array from game.users iteration', () => {
|
||||
const result = adapter.users.all();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toContain(GM_USER);
|
||||
expect(result).toContain(PLAYER_USER);
|
||||
});
|
||||
|
||||
it('users.isGM(id) checks a specific user', () => {
|
||||
expect(adapter.users.isGM(GM_USER.id)).toBe(true);
|
||||
expect(adapter.users.isGM(PLAYER_USER.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('users.isGM() with no arg checks current user', () => {
|
||||
expect(adapter.users.isGM()).toBe(true); // USER_STUB is GM
|
||||
});
|
||||
|
||||
it('users.current() returns game.user', () => {
|
||||
expect(adapter.users.current()).toEqual({ id: GM_USER.id, name: GM_USER.name, isGM: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('scenes surface', () => {
|
||||
it('scenes.current() returns game.scenes.active', () => {
|
||||
expect(adapter.scenes.current()).toBe(ACTIVE_SCENE);
|
||||
});
|
||||
|
||||
it('scenes.get(id) delegates to game.scenes.get', () => {
|
||||
const result = adapter.scenes.get(ACTIVE_SCENE.id);
|
||||
expect(SCENES_STUB.get).toHaveBeenCalledWith(ACTIVE_SCENE.id);
|
||||
expect(result).toBe(ACTIVE_SCENE);
|
||||
});
|
||||
|
||||
it('scenes.get returns null for unknown id', () => {
|
||||
expect(adapter.scenes.get('no-such-scene')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifications surface', () => {
|
||||
it('notifications.info delegates to ui.notifications.info', () => {
|
||||
adapter.notifications.info('Test info');
|
||||
expect(UI_STUB.notifications.info).toHaveBeenCalledWith('Test info');
|
||||
});
|
||||
|
||||
it('notifications.warn delegates to ui.notifications.warn', () => {
|
||||
adapter.notifications.warn('Test warn');
|
||||
expect(UI_STUB.notifications.warn).toHaveBeenCalledWith('Test warn');
|
||||
});
|
||||
|
||||
it('notifications.error delegates to ui.notifications.error', () => {
|
||||
adapter.notifications.error('Test error');
|
||||
expect(UI_STUB.notifications.error).toHaveBeenCalledWith('Test error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooks surface', () => {
|
||||
it('hooks.on delegates to Hooks.on', () => {
|
||||
const handler = vi.fn();
|
||||
adapter.hooks.on('ready', handler);
|
||||
expect(HOOKS_STUB.on).toHaveBeenCalledWith('ready', handler);
|
||||
});
|
||||
|
||||
it('hooks.once delegates to Hooks.once', () => {
|
||||
const handler = vi.fn();
|
||||
adapter.hooks.once('ready', handler);
|
||||
expect(HOOKS_STUB.once).toHaveBeenCalledWith('ready', handler);
|
||||
});
|
||||
|
||||
it('hooks.off delegates to Hooks.off', () => {
|
||||
const handler = vi.fn();
|
||||
adapter.hooks.off('ready', handler);
|
||||
expect(HOOKS_STUB.off).toHaveBeenCalledWith('ready', handler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user