diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index e5fee26..ff26af1 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -74,4 +74,4 @@ development_status: # Epic 5: Full AV Replacement epic-5: in-progress - 5-1-full-av-replacement: ready-for-dev + 5-1-full-av-replacement: done diff --git a/tests/unit/foundry/FoundryAdapter.test.js b/tests/unit/foundry/FoundryAdapter.test.js index 0e7ac5f..f9c1971 100644 --- a/tests/unit/foundry/FoundryAdapter.test.js +++ b/tests/unit/foundry/FoundryAdapter.test.js @@ -4,10 +4,13 @@ * * 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) + * OQ-1 spike result: css-fallback (FoundryVTT v14, 2026-05-21) - SUPERSEDED * track.enabled = false does NOT stop inbound WebRTC bandwidth. - * Probe returns 'css-fallback' when AVClient is available, 'unsupported' otherwise. + * + * 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'; @@ -72,15 +75,36 @@ describe('FoundryAdapter.probeCapability', () => { 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 + 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('css-fallback'); + 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; @@ -94,6 +118,184 @@ describe('FoundryAdapter.buildWebRTCSurface', () => { 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); diff --git a/tests/unit/ui/gm/ScryingPoolStrip.test.js b/tests/unit/ui/gm/ScryingPoolStrip.test.js index 3988612..f7ad849 100644 --- a/tests/unit/ui/gm/ScryingPoolStrip.test.js +++ b/tests/unit/ui/gm/ScryingPoolStrip.test.js @@ -21,6 +21,10 @@ beforeEach(() => { getFlag: vi.fn(() => null), }, }); + vi.stubGlobal('document', { + createElement: vi.fn(tag => ({ tag, srcObject: null, autoplay: false, playsInline: false, muted: false, className: '', addEventListener: vi.fn(), remove: vi.fn() })), + querySelectorAll: vi.fn(() => []), + }); }); afterEach(() => { @@ -140,6 +144,21 @@ describe('buildParticipantList()', () => { const list = buildParticipantList(['user-2'], stateStore, controller, adapter); expect(list[0].stateLabel).toBe('Hidden'); }); + + it('includes hasStreamAccess flag when passed as true (Story 5.1)', () => { + const list = buildParticipantList(['user-1'], stateStore, controller, adapter, true); + expect(list[0].hasStreamAccess).toBe(true); + }); + + it('includes hasStreamAccess flag when passed as false (Story 5.1)', () => { + const list = buildParticipantList(['user-1'], stateStore, controller, adapter, false); + expect(list[0].hasStreamAccess).toBe(false); + }); + + it('defaults hasStreamAccess to false when not provided (Story 5.1)', () => { + const list = buildParticipantList(['user-1'], stateStore, controller, adapter); + expect(list[0].hasStreamAccess).toBe(false); + }); }); describe('ScryingPoolStrip', () => { @@ -202,5 +221,155 @@ describe('ScryingPoolStrip', () => { const data = strip.getData(); expect(data.isEmpty).toBe(false); }); + + it('returns hasStreamAccess true when webrtc.getMediaStreamForUser is available (Story 5.1)', () => { + adapter.webrtc = { + getMediaStreamForUser: vi.fn(), + }; + const data = strip.getData(); + expect(data.hasStreamAccess).toBe(true); + }); + + it('returns hasStreamAccess false when webrtc is null (Story 5.1)', () => { + adapter.webrtc = null; + const data = strip.getData(); + expect(data.hasStreamAccess).toBe(false); + }); + + it('returns hasStreamAccess false when webrtc has no getMediaStreamForUser (Story 5.1)', () => { + adapter.webrtc = {}; + const data = strip.getData(); + expect(data.hasStreamAccess).toBe(false); + }); + }); + + describe('_attachVideoStream() (Story 5.1)', () => { + let mockVideoContainer; + let consoleWarnSpy; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockVideoContainer = { + querySelector: vi.fn(), + appendChild: vi.fn(), + }; + adapter.webrtc = { + getMediaStreamForUser: vi.fn().mockReturnValue(new MediaStream()), + }; + vi.spyOn(document, 'createElement').mockReturnValue({ + srcObject: null, + autoplay: false, + playsInline: false, + muted: false, + className: '', + addEventListener: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates video element with stream (Story 5.1)', () => { + const participantItem = { + querySelector: vi.fn().mockReturnValue(mockVideoContainer), + }; + + // Call the method directly + strip._attachVideoStream('user-1', participantItem); + + // Check that getMediaStreamForUser was called + expect(adapter.webrtc.getMediaStreamForUser).toHaveBeenCalledWith('user-1'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('returns early and does not warn when webrtc is not available (Story 5.1)', () => { + adapter.webrtc = null; + const participantItem = { querySelector: vi.fn() }; + + strip._attachVideoStream('user-1', participantItem); + + // Should return early without warning since webrtc check happens first + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(participantItem.querySelector).not.toHaveBeenCalled(); + }); + + it('warns when no video container found (Story 5.1)', () => { + const participantItem = { + querySelector: vi.fn().mockReturnValue(null), + }; + + strip._attachVideoStream('user-1', participantItem); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[ScryingPool] No video container found for user:', + 'user-1' + ); + }); + }); + + describe('_cleanupVideoStreams() (Story 5.1)', () => { + let consoleWarnSpy; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('handles undefined document gracefully', () => { + const originalDocument = global.document; + vi.stubGlobal('document', undefined); + + expect(() => strip._cleanupVideoStreams()).not.toThrow(); + + vi.stubGlobal('document', originalDocument); + }); + + it('removes video elements and cleans up streams', () => { + const mockStream = new MediaStream(); + const mockTrack = { stop: vi.fn() }; + const mockVideoElement = { + srcObject: mockStream, + remove: vi.fn(), + }; + // Add getTracks method to the mock stream + mockStream.getTracks = vi.fn().mockReturnValue([mockTrack]); + + vi.stubGlobal('document', { + querySelectorAll: vi.fn().mockReturnValue([mockVideoElement]), + }); + + strip._cleanupVideoStreams(); + + expect(mockStream.getTracks).toHaveBeenCalled(); + expect(mockTrack.stop).toHaveBeenCalled(); + expect(mockVideoElement.srcObject).toBe(null); + expect(mockVideoElement.remove).toHaveBeenCalled(); + }); + + it('handles video elements without streams gracefully', () => { + const mockVideoElement = { + srcObject: null, + remove: vi.fn(), + }; + + vi.stubGlobal('document', { + querySelectorAll: vi.fn().mockReturnValue([mockVideoElement]), + }); + + expect(() => strip._cleanupVideoStreams()).not.toThrow(); + expect(mockVideoElement.remove).toHaveBeenCalled(); + }); + + it('handles empty video element list', () => { + vi.stubGlobal('document', { + querySelectorAll: vi.fn().mockReturnValue([]), + }); + + expect(() => strip._cleanupVideoStreams()).not.toThrow(); + }); }); });