Files
scrying-pool/tests/unit/ui/shared/TokenVideoOverlay.test.js
T
uberwald 76ce992505
CI / ci (push) Successful in 40s
Release Creation / build (release) Successful in 46s
Video over token, free-form video windows
2026-06-07 22:18:08 +02:00

486 lines
15 KiB
JavaScript

// @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');
});
});
});