// @ts-nocheck import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Stub foundry globals before importing ScryingPoolStrip. // Do NOT include foundry.applications.api.ApplicationV2 so the fallback class is used. beforeEach(() => { vi.stubGlobal('foundry', { utils: { mergeObject: (base, override) => Object.assign({}, base, override), }, }); vi.stubGlobal('game', { user: { setFlag: vi.fn(), 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(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); import { LABELS, resolveTargetState, buildParticipantList, ScryingPoolStrip, } from '../../../../src/ui/gm/ScryingPoolStrip.js'; describe('LABELS', () => { it('has HIDE_FROM_TABLE equal to exact canonical string', () => { expect(LABELS.HIDE_FROM_TABLE).toBe('Hide from table'); }); it('has SHOW_TO_TABLE equal to exact canonical string', () => { expect(LABELS.SHOW_TO_TABLE).toBe('Show to table'); }); it('has FIRST_TOOLTIP set', () => { expect(LABELS.FIRST_TOOLTIP).toBe('Hide this participant from other players.'); }); it('is frozen (immutable)', () => { expect(Object.isFrozen(LABELS)).toBe(true); }); }); describe('resolveTargetState()', () => { it('returns active when current state is hidden', () => { expect(resolveTargetState('hidden')).toBe('active'); }); it('returns hidden when current state is active', () => { expect(resolveTargetState('active')).toBe('hidden'); }); it('returns hidden when current state is self-muted', () => { expect(resolveTargetState('self-muted')).toBe('hidden'); }); it('returns hidden when current state is cam-lost', () => { expect(resolveTargetState('cam-lost')).toBe('hidden'); }); it('returns hidden when current state is never-connected', () => { expect(resolveTargetState('never-connected')).toBe('hidden'); }); }); describe('buildParticipantList()', () => { let stateStore; let controller; let adapter; beforeEach(() => { stateStore = { getState: vi.fn(userId => userId === 'user-1' ? 'active' : 'hidden'), }; controller = { hasPendingOp: vi.fn(() => false), }; adapter = { users: { get: vi.fn(userId => ({ id: userId, name: `User ${userId}`, avatar: `avatars/${userId}.png`, })), current: vi.fn(() => ({ id: 'user-1' })), }, }; }); it('returns correct shape for each participant', () => { const list = buildParticipantList(['user-1', 'user-2'], stateStore, controller, adapter); expect(list).toHaveLength(2); expect(list[0]).toMatchObject({ userId: 'user-1', name: 'User user-1', avatarSrc: 'avatars/user-1.png', state: 'active', stateLabel: 'Active', hasPendingOp: false, isCurrentUser: true, }); }); it('returns isEmpty-compatible empty array for no userIds', () => { const list = buildParticipantList([], stateStore, controller, adapter); expect(list).toHaveLength(0); }); it('sets hasPendingOp to true when controller reports pending', () => { controller.hasPendingOp.mockReturnValue(true); const list = buildParticipantList(['user-1'], stateStore, controller, adapter); expect(list[0].hasPendingOp).toBe(true); }); it('uses mystery-man.svg fallback when avatar is null', () => { adapter.users.get.mockReturnValue({ id: 'user-1', name: 'Alice', avatar: null }); const list = buildParticipantList(['user-1'], stateStore, controller, adapter); expect(list[0].avatarSrc).toBe('icons/svg/mystery-man.svg'); }); it('marks only the current user as isCurrentUser', () => { const list = buildParticipantList(['user-1', 'user-2'], stateStore, controller, adapter); expect(list[0].isCurrentUser).toBe(true); expect(list[1].isCurrentUser).toBe(false); }); it('correctly maps hidden state label', () => { 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', () => { let stateStore; let controller; let avTileAdapter; let adapter; let strip; beforeEach(() => { stateStore = { getState: vi.fn(() => 'active') }; controller = { action: vi.fn(), getRevision: vi.fn(() => 0), hasPendingOp: vi.fn(() => false) }; avTileAdapter = { mount: vi.fn(), unmount: vi.fn(), setStateClass: vi.fn(), disconnect: vi.fn() }; adapter = { users: { get: vi.fn(() => ({ id: 'u1', name: 'Alice', avatar: 'av.png' })), all: vi.fn(() => [{ id: 'u1' }]), current: vi.fn(() => ({ id: 'u1' })), }, }; strip = new ScryingPoolStrip(stateStore, controller, avTileAdapter, adapter); }); describe('DEFAULT_OPTIONS', () => { it('has correct id', () => { expect(ScryingPoolStrip.DEFAULT_OPTIONS.id).toBe('scrying-pool-strip'); }); it('has correct template path', () => { expect(ScryingPoolStrip.PARTS.strip.template).toContain('roster-strip.hbs'); }); it('is not resizable', () => { expect(ScryingPoolStrip.DEFAULT_OPTIONS.window?.resizable).toBe(false); }); it('has title set', () => { expect(ScryingPoolStrip.DEFAULT_OPTIONS.window?.title).toBe('Scrying Pool'); }); }); describe('_prepareContext()', () => { it('returns participants array', async () => { const data = await strip._prepareContext({}); expect(Array.isArray(data.participants)).toBe(true); }); it('returns isExpanded property', async () => { const data = await strip._prepareContext({}); expect(typeof data.isExpanded).toBe('boolean'); }); it('returns isEmpty true when no participants', async () => { adapter.users.all.mockReturnValue([]); const data = await strip._prepareContext({}); expect(data.isEmpty).toBe(true); }); it('returns isEmpty false when participants exist', async () => { const data = await strip._prepareContext({}); expect(data.isEmpty).toBe(false); }); it('returns hasStreamAccess true when webrtc.getMediaStreamForUser is available (Story 5.1)', async () => { adapter.webrtc = { getMediaStreamForUser: vi.fn(), }; const data = await strip._prepareContext({}); expect(data.hasStreamAccess).toBe(true); }); it('returns hasStreamAccess false when webrtc is null (Story 5.1)', async () => { adapter.webrtc = null; const data = await strip._prepareContext({}); expect(data.hasStreamAccess).toBe(false); }); it('returns hasStreamAccess false when webrtc has no getMediaStreamForUser (Story 5.1)', async () => { adapter.webrtc = {}; const data = await strip._prepareContext({}); expect(data.hasStreamAccess).toBe(false); }); it('includes current user when showGMSelfFeed is true', async () => { adapter.settings = { get: vi.fn(() => true) }; adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); const data = await strip._prepareContext({}); expect(data.participants.map(p => p.userId)).toContain('u1'); expect(data.participants.map(p => p.userId)).toContain('u2'); }); it('excludes current user when showGMSelfFeed is false', async () => { adapter.settings = { get: vi.fn(() => false) }; adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); const data = await strip._prepareContext({}); // u1 is the current user (mocked in beforeEach), should be excluded expect(data.participants.map(p => p.userId)).not.toContain('u1'); expect(data.participants.map(p => p.userId)).toContain('u2'); }); it('includes all users when settings is unavailable (defaults to true)', async () => { // no adapter.settings — fallback to true adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); const data = await strip._prepareContext({}); expect(data.participants.length).toBe(2); }); it('excludes hidden participants from the strip to keep it compact', async () => { stateStore.getState.mockImplementation(id => id === 'u1' ? 'hidden' : 'active'); adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); const data = await strip._prepareContext({}); expect(data.participants.map(p => p.userId)).not.toContain('u1'); expect(data.participants.map(p => p.userId)).toContain('u2'); }); it('returns isEmpty true when all participants are hidden', async () => { stateStore.getState.mockReturnValue('hidden'); adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]); const data = await strip._prepareContext({}); expect(data.isEmpty).toBe(true); expect(data.participants.length).toBe(0); }); it('returns dockLayout from settings (defaults to vertical-sm when setting is not a string)', async () => { // dockLayout non-string → fallback to vertical-sm; no sizeOverride → canonical size 'sm' adapter.settings = { get: vi.fn(key => key === 'dockLayoutExpanded' ? '' : true) }; const data = await strip._prepareContext({}); expect(data.dockLayout).toBe('vertical-sm'); }); it('returns dockLayout from settings when setting is a valid string', async () => { // No size override → canonical size from world setting ('md') is used adapter.settings = { get: vi.fn(key => key === 'dockLayout' ? 'horizontal-md' : '') }; const data = await strip._prepareContext({}); expect(data.dockLayout).toBe('horizontal-md'); expect(data.isExpanded).toBe(false); expect(data.showName).toBe(true); }); it('client size override forces sm even when world layout is md', async () => { adapter.settings = { get: vi.fn(key => key === 'dockLayout' ? 'horizontal-md' : key === 'dockLayoutExpanded' ? 'sm' : '') }; const data = await strip._prepareContext({}); expect(data.dockLayout).toBe('horizontal-sm'); expect(data.showName).toBe(false); }); it('client size override forces md even when world layout is sm', async () => { adapter.settings = { get: vi.fn(key => key === 'dockLayout' ? 'vertical-sm' : key === 'dockLayoutExpanded' ? 'md' : '') }; const data = await strip._prepareContext({}); expect(data.dockLayout).toBe('vertical-md'); expect(data.isExpanded).toBe(true); }); it('sets isExpanded=true and showName=true only for vertical-md', async () => { adapter.settings = { get: vi.fn(key => key === 'dockLayout' ? 'vertical-md' : '') }; const data = await strip._prepareContext({}); expect(data.isExpanded).toBe(true); expect(data.showName).toBe(true); }); }); describe('_computeStripWidth()', () => { it('returns 85 for vertical-sm', () => { expect(strip._computeStripWidth('vertical-sm', 3)).toBe(85); }); it('returns 242 for vertical-md', () => { expect(strip._computeStripWidth('vertical-md', 3)).toBe(242); }); it('returns 85 for unknown layout', () => { expect(strip._computeStripWidth('unknown', 3)).toBe(85); }); it('scales horizontal-sm width with participant count (max 4 cols)', () => { const w2 = strip._computeStripWidth('horizontal-sm', 2); const w4 = strip._computeStripWidth('horizontal-sm', 4); const w6 = strip._computeStripWidth('horizontal-sm', 6); expect(w4).toBeGreaterThan(w2); expect(w6).toBe(w4); // capped at 4 cols }); it('horizontal-md tiles are wider than horizontal-sm', () => { const wSm = strip._computeStripWidth('horizontal-sm', 4); const wMd = strip._computeStripWidth('horizontal-md', 4); expect(wMd).toBeGreaterThan(wSm); }); }); describe('_computeStripHeight()', () => { const CHROME = 16 + 29; // grip + toolbar (29 = 28px content + 1px border-bottom) const BORDER_H = 2, GAP = 4, TILE_PAD = 8; it('returns auto for vertical layouts', () => { expect(strip._computeStripHeight('vertical-sm', 3)).toBe('auto'); expect(strip._computeStripHeight('vertical-md', 3)).toBe('auto'); expect(strip._computeStripHeight('unknown', 3)).toBe('auto'); }); it('horizontal-sm: 1 row for ≤4 participants', () => { // 4 tiles, 1 row: CHROME + 83 + 8pad + 2border = 138 expect(strip._computeStripHeight('horizontal-sm', 4)).toBe(CHROME + 83 + TILE_PAD + BORDER_H); }); it('horizontal-sm: 2 rows for 5+ participants', () => { // 5 tiles → cols=4, rows=2: CHROME + 2*83 + 1*4 + 8 + 2 = 225 expect(strip._computeStripHeight('horizontal-sm', 5)).toBe(CHROME + 2 * 83 + GAP + TILE_PAD + BORDER_H); }); it('horizontal-md tiles produce taller rows than horizontal-sm', () => { const hSm = strip._computeStripHeight('horizontal-sm', 4); const hMd = strip._computeStripHeight('horizontal-md', 4); expect(hMd).toBeGreaterThan(hSm); }); it('mosaic-sm: NxN grid', () => { // n=4 → cols=2, rows=2: CHROME + 2*83 + 1*4 + 8 + 2 = 225 expect(strip._computeStripHeight('mosaic-sm', 4)).toBe(CHROME + 2 * 83 + GAP + TILE_PAD + BORDER_H); }); it('mosaic height grows with participant count', () => { const h4 = strip._computeStripHeight('mosaic-sm', 4); const h9 = strip._computeStripHeight('mosaic-sm', 9); expect(h9).toBeGreaterThan(h4); }); }); 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(); }); }); });