// @ts-nocheck import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TokenVideoOverlay } from '../../../../src/ui/shared/TokenVideoOverlay.js'; import { createFoundryAdapterMock } from '../../../helpers/foundryAdapterMock.js'; describe('TokenVideoOverlay', () => { let adapter; let overlay; let mockToken; let rafCallbacks; beforeEach(() => { rafCallbacks = []; let rafIdCounter = 0; vi.stubGlobal('PIXI', { Texture: { from: vi.fn(() => { const tex = { baseTexture: { update: vi.fn() } }; return tex; }), }, }); vi.stubGlobal('requestAnimationFrame', (cb) => { rafCallbacks.push(cb); return ++rafIdCounter; }); vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.stubGlobal('Hooks', { on: vi.fn(), once: vi.fn() }); globalThis.canvas = { scene: { id: 'scene-1' }, tokens: { placeables: [], get: vi.fn() }, }; globalThis.CONST = { DOCUMENT_PERMISSION_LEVELS: { OWNER: 3 } }; globalThis.foundry = { canvas: { placeables: { Token: class {} } }, }; HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve()); // happy-dom rejects non-MediaStream srcObject values Object.defineProperty(HTMLVideoElement.prototype, 'srcObject', { writable: true, value: null, }); const mockStream = { getVideoTracks: vi.fn(() => [{ enabled: true }]), }; adapter = createFoundryAdapterMock({ webrtc: { getMediaStreamForUser: vi.fn(() => mockStream), }, settings: { get: vi.fn((key) => { if (key === 'userActorMapping') return {}; if (key === 'showVideoOnTokens') return true; return null; }), }, users: { all: vi.fn(() => [{ id: 'user-1', isGM: false, active: true }]), get: vi.fn(() => ({ id: 'user-1', isGM: false, active: true })), }, }); mockToken = { id: 'token-1', document: { actor: { id: 'actor-1', testUserPermission: vi.fn(() => true), }, }, mesh: { width: 100, height: 100, texture: 'original-tex' }, }; overlay = new TokenVideoOverlay(adapter); }); afterEach(() => { vi.unstubAllGlobals(); }); const tickRAF = () => { const cb = rafCallbacks.shift(); if (cb) cb(); }; describe('constructor', () => { it('stores adapter reference without side effects', () => { expect(overlay._adapter).toBe(adapter); expect(overlay._overlays).toBeInstanceOf(Map); expect(overlay._overlays.size).toBe(0); expect(overlay._pending).toBeInstanceOf(Set); expect(overlay._pending.size).toBe(0); expect(overlay._origRefresh).toBeNull(); expect(overlay._enabled).toBe(false); }); }); describe('_attach() — canvas 2D fallback', () => { // happy-dom getContext('2d') returns null — fallback path beforeEach(() => { overlay._enabled = true; }); it('falls back to direct video texture when canvas 2D unavailable', () => { overlay._attach(mockToken); tickRAF(); expect(overlay._overlays.size).toBe(1); const data = overlay._overlays.get('scene-1.token-1'); expect(data.canvas).toBeUndefined(); expect(data.ctx).toBeUndefined(); expect(data.pixiTexture).toBeUndefined(); expect(data.origTexture).toBe('original-tex'); expect(data.userId).toBe('user-1'); expect(data.videoEl).toBeInstanceOf(HTMLVideoElement); expect(PIXI.Texture.from).toHaveBeenCalled(); }); it('does not create overlay when token has no mesh', () => { const badToken = { id: 'bad', document: { actor: { id: 'a1' } } }; overlay._attach(badToken); tickRAF(); expect(overlay._overlays.size).toBe(0); }); it('clears pending when attach completes', () => { overlay._attach(mockToken); expect(overlay._pending.has('scene-1.token-1')).toBe(true); tickRAF(); expect(overlay._pending.has('scene-1.token-1')).toBe(false); }); }); describe('_attach() — circular canvas path', () => { let mockCtx; beforeEach(() => { overlay._enabled = true; mockCtx = { clearRect: vi.fn(), save: vi.fn(), beginPath: vi.fn(), arc: vi.fn(), clip: vi.fn(), drawImage: vi.fn(), restore: vi.fn(), }; vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockCtx); }); it('creates canvas sized to mesh dimensions', () => { overlay._attach(mockToken); tickRAF(); const data = overlay._overlays.get('scene-1.token-1'); expect(data.canvas).toBeInstanceOf(HTMLCanvasElement); expect(data.canvas.width).toBe(100); expect(data.canvas.height).toBe(100); expect(data.ctx).toBe(mockCtx); expect(data.rafId).toBeTypeOf('number'); expect(data.pixiTexture).toBeDefined(); }); it('sets mesh texture to canvas-based PIXI texture', () => { overlay._attach(mockToken); tickRAF(); const data = overlay._overlays.get('scene-1.token-1'); expect(mockToken.mesh.texture).toBe(data.pixiTexture); expect(mockToken.mesh.texture).not.toBe('original-tex'); }); it('runs render loop drawing circular clip each frame', () => { overlay._attach(mockToken); tickRAF(); const data = overlay._overlays.get('scene-1.token-1'); const firstRafId = data.rafId; tickRAF(); expect(mockCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100); expect(mockCtx.save).toHaveBeenCalled(); expect(mockCtx.beginPath).toHaveBeenCalled(); expect(mockCtx.arc).toHaveBeenCalledWith(50, 50, 50, 0, Math.PI * 2); expect(mockCtx.clip).toHaveBeenCalled(); expect(mockCtx.drawImage).toHaveBeenCalled(); expect(mockCtx.restore).toHaveBeenCalled(); expect(data.pixiTexture.baseTexture.update).toHaveBeenCalled(); expect(data.rafId).not.toBe(firstRafId); }); it('uses min dimension radius for non-square tokens', () => { mockToken.mesh.width = 200; mockToken.mesh.height = 100; overlay._attach(mockToken); tickRAF(); tickRAF(); expect(mockCtx.arc).toHaveBeenCalledWith(100, 50, 50, 0, Math.PI * 2); }); it('guards against zero-dimension mesh', () => { mockToken.mesh.width = 0; mockToken.mesh.height = 0; overlay._attach(mockToken); tickRAF(); const data = overlay._overlays.get('scene-1.token-1'); expect(data.canvas.width).toBe(1); expect(data.canvas.height).toBe(1); }); it('stops render loop when overlay is removed from map', () => { overlay._attach(mockToken); tickRAF(); overlay._overlays.delete('scene-1.token-1'); rafCallbacks.length = 0; tickRAF(); expect(mockCtx.clearRect).not.toHaveBeenCalled(); }); it('stops render loop when disabled', () => { overlay._attach(mockToken); tickRAF(); overlay._enabled = false; rafCallbacks.length = 0; tickRAF(); expect(mockCtx.clearRect).not.toHaveBeenCalled(); }); }); describe('_detachByKey()', () => { beforeEach(() => { overlay._overlays.set('scene-1.token-1', { videoEl: document.createElement('video'), origTexture: 'original-tex', userId: 'user-1', canvas: document.createElement('canvas'), pixiTexture: { baseTexture: { update: vi.fn() } }, rafId: 42, }); globalThis.canvas.tokens.get = vi.fn(() => mockToken); }); it('cancels rAF loop', () => { overlay._detachByKey('scene-1.token-1'); expect(cancelAnimationFrame).toHaveBeenCalledWith(42); }); it('restores original texture on mesh', () => { mockToken.mesh.texture = 'overwritten-by-something'; overlay._detachByKey('scene-1.token-1'); expect(mockToken.mesh.texture).toBe('original-tex'); }); it('removes video element from DOM', () => { const data = overlay._overlays.get('scene-1.token-1'); document.body.appendChild(data.videoEl); overlay._detachByKey('scene-1.token-1'); expect(document.body.contains(data.videoEl)).toBe(false); }); it('removes overlay from map', () => { overlay._detachByKey('scene-1.token-1'); expect(overlay._overlays.has('scene-1.token-1')).toBe(false); }); it('is no-op for unknown key', () => { expect(() => overlay._detachByKey('unknown')).not.toThrow(); }); it('is idempotent', () => { overlay._detachByKey('scene-1.token-1'); overlay._detachByKey('scene-1.token-1'); expect(cancelAnimationFrame).toHaveBeenCalledTimes(1); }); }); describe('_onTokenRefreshed()', () => { let mockPixiTex; beforeEach(() => { overlay._enabled = true; mockPixiTex = { baseTexture: { update: vi.fn() } }; const c = document.createElement('canvas'); c.width = 100; c.height = 100; overlay._overlays.set('scene-1.token-1', { videoEl: document.createElement('video'), origTexture: 'original-tex', userId: 'user-1', canvas: c, ctx: { clearRect: vi.fn() }, pixiTexture: mockPixiTex, rafId: 42, }); globalThis.canvas.tokens.get = vi.fn(() => mockToken); }); it('re-applies pixiTexture when mesh texture was lost', () => { mockToken.mesh.texture = 'wrong-texture'; overlay._onTokenRefreshed(mockToken); expect(mockToken.mesh.texture).toBe(mockPixiTex); }); it('detects mesh resize and re-attaches', () => { vi.spyOn(overlay, '_detachByKey'); vi.spyOn(overlay, '_attach'); mockToken.mesh.width = 200; mockToken.mesh.height = 200; overlay._onTokenRefreshed(mockToken); expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1'); expect(overlay._attach).toHaveBeenCalledWith(mockToken); }); it('does nothing when mesh size and texture match', () => { vi.spyOn(overlay, '_detachByKey'); vi.spyOn(overlay, '_attach'); mockToken.mesh.texture = mockPixiTex; overlay._onTokenRefreshed(mockToken); expect(overlay._detachByKey).not.toHaveBeenCalled(); expect(overlay._attach).not.toHaveBeenCalled(); }); it('uses fallback video path for non-canvas overlays', () => { overlay._overlays.set('scene-1.token-2', { videoEl: document.createElement('video'), origTexture: 'original-tex-2', userId: 'user-2', }); const token2 = { id: 'token-2', document: mockToken.document, mesh: { width: 100, height: 100, texture: 'wrong' }, }; overlay._onTokenRefreshed(token2); expect(PIXI.Texture.from).toHaveBeenCalled(); }); it('attaches when overlay missing and not pending', () => { overlay._overlays.clear(); vi.spyOn(overlay, '_attach'); overlay._onTokenRefreshed(mockToken); expect(overlay._attach).toHaveBeenCalledWith(mockToken); }); it('skips attach when token is pending', () => { overlay._overlays.clear(); overlay._pending.add('scene-1.token-1'); vi.spyOn(overlay, '_attach'); overlay._onTokenRefreshed(mockToken); expect(overlay._attach).not.toHaveBeenCalled(); }); }); describe('_onUpdateToken()', () => { let mockPixiTex; beforeEach(() => { overlay._enabled = true; mockPixiTex = { baseTexture: { update: vi.fn() } }; overlay._overlays.set('scene-1.token-1', { videoEl: document.createElement('video'), origTexture: 'original-tex', userId: 'user-1', canvas: document.createElement('canvas'), pixiTexture: mockPixiTex, rafId: 42, }); globalThis.canvas.tokens.get = vi.fn(() => mockToken); }); it('re-applies pixiTexture when mesh lost it', () => { mockToken.mesh.texture = 'wrong-texture'; overlay._onUpdateToken(mockToken.document); expect(mockToken.mesh.texture).toBe(mockPixiTex); }); it('detaches when token loses user', () => { adapter.users.all = vi.fn(() => []); overlay._onUpdateToken(mockToken.document); expect(overlay._overlays.has('scene-1.token-1')).toBe(false); }); it('detaches old and re-attaches when user changes', () => { overlay._overlays.set('scene-1.token-1', { videoEl: document.createElement('video'), origTexture: 'original-tex', userId: 'old-user', }); vi.spyOn(overlay, '_detachByKey'); vi.spyOn(overlay, '_attach'); overlay._onUpdateToken(mockToken.document); expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1'); expect(overlay._attach).toHaveBeenCalledWith(mockToken); }); it('is no-op when token not on canvas', () => { globalThis.canvas.tokens.get = vi.fn(() => null); expect(() => overlay._onUpdateToken(mockToken.document)).not.toThrow(); }); }); describe('enable/disable', () => { it('enable sets _enabled and calls syncAll', () => { vi.spyOn(overlay, 'syncAll'); overlay.enable(); expect(overlay._enabled).toBe(true); expect(overlay.syncAll).toHaveBeenCalled(); }); it('disable sets _enabled and calls _cleanupAll', () => { vi.spyOn(overlay, '_cleanupAll'); overlay.enable(); overlay.disable(); expect(overlay._enabled).toBe(false); expect(overlay._cleanupAll).toHaveBeenCalled(); }); }); describe('_cleanupAll()', () => { it('detaches all overlays', () => { vi.spyOn(overlay, '_detachByKey'); overlay._overlays.set('scene-1.token-1', { origTexture: 't1' }); overlay._overlays.set('scene-1.token-2', { origTexture: 't2' }); overlay._cleanupAll(); expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-1'); expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-2'); expect(overlay._detachByKey).toHaveBeenCalledTimes(2); }); }); describe('syncAll()', () => { beforeEach(() => { overlay._enabled = true; }); it('attaches to all canvas tokens', () => { vi.spyOn(overlay, '_attach'); canvas.tokens.placeables = [mockToken]; overlay.syncAll(); expect(overlay._attach).toHaveBeenCalledWith(mockToken); }); it('detaches overlays for tokens no longer on canvas', () => { overlay._overlays.set('scene-1.token-1', {}); overlay._overlays.set('scene-1.token-2', {}); overlay._overlays.set('scene-2.token-3', {}); canvas.tokens.placeables = [mockToken]; vi.spyOn(overlay, '_detachByKey'); overlay.syncAll(); expect(overlay._detachByKey).toHaveBeenCalledWith('scene-1.token-2'); expect(overlay._detachByKey).toHaveBeenCalledWith('scene-2.token-3'); expect(overlay._detachByKey).not.toHaveBeenCalledWith('scene-1.token-1'); }); }); });