5ba7717ecd
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>
379 lines
14 KiB
JavaScript
379 lines
14 KiB
JavaScript
// @ts-nocheck
|
|
/**
|
|
* 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.
|
|
* Probe returns 'css-fallback' when AVClient is available, 'unsupported' otherwise.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { FoundryAdapter } from '../../../src/foundry/FoundryAdapter.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 ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns a minimal mock game.webrtc object whose client exposes getMediaStreamForUser.
|
|
* @param {MediaStream|null} [stream=null]
|
|
* @returns {{ client: { getMediaStreamForUser: import('vitest').MockedFunction<() => MediaStream|null> } }}
|
|
*/
|
|
function makeGameWebrtc(stream = null) {
|
|
return {
|
|
client: {
|
|
getMediaStreamForUser: vi.fn().mockReturnValue(stream),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns a minimal MediaStream mock with one video track.
|
|
* @param {boolean} [enabled=true]
|
|
* @returns {{ getVideoTracks: import('vitest').MockedFunction<() => object[]>, _track: { enabled: boolean, kind: string } }}
|
|
*/
|
|
function makeStream(enabled = true) {
|
|
const track = { enabled, kind: 'video' };
|
|
return {
|
|
getVideoTracks: vi.fn().mockReturnValue([track]),
|
|
_track: track,
|
|
};
|
|
}
|
|
|
|
// ─── probeCapability ─────────────────────────────────────────────────────────
|
|
|
|
describe('FoundryAdapter.probeCapability', () => {
|
|
it('returns "unsupported" when gameWebrtc is null', () => {
|
|
expect(FoundryAdapter.probeCapability(null)).toBe('unsupported');
|
|
});
|
|
|
|
it('returns "unsupported" when gameWebrtc is undefined', () => {
|
|
expect(FoundryAdapter.probeCapability(undefined)).toBe('unsupported');
|
|
});
|
|
|
|
it('returns "unsupported" when gameWebrtc has no client', () => {
|
|
expect(FoundryAdapter.probeCapability({})).toBe('unsupported');
|
|
});
|
|
|
|
it('returns "unsupported" when client lacks getMediaStreamForUser', () => {
|
|
expect(FoundryAdapter.probeCapability({ client: {} })).toBe('unsupported');
|
|
});
|
|
|
|
it('returns "css-fallback" when client has getMediaStreamForUser (OQ-1 spike result)', () => {
|
|
// track.enabled = false does NOT stop bandwidth → probe returns css-fallback, not track-disable
|
|
const gameWebrtc = makeGameWebrtc();
|
|
expect(FoundryAdapter.probeCapability(gameWebrtc)).toBe('css-fallback');
|
|
});
|
|
});
|
|
|
|
// ─── buildWebRTCSurface ───────────────────────────────────────────────────────
|
|
|
|
describe('FoundryAdapter.buildWebRTCSurface', () => {
|
|
let consoleWarnSpy;
|
|
let consoleErrorSpy;
|
|
|
|
beforeEach(() => {
|
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('disableTrack', () => {
|
|
it('sets track.enabled = false on all video tracks for the user', () => {
|
|
const stream = makeStream(true);
|
|
const gameWebrtc = makeGameWebrtc(stream);
|
|
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
|
|
|
|
surface.disableTrack('user-123');
|
|
|
|
expect(gameWebrtc.client.getMediaStreamForUser).toHaveBeenCalledWith('user-123');
|
|
expect(stream._track.enabled).toBe(false);
|
|
});
|
|
|
|
it('logs [ScryingPool] warning when no video tracks found (stream has none)', () => {
|
|
const emptyStream = { getVideoTracks: vi.fn().mockReturnValue([]) };
|
|
const gameWebrtc = makeGameWebrtc(emptyStream);
|
|
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
|
|
|
|
surface.disableTrack('user-456');
|
|
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
'[ScryingPool] disableTrack: no video tracks found for',
|
|
'user-456',
|
|
);
|
|
});
|
|
|
|
it('logs [ScryingPool] warning when getMediaStreamForUser returns null', () => {
|
|
const gameWebrtc = makeGameWebrtc(null);
|
|
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
|
|
|
|
surface.disableTrack('user-789');
|
|
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
'[ScryingPool] disableTrack: no video tracks found for',
|
|
'user-789',
|
|
);
|
|
});
|
|
|
|
it('catches errors and logs [ScryingPool] error without throwing', () => {
|
|
const gameWebrtc = {
|
|
client: {
|
|
getMediaStreamForUser: vi.fn().mockImplementation(() => {
|
|
throw new Error('AV backend unavailable');
|
|
}),
|
|
},
|
|
};
|
|
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
|
|
|
|
expect(() => surface.disableTrack('user-err')).not.toThrow();
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'[ScryingPool] disableTrack failed:',
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('enableTrack', () => {
|
|
it('sets track.enabled = true on all video tracks for the user', () => {
|
|
const stream = makeStream(false);
|
|
const gameWebrtc = makeGameWebrtc(stream);
|
|
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
|
|
|
|
surface.enableTrack('user-123');
|
|
|
|
expect(gameWebrtc.client.getMediaStreamForUser).toHaveBeenCalledWith('user-123');
|
|
expect(stream._track.enabled).toBe(true);
|
|
});
|
|
|
|
it('logs [ScryingPool] warning when no video tracks found', () => {
|
|
const emptyStream = { getVideoTracks: vi.fn().mockReturnValue([]) };
|
|
const gameWebrtc = makeGameWebrtc(emptyStream);
|
|
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
|
|
|
|
surface.enableTrack('user-456');
|
|
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
'[ScryingPool] enableTrack: no video tracks found for',
|
|
'user-456',
|
|
);
|
|
});
|
|
|
|
it('catches errors and logs [ScryingPool] error without throwing', () => {
|
|
const gameWebrtc = {
|
|
client: {
|
|
getMediaStreamForUser: vi.fn().mockImplementation(() => {
|
|
throw new Error('AV backend unavailable');
|
|
}),
|
|
},
|
|
};
|
|
const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc);
|
|
|
|
expect(() => surface.enableTrack('user-err')).not.toThrow();
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'[ScryingPool] enableTrack failed:',
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Constructor + interface shape parity ─────────────────────────────────────
|
|
|
|
describe('FoundryAdapter constructor', () => {
|
|
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(GAME_STUB);
|
|
expect(adapter.webrtc).toBeNull();
|
|
});
|
|
|
|
it('exposes SETTINGS_NS and SETTING_WEBRTC_MODE static constants', () => {
|
|
expect(typeof FoundryAdapter.SETTINGS_NS).toBe('string');
|
|
expect(FoundryAdapter.SETTINGS_NS).toBe('scrying-pool');
|
|
expect(typeof FoundryAdapter.SETTING_WEBRTC_MODE).toBe('string');
|
|
expect(FoundryAdapter.SETTING_WEBRTC_MODE).toBe('webrtcMode');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|