// @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. * Story 5.1 — Full AV Replacement: probeCapability returns 'stream-access', buildWebRTCSurface with full API * * OQ-1 spike result: css-fallback (FoundryVTT v14, 2026-05-21) - SUPERSEDED * track.enabled = false does NOT stop inbound WebRTC bandwidth. * * NEW: probeCapability returns 'stream-access' when getMediaStreamForUser is available * Full AV replacement architecture: hide Foundry dock, show VVM dock with actual video */ 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 "stream-access" when client has getMediaStreamForUser (Story 5.1 - Full AV Replacement)', () => { // getMediaStreamForUser is available → full AV replacement is possible const gameWebrtc = makeGameWebrtc(); expect(FoundryAdapter.probeCapability(gameWebrtc)).toBe('stream-access'); }); }); // ─── buildWebRTCSurface ─────────────────────────────────────────────────────── // Helper to create a full mock WebRTC client with all methods function makeFullWebRTCClient(stream = null) { return { getMediaStreamForUser: vi.fn().mockReturnValue(stream), getConnectedUsers: vi.fn().mockReturnValue(['user-1', 'user-2']), getLevelsStreamForUser: vi.fn().mockReturnValue(stream), isAudioEnabled: vi.fn().mockReturnValue(true), isVideoEnabled: vi.fn().mockReturnValue(true), toggleAudio: vi.fn(), toggleVideo: vi.fn(), toggleBroadcast: vi.fn(), setUserVideo: vi.fn().mockResolvedValue(undefined), }; } function makeFullGameWebrtc(stream = null) { return { client: makeFullWebRTCClient(stream), }; } describe('FoundryAdapter.buildWebRTCSurface', () => { let consoleWarnSpy; let consoleErrorSpy; beforeEach(() => { consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); describe('returns complete WebRTC surface with 11 methods (Story 5.1)', () => { it('returns object with all 11 WebRTC methods', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); expect(surface).toHaveProperty('getMediaStreamForUser'); expect(surface).toHaveProperty('getConnectedUsers'); expect(surface).toHaveProperty('getLevelsStreamForUser'); expect(surface).toHaveProperty('isAudioEnabled'); expect(surface).toHaveProperty('isVideoEnabled'); expect(surface).toHaveProperty('toggleAudio'); expect(surface).toHaveProperty('toggleVideo'); expect(surface).toHaveProperty('toggleBroadcast'); expect(surface).toHaveProperty('setUserVideo'); expect(surface).toHaveProperty('disableTrack'); expect(surface).toHaveProperty('enableTrack'); }); it('getMediaStreamForUser returns stream from client', () => { const mockStream = new MediaStream(); const gameWebrtc = makeFullGameWebrtc(mockStream); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const result = surface.getMediaStreamForUser('user-1'); expect(result).toBe(mockStream); expect(gameWebrtc.client.getMediaStreamForUser).toHaveBeenCalledWith('user-1'); }); it('getMediaStreamForUser returns null for invalid userId', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const result = surface.getMediaStreamForUser(''); expect(result).toBeNull(); expect(consoleWarnSpy).toHaveBeenCalled(); }); it('getMediaStreamForUser returns null when client method throws', () => { const gameWebrtc = { client: { getMediaStreamForUser: vi.fn().mockImplementation(() => { throw new Error('AV error'); }), }, }; const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const result = surface.getMediaStreamForUser('user-1'); expect(result).toBeNull(); expect(consoleErrorSpy).toHaveBeenCalled(); }); it('getConnectedUsers returns array from client', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const result = surface.getConnectedUsers(); expect(result).toEqual(['user-1', 'user-2']); }); it('getConnectedUsers returns empty array on error', () => { const gameWebrtc = { client: { getConnectedUsers: vi.fn().mockImplementation(() => { throw new Error('Connection error'); }), }, }; const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const result = surface.getConnectedUsers(); expect(result).toEqual([]); }); it('isAudioEnabled delegates to client', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const result = surface.isAudioEnabled(); expect(result).toBe(true); expect(gameWebrtc.client.isAudioEnabled).toHaveBeenCalled(); }); it('isVideoEnabled delegates to client', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const result = surface.isVideoEnabled(); expect(result).toBe(true); expect(gameWebrtc.client.isVideoEnabled).toHaveBeenCalled(); }); it('toggleAudio calls client method with boolean', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); surface.toggleAudio(true); expect(gameWebrtc.client.toggleAudio).toHaveBeenCalledWith(true); surface.toggleAudio(false); expect(gameWebrtc.client.toggleAudio).toHaveBeenCalledWith(false); }); it('toggleVideo calls client method with boolean', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); surface.toggleVideo(true); expect(gameWebrtc.client.toggleVideo).toHaveBeenCalledWith(true); }); it('toggleBroadcast calls client method with boolean', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); surface.toggleBroadcast(false); expect(gameWebrtc.client.toggleBroadcast).toHaveBeenCalledWith(false); }); it('setUserVideo calls client method with userId and videoElement', async () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const videoElement = document.createElement('video'); await surface.setUserVideo('user-1', videoElement); expect(gameWebrtc.client.setUserVideo).toHaveBeenCalledWith('user-1', videoElement); }); it('setUserVideo validates userId is string', async () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const videoElement = document.createElement('video'); await surface.setUserVideo(null, videoElement); expect(consoleWarnSpy).toHaveBeenCalled(); expect(gameWebrtc.client.setUserVideo).not.toHaveBeenCalled(); }); it('setUserVideo validates videoElement is HTMLVideoElement', async () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); await surface.setUserVideo('user-1', {}); expect(consoleWarnSpy).toHaveBeenCalled(); expect(gameWebrtc.client.setUserVideo).not.toHaveBeenCalled(); }); }); describe('input validation for userId-taking methods', () => { it('getLevelsStreamForUser warns on empty userId', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); const result = surface.getLevelsStreamForUser(''); expect(result).toBeNull(); expect(consoleWarnSpy).toHaveBeenCalledWith( '[ScryingPool] getLevelsStreamForUser: invalid userId:', '' ); }); it('disableTrack warns on invalid userId', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); surface.disableTrack(null); expect(consoleWarnSpy).toHaveBeenCalled(); }); it('enableTrack warns on invalid userId', () => { const gameWebrtc = makeFullGameWebrtc(); const surface = FoundryAdapter.buildWebRTCSurface(gameWebrtc); surface.enableTrack(123); expect(consoleWarnSpy).toHaveBeenCalled(); }); }); 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('user flag methods', () => { it('users.getFlag returns flag value for valid user, scope, and key', () => { // First set a flag on the GM user GM_USER.setFlag('scrying-pool', 'testFlag', 'testValue'); const result = adapter.users.getFlag(GM_USER.id, 'scrying-pool', 'testFlag'); expect(result).toBe('testValue'); expect(USERS_STUB.get).toHaveBeenCalledWith(GM_USER.id); }); it('users.getFlag returns null when flag does not exist', () => { const result = adapter.users.getFlag(GM_USER.id, 'scrying-pool', 'nonExistentFlag'); expect(result).toBeNull(); }); it('users.getFlag returns null when user does not exist', () => { const result = adapter.users.getFlag('unknown-user-id', 'scrying-pool', 'testFlag'); expect(result).toBeNull(); expect(USERS_STUB.get).toHaveBeenCalledWith('unknown-user-id'); }); it('users.setFlag sets flag value for valid user', async () => { const promise = adapter.users.setFlag(PLAYER_USER.id, 'scrying-pool', 'reactionCamEnabled', true); expect(promise).not.toBeNull(); await promise; expect(USERS_STUB.get).toHaveBeenCalledWith(PLAYER_USER.id); // Verify the flag was set expect(PLAYER_USER.getFlag('scrying-pool', 'reactionCamEnabled')).toBe(true); }); it('users.setFlag returns null when user does not exist', () => { const promise = adapter.users.setFlag('unknown-user-id', 'scrying-pool', 'testFlag', true); expect(promise).toBeNull(); expect(USERS_STUB.get).toHaveBeenCalledWith('unknown-user-id'); }); it('users.getFlagModule returns module-scoped flag', () => { GM_USER.setFlag('scrying-pool', 'reactionCamEnabled', false); const result = adapter.users.getFlagModule(GM_USER.id, 'reactionCamEnabled'); expect(result).toBe(false); }); it('users.getFlagModule returns null when flag does not exist', () => { const result = adapter.users.getFlagModule(GM_USER.id, 'nonExistentFlag'); expect(result).toBeNull(); }); it('users.setFlagModule sets module-scoped flag', async () => { const promise = adapter.users.setFlagModule(PLAYER_USER.id, 'reactionCamEnabled', true); expect(promise).not.toBeNull(); await promise; expect(PLAYER_USER.getFlag('scrying-pool', 'reactionCamEnabled')).toBe(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); }); }); });