357 lines
13 KiB
JavaScript
357 lines
13 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
function loadRendererScripts(sandbox) {
|
|
const registryPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-region-registry.js');
|
|
const animationPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-animation.js');
|
|
const mediaPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-media.js');
|
|
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
|
|
const registryScript = fs.readFileSync(registryPath, 'utf8');
|
|
const animationScript = fs.readFileSync(animationPath, 'utf8');
|
|
const mediaScript = fs.readFileSync(mediaPath, 'utf8');
|
|
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
|
|
vm.runInNewContext(registryScript + '\n' + animationScript + '\n' + mediaScript + '\n' + transitionScript, sandbox, { filename: transitionPath });
|
|
sandbox.pulsePlayerRegionTypes.register('test-region', sandbox.regionDefinition);
|
|
}
|
|
|
|
function createSandbox() {
|
|
const rafCallbacks = [];
|
|
const timers = new Map();
|
|
let nextTimerId = 0;
|
|
const calls = {
|
|
syncRtmpRegions: 0,
|
|
initRegion: 0,
|
|
destroyRegion: 0,
|
|
destroyRtmpRegions: 0,
|
|
playRegionAnimations: 0,
|
|
lifecycle: []
|
|
};
|
|
|
|
function createElement() {
|
|
const classes = new Set();
|
|
return {
|
|
style: {},
|
|
isConnected: false,
|
|
parentNode: null,
|
|
innerHTML: '',
|
|
get className() { return Array.from(classes).join(' '); },
|
|
set className(value) {
|
|
classes.clear();
|
|
String(value).split(/\s+/).filter(Boolean).forEach(name => classes.add(name));
|
|
},
|
|
classList: {
|
|
contains(name) { return classes.has(name); },
|
|
add(name) { classes.add(name); },
|
|
remove(name) { classes.delete(name); }
|
|
},
|
|
querySelectorAll() { return []; }
|
|
};
|
|
}
|
|
|
|
const app = {
|
|
children: [],
|
|
firstElementChild: null,
|
|
innerHTMLValue: '',
|
|
classList: {
|
|
contains() {
|
|
return false;
|
|
}
|
|
},
|
|
set innerHTML(value) {
|
|
this.innerHTMLValue = String(value || '');
|
|
this.children.forEach(node => { node.isConnected = false; node.parentNode = null; });
|
|
this.children = [];
|
|
this.firstElementChild = null;
|
|
if (this.innerHTMLValue) this.appendChild(createElement());
|
|
},
|
|
get innerHTML() {
|
|
return this.innerHTMLValue;
|
|
},
|
|
appendChild(node) {
|
|
if (node) {
|
|
node.isConnected = true;
|
|
node.parentNode = this;
|
|
this.children.push(node);
|
|
this.firstElementChild = this.firstElementChild || node;
|
|
}
|
|
return node;
|
|
},
|
|
removeChild(node) {
|
|
this.children = this.children.filter(child => child !== node);
|
|
node.parentNode = null;
|
|
node.isConnected = false;
|
|
this.firstElementChild = this.children[0] || null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const sandbox = {
|
|
window: null,
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout(callback) {
|
|
const timerId = ++nextTimerId;
|
|
timers.set(timerId, callback);
|
|
return timerId;
|
|
},
|
|
clearTimeout(timerId) { timers.delete(timerId); },
|
|
console,
|
|
requestAnimationFrame(callback) {
|
|
rafCallbacks.push(callback);
|
|
return rafCallbacks.length;
|
|
},
|
|
app,
|
|
document: { createElement },
|
|
slides: [],
|
|
index: 0,
|
|
currentPlaylistSignature: 'signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
pendingPlaylistUpdate: null,
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
renderCacheViewportKey: '',
|
|
slideTransitionTimer: null,
|
|
slideFadeLengthMs: 560,
|
|
slideFadeOffsetMs: 280,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
timer: null,
|
|
lastRenderedSlide: null,
|
|
destroyRtmpRegions() { calls.destroyRtmpRegions += 1; },
|
|
syncRtmpRegions() {
|
|
calls.syncRtmpRegions += 1;
|
|
},
|
|
playRegionAnimations() {
|
|
calls.playRegionAnimations += 1;
|
|
},
|
|
addEventListener() {},
|
|
regionDefinition: {
|
|
initRegion(root) { calls.initRegion += 1; calls.lifecycle.push(['init', root]); },
|
|
destroyRegion(root) { calls.destroyRegion += 1; calls.lifecycle.push(['destroy', root]); }
|
|
},
|
|
isThumbnailPreview() {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
sandbox.window = sandbox;
|
|
sandbox.window.requestAnimationFrame = sandbox.requestAnimationFrame;
|
|
return { sandbox, calls, rafCallbacks, app, timers };
|
|
}
|
|
|
|
test('renderSlideMarkup initializes immediately without loading command handling', () => {
|
|
const { sandbox, calls, rafCallbacks, app } = createSandbox();
|
|
loadRendererScripts(sandbox);
|
|
sandbox.playRegionAnimations = function () {
|
|
calls.playRegionAnimations += 1;
|
|
};
|
|
|
|
const returned = sandbox.renderSlideMarkup('<div class="slide">visible</div>', false);
|
|
|
|
assert.equal(app.innerHTMLValue, '<div class="slide">visible</div>');
|
|
assert.equal(calls.syncRtmpRegions, 1);
|
|
assert.equal(calls.initRegion, 1);
|
|
assert.equal(calls.playRegionAnimations, 1);
|
|
assert.equal(rafCallbacks.length, 0);
|
|
assert.equal(returned, app.firstElementChild);
|
|
assert.equal(calls.destroyRegion, 1);
|
|
assert.equal(calls.destroyRtmpRegions, 1);
|
|
assert.equal(typeof sandbox.sendCommandState, 'undefined');
|
|
});
|
|
|
|
test('fade rendering destroys old regions, initializes on the next frame, and removes the old shell', () => {
|
|
const { sandbox, calls, rafCallbacks, app, timers } = createSandbox();
|
|
loadRendererScripts(sandbox);
|
|
const previous = sandbox.renderSlideMarkup('<div>first</div>', true);
|
|
assert.equal(calls.initRegion, 1);
|
|
const next = sandbox.renderSlideMarkup('<div>second</div>', true, 0);
|
|
assert.equal(calls.initRegion, 1);
|
|
assert.equal(calls.destroyRegion, 2);
|
|
assert.equal(calls.destroyRtmpRegions, 2);
|
|
assert.equal(app.children.length, 2);
|
|
assert.equal(next.style.opacity, '0');
|
|
assert.equal(rafCallbacks.length, 1);
|
|
rafCallbacks.shift()();
|
|
assert.equal(calls.initRegion, 2);
|
|
assert.equal(calls.lifecycle.at(-1)[1], next);
|
|
assert.equal(next.style.opacity, '1');
|
|
assert.equal(previous.style.opacity, '0');
|
|
assert.equal(timers.size, 1);
|
|
timers.get(sandbox.slideTransitionTimer)();
|
|
assert.equal(app.children.length, 1);
|
|
assert.equal(app.firstElementChild, next);
|
|
assert.equal(previous.isConnected, false);
|
|
assert.equal(sandbox.slideTransitionTimer, null);
|
|
});
|
|
|
|
test('region lifecycle dispatch isolates failing hooks and tolerates absent modules', () => {
|
|
const { sandbox, calls, app } = createSandbox();
|
|
loadRendererScripts(sandbox);
|
|
sandbox.pulsePlayerRegionTypes.register('broken', {
|
|
initRegion() { throw new Error('broken init'); },
|
|
destroyRegion() { throw new Error('broken destroy'); }
|
|
});
|
|
let healthyCalls = 0;
|
|
sandbox.pulsePlayerRegionTypes.register('healthy', {
|
|
initRegion() { healthyCalls += 1; },
|
|
destroyRegion() { healthyCalls += 1; }
|
|
});
|
|
sandbox.pulsePlayerRegionTypes.register('no-hooks', {});
|
|
sandbox.initializeRegionInstances(app);
|
|
sandbox.destroyRegionInstances(app);
|
|
assert.equal(healthyCalls, 2);
|
|
assert.equal(calls.initRegion, 1);
|
|
assert.equal(calls.destroyRegion, 1);
|
|
sandbox.initializeRegionInstances(null);
|
|
sandbox.pulsePlayerRegionTypes = null;
|
|
assert.doesNotThrow(() => sandbox.destroyRegionInstances(app));
|
|
});
|
|
|
|
function createVideoSandbox(readyState = 2) {
|
|
const fixture = createSandbox();
|
|
loadRendererScripts(fixture.sandbox);
|
|
const listeners = new Map();
|
|
const mediaCalls = { plays: 0, pauses: 0, loads: 0, removed: [] };
|
|
const video = {
|
|
readyState,
|
|
dataset: { loop: '0', disableAudio: '0' },
|
|
isConnected: true,
|
|
src: '/media/video.mp4',
|
|
play() { mediaCalls.plays += 1; return Promise.resolve(); },
|
|
pause() { mediaCalls.pauses += 1; },
|
|
load() { mediaCalls.loads += 1; },
|
|
removeAttribute(name) { mediaCalls.removed.push(name); if (name === 'src') this.src = ''; },
|
|
addEventListener(name, callback) { listeners.set(name, callback); },
|
|
removeEventListener(name, callback) {
|
|
if (listeners.get(name) === callback) listeners.delete(name);
|
|
}
|
|
};
|
|
const root = { isConnected: true, querySelectorAll() { return [video]; } };
|
|
return { ...fixture, video, root, listeners, mediaCalls };
|
|
}
|
|
|
|
test('video teardown cancels delayed starts and releases the source', () => {
|
|
const { sandbox, root, video, timers, mediaCalls } = createVideoSandbox();
|
|
sandbox.initializeSlideMedia(root, 280);
|
|
assert.equal(timers.size, 1);
|
|
const staleStart = Array.from(timers.values())[0];
|
|
sandbox.destroySlideMedia(root);
|
|
root.isConnected = false;
|
|
video.isConnected = false;
|
|
assert.equal(timers.size, 0);
|
|
staleStart();
|
|
assert.equal(mediaCalls.plays, 0);
|
|
assert.equal(mediaCalls.pauses, 1);
|
|
assert.deepEqual(mediaCalls.removed, ['src']);
|
|
assert.equal(mediaCalls.loads, 1);
|
|
});
|
|
|
|
test('video teardown removes readiness listeners and ignores late readiness events', () => {
|
|
const { sandbox, root, video, timers, listeners, mediaCalls } = createVideoSandbox(0);
|
|
sandbox.initializeSlideMedia(root, 280);
|
|
const lateCanPlay = listeners.get('canplay');
|
|
const lateMetadata = listeners.get('loadedmetadata');
|
|
assert.equal(listeners.size, 2);
|
|
sandbox.destroySlideMedia(root);
|
|
assert.equal(listeners.size, 0);
|
|
video.readyState = 2;
|
|
lateCanPlay();
|
|
lateMetadata();
|
|
assert.equal(timers.size, 0);
|
|
assert.equal(mediaCalls.plays, 0);
|
|
});
|
|
|
|
test('video readiness schedules one start and reinitialization cancels the previous start', () => {
|
|
const { sandbox, root, video, timers, listeners, mediaCalls } = createVideoSandbox(0);
|
|
sandbox.initializeSlideMedia(root, 280);
|
|
const metadata = listeners.get('loadedmetadata');
|
|
const canPlay = listeners.get('canplay');
|
|
metadata();
|
|
assert.equal(timers.size, 0);
|
|
video.readyState = 2;
|
|
canPlay();
|
|
metadata();
|
|
assert.equal(timers.size, 1);
|
|
assert.equal(listeners.size, 0);
|
|
const staleStart = Array.from(timers.values())[0];
|
|
sandbox.initializeSlideMedia(root, 280);
|
|
assert.equal(timers.size, 1);
|
|
staleStart();
|
|
assert.equal(mediaCalls.plays, 0);
|
|
const [timerId, start] = Array.from(timers.entries())[0];
|
|
timers.delete(timerId);
|
|
start();
|
|
assert.equal(mediaCalls.plays, 1);
|
|
assert.equal(video.loop, false);
|
|
assert.equal(video.muted, false);
|
|
assert.equal(video.autoplay, false);
|
|
sandbox.destroySlideMedia(root);
|
|
assert.equal(mediaCalls.loads, 1);
|
|
});
|
|
|
|
test('video teardown can preserve the outgoing frame until crossfade removal', () => {
|
|
const { sandbox, root, timers, mediaCalls } = createVideoSandbox();
|
|
sandbox.initializeSlideMedia(root, 280);
|
|
sandbox.destroySlideMedia(root, { preserveVideoFrame: true });
|
|
assert.equal(timers.size, 0);
|
|
assert.equal(mediaCalls.pauses, 1);
|
|
assert.deepEqual(mediaCalls.removed, []);
|
|
assert.equal(mediaCalls.loads, 0);
|
|
sandbox.destroySlideMedia(root);
|
|
assert.deepEqual(mediaCalls.removed, ['src']);
|
|
assert.equal(mediaCalls.loads, 1);
|
|
});
|
|
|
|
test('crossfade cancels outgoing video startup but releases its source only at shell removal', () => {
|
|
const { sandbox, app, video, timers, rafCallbacks, mediaCalls } = createVideoSandbox();
|
|
const previous = sandbox.renderSlideMarkup('<div>video</div>', true);
|
|
previous.querySelectorAll = function () { return [video]; };
|
|
app.querySelectorAll = function () { return app.children.includes(previous) ? [video] : []; };
|
|
sandbox.initializeSlideMedia(previous, 280);
|
|
sandbox.renderSlideMarkup('<div>next</div>', true);
|
|
assert.equal(mediaCalls.pauses, 1);
|
|
assert.equal(mediaCalls.loads, 0);
|
|
assert.equal(timers.size, 1);
|
|
rafCallbacks.shift()();
|
|
timers.get(sandbox.slideTransitionTimer)();
|
|
assert.equal(mediaCalls.plays, 0);
|
|
assert.deepEqual(mediaCalls.removed, ['src']);
|
|
assert.equal(mediaCalls.loads, 1);
|
|
assert.equal(previous.isConnected, false);
|
|
});
|
|
|
|
test('rapid crossfades ignore obsolete media setup and cleanup callbacks', () => {
|
|
const { sandbox, app, timers, rafCallbacks, calls } = createSandbox();
|
|
loadRendererScripts(sandbox);
|
|
const first = sandbox.renderSlideMarkup('<div>first</div>', true);
|
|
const second = sandbox.renderSlideMarkup('<div>second</div>', true);
|
|
const staleCleanup = timers.get(sandbox.slideTransitionTimer);
|
|
const third = sandbox.renderSlideMarkup('<div>third</div>', true);
|
|
const activeTimerId = sandbox.slideTransitionTimer;
|
|
assert.equal(first.isConnected, false);
|
|
assert.equal(app.children.length, 2);
|
|
staleCleanup();
|
|
assert.equal(sandbox.slideTransitionTimer, activeTimerId);
|
|
rafCallbacks.shift()();
|
|
assert.equal(calls.initRegion, 1);
|
|
rafCallbacks.shift()();
|
|
assert.equal(calls.initRegion, 2);
|
|
assert.equal(calls.lifecycle.at(-1)[1], third);
|
|
timers.get(activeTimerId)();
|
|
assert.equal(second.isConnected, false);
|
|
assert.equal(app.firstElementChild, third);
|
|
}); |