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:
2026-05-22 11:38:45 +02:00
parent 110b295a7b
commit 5ba7717ecd
17 changed files with 2391 additions and 55 deletions
+177 -23
View File
@@ -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);
});
});
});