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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user