486 lines
15 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
});
|