1263 lines
41 KiB
JavaScript
1263 lines
41 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 createRenderSandbox() {
|
|
const calls = { rendered: [], scheduled: [], reported: [], saved: 0, progress: 0 };
|
|
const sandbox = {
|
|
window: null,
|
|
slides: [{ id: 1, duration_seconds: 10 }, { id: 2, duration_seconds: 12 }],
|
|
slideRenderRequestId: 0,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
isPaused: false,
|
|
index: 0,
|
|
timer: null,
|
|
app: null,
|
|
lastRenderedViewKey: '',
|
|
getCurrentActiveSlides() { return sandbox.slides; },
|
|
getCurrentRenderKey() { return String(sandbox.index); },
|
|
buildSlideMarkup(slide) { return slide.id; },
|
|
renderEmpty() { calls.rendered.push('empty'); },
|
|
savePlaybackState() { calls.saved += 1; },
|
|
sendCommandState(slide) { calls.reported.push(slide.id); },
|
|
getSlideHoldDelay(delayMs) { return delayMs; },
|
|
clearSlideTimer() {},
|
|
syncWebpagePreloads() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
|
|
vm.runInNewContext(fs.readFileSync(transitionPath, 'utf8'), sandbox, { filename: transitionPath });
|
|
sandbox.renderSlideMarkup = function (markup) { calls.rendered.push(markup); };
|
|
sandbox.scheduleSlideAdvance = function (delayMs) { calls.scheduled.push(delayMs); };
|
|
sandbox.markPlayerPlaybackProgress = function () { calls.progress += 1; };
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
|
|
return { sandbox, calls };
|
|
}
|
|
|
|
test('playlist refreshes coalesce startup bursts but forced refreshes run after the debounce window', () => {
|
|
const requests = [];
|
|
class XhrStub {
|
|
open(_method, url) { this.url = url; }
|
|
setRequestHeader() {}
|
|
send() {
|
|
requests.push(this);
|
|
this.readyState = 4;
|
|
this.status = 304;
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost' },
|
|
slug: 'test',
|
|
currentPlaylistEtag: '"etag"',
|
|
initialData: { apiSources: [], weatherLocations: [] },
|
|
playlistRefreshInFlight: false,
|
|
XMLHttpRequest: XhrStub,
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
logDebug() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
|
|
|
|
sandbox.refresh();
|
|
sandbox.refresh();
|
|
assert.equal(requests.length, 1);
|
|
|
|
sandbox.refresh(true);
|
|
assert.equal(requests.length, 1);
|
|
|
|
sandbox.lastPlaylistRefreshAt = Date.now() - 1001;
|
|
sandbox.refresh(true);
|
|
assert.equal(requests.length, 2);
|
|
});
|
|
|
|
test('playlist refresh sends cached etags only after source metadata is hydrated', () => {
|
|
function captureHeaders(initialData) {
|
|
let request = null;
|
|
class XhrStub {
|
|
constructor() { this.headers = {}; request = this; }
|
|
open() {}
|
|
setRequestHeader(name, value) { this.headers[name] = value; }
|
|
send() {}
|
|
}
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost' },
|
|
slug: 'test',
|
|
initialData: initialData,
|
|
currentPlaylistEtag: '"cached-etag"',
|
|
playlistRefreshInFlight: false,
|
|
XMLHttpRequest: XhrStub
|
|
};
|
|
sandbox.window = sandbox;
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
|
|
sandbox.refresh();
|
|
return request.headers['If-None-Match'];
|
|
}
|
|
|
|
assert.equal(captureHeaders(null), undefined);
|
|
assert.equal(captureHeaders({ apiSources: [], weatherLocations: [] }), '"cached-etag"');
|
|
});
|
|
|
|
test('command client ID conflict reconnects in place without navigating through onboarding', () => {
|
|
const sockets = [];
|
|
const storedValues = new Map([['pulse-signage-player-client-id', 'client-old']]);
|
|
const reconnectTimers = [];
|
|
let redirects = 0;
|
|
|
|
class MockWebSocket {
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.readyState = MockWebSocket.CONNECTING;
|
|
this.sent = [];
|
|
sockets.push(this);
|
|
}
|
|
send(payload) { this.sent.push(payload); }
|
|
}
|
|
MockWebSocket.CONNECTING = 0;
|
|
MockWebSocket.OPEN = 1;
|
|
MockWebSocket.CLOSED = 3;
|
|
|
|
const window = {
|
|
location: {
|
|
origin: 'http://localhost:8081',
|
|
href: 'http://localhost:8081/screen/test',
|
|
replace() { redirects += 1; }
|
|
},
|
|
navigator: { userAgent: 'test' },
|
|
crypto: { randomUUID() { return 'client-new'; } },
|
|
sessionStorage: {
|
|
getItem(key) { return storedValues.get(key) || null; },
|
|
setItem(key, value) { storedValues.set(key, String(value)); },
|
|
removeItem(key) { storedValues.delete(key); }
|
|
},
|
|
WebSocket: MockWebSocket,
|
|
addEventListener() {},
|
|
setTimeout(callback) { reconnectTimers.push(callback); return reconnectTimers.length; },
|
|
clearTimeout() {},
|
|
setInterval() { return 1; },
|
|
clearInterval() {}
|
|
};
|
|
const sandbox = {
|
|
window: window,
|
|
WebSocket: MockWebSocket,
|
|
URL,
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
commandSocketPath: '/ws/screens/test',
|
|
commandSocket: null,
|
|
commandClientId: null,
|
|
commandClientStorageKey: 'pulse-signage-player-client-id',
|
|
commandReconnectTimer: null,
|
|
commandHeartbeatTimer: null,
|
|
currentPlaylistSignature: 'playlist-revision',
|
|
getOnboardingClientName() { return ''; },
|
|
getOnboardingDeviceId() { return 'device-1'; },
|
|
getCurrentViewport() { return { width: 1920, height: 1080 }; },
|
|
lastRenderedSlide: null,
|
|
isPaused: false,
|
|
isBlackout: false
|
|
};
|
|
const playlistScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
|
|
const commandsScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
|
|
vm.runInNewContext(fs.readFileSync(playlistScriptPath, 'utf8'), sandbox, { filename: playlistScriptPath });
|
|
vm.runInNewContext(fs.readFileSync(commandsScriptPath, 'utf8'), sandbox, { filename: commandsScriptPath });
|
|
|
|
sandbox.connectCommandSocket();
|
|
const socket = sockets[0];
|
|
socket.readyState = MockWebSocket.OPEN;
|
|
socket.onopen();
|
|
socket.onmessage({ data: JSON.stringify({ type: 'client-id-conflict' }) });
|
|
socket.onclose({ code: 4009, reason: 'Client ID is already in use.' });
|
|
|
|
assert.equal(redirects, 0);
|
|
assert.equal(storedValues.get('pulse-signage-player-client-id'), 'client-new');
|
|
assert.equal(reconnectTimers.length, 1);
|
|
});
|
|
|
|
test('invalid slide indexes fall back to the first available slide', async () => {
|
|
const { sandbox, calls } = createRenderSandbox();
|
|
for (const targetIndex of [-1, Infinity, NaN, 0.5, sandbox.slides.length]) {
|
|
assert.equal(await sandbox.renderSlideAtIndex(sandbox.slides, targetIndex), true);
|
|
assert.equal(sandbox.index, 0);
|
|
}
|
|
assert.deepEqual(calls.rendered, [1, 1, 1, 1, 1]);
|
|
});
|
|
|
|
test('obsolete RTMP probes cannot overwrite a newer render or its side effects', async () => {
|
|
for (const olderAvailable of [true, false]) {
|
|
const { sandbox, calls } = createRenderSandbox();
|
|
const pending = new Map();
|
|
sandbox.currentPlaylistSkipUnavailableRtmp = true;
|
|
sandbox.probeRtmpSlideAvailability = function (slide) {
|
|
return new Promise(resolve => pending.set(slide.id, resolve));
|
|
};
|
|
const older = sandbox.renderSlideAtIndex(sandbox.slides, 0);
|
|
const newer = sandbox.renderSlideAtIndex(sandbox.slides, 1);
|
|
pending.get(2)(true);
|
|
assert.equal(await newer, true);
|
|
pending.get(1)(olderAvailable);
|
|
assert.equal(await older, false);
|
|
assert.deepEqual(calls.rendered, [2]);
|
|
assert.deepEqual(calls.reported, [2]);
|
|
assert.deepEqual(calls.scheduled, [12000]);
|
|
assert.equal(calls.saved, 1);
|
|
assert.equal(calls.progress, 1);
|
|
assert.equal(sandbox.index, 1);
|
|
}
|
|
});
|
|
|
|
test('an empty active playlist cancels a pending RTMP render', async () => {
|
|
const { sandbox, calls } = createRenderSandbox();
|
|
let resolveProbe;
|
|
sandbox.currentPlaylistSkipUnavailableRtmp = true;
|
|
sandbox.probeRtmpSlideAvailability = function () {
|
|
return new Promise(resolve => { resolveProbe = resolve; });
|
|
};
|
|
const pendingRender = sandbox.renderSlideAtIndex(sandbox.slides, 0);
|
|
sandbox.slides = [];
|
|
sandbox.showCurrent();
|
|
resolveProbe(true);
|
|
assert.equal(await pendingRender, false);
|
|
assert.deepEqual(calls.rendered, ['empty']);
|
|
assert.deepEqual(calls.scheduled, []);
|
|
assert.equal(calls.saved, 0);
|
|
});
|
|
|
|
test('playlist refreshes preserve single-slide deadlines and paused remaining time', () => {
|
|
for (const outcome of ['304', 'unchanged', 'network-error', 'timeout', '503', 'invalid-json']) {
|
|
for (const paused of [false, true]) {
|
|
const requests = [];
|
|
let advances = 0;
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost' },
|
|
slug: 'test',
|
|
isPaused: paused,
|
|
timer: paused ? null : 42,
|
|
slideExpiresAt: paused ? null : Date.now() + 90000,
|
|
pausedRemainingMs: paused ? 45000 : null,
|
|
lastRenderedSlide: { id: 1, duration_seconds: 120 },
|
|
currentPlaylistSignature: 'revision',
|
|
currentPlaylistEtag: '"etag"',
|
|
initialData: { apiSources: [], weatherLocations: [] },
|
|
pendingPlaylistUpdate: null,
|
|
getCurrentActiveSlides() { return [sandbox.lastRenderedSlide]; },
|
|
normalizeSlide(slide) { return slide; },
|
|
getActiveSlidesFrom(slides) { return slides; },
|
|
getPlaylistRevision() { return 'revision'; },
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
logDebug() {},
|
|
getSlideAdvanceDelay() { return 120000; },
|
|
scheduleSlideAdvance(delayMs) {
|
|
advances += 1;
|
|
sandbox.timer = 99;
|
|
sandbox.slideExpiresAt = Date.now() + delayMs;
|
|
},
|
|
XMLHttpRequest: class {
|
|
open() { requests.push(this); }
|
|
setRequestHeader() {}
|
|
getResponseHeader() { return '"etag"'; }
|
|
send() {
|
|
if (outcome === 'network-error') { this.onerror(); return; }
|
|
if (outcome === 'timeout') { this.ontimeout(); return; }
|
|
this.readyState = 4;
|
|
this.status = outcome === '304' ? 304 : outcome === '503' ? 503 : 200;
|
|
this.responseText = outcome === 'invalid-json' ? '{' : JSON.stringify({
|
|
slides: [{ id: 1, duration_seconds: 120 }], apiSources: [], weatherLocations: [], playlist: {}
|
|
});
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
};
|
|
sandbox.window = sandbox;
|
|
const deadline = sandbox.slideExpiresAt;
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
|
|
sandbox.refresh();
|
|
sandbox.refresh();
|
|
assert.equal(requests.length, 1);
|
|
assert.equal(advances, 0, outcome + ', paused=' + paused);
|
|
assert.equal(sandbox.timer, paused ? null : 42);
|
|
assert.equal(sandbox.slideExpiresAt, deadline);
|
|
assert.equal(sandbox.pausedRemainingMs, paused ? 45000 : null);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('forced playlist refresh queues updates until the next slide transition', async () => {
|
|
const calls = {
|
|
showCurrent: 0,
|
|
logDebug: [],
|
|
scheduleSlideAdvance: []
|
|
};
|
|
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
currentPlaylistSignature: 'old-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
slug: 'test2',
|
|
pendingPlaylistUpdate: null,
|
|
slides: [{ id: 1, duration_seconds: 12 }],
|
|
lastRenderedSlide: { id: 1, duration_seconds: 12 },
|
|
index: 0,
|
|
timer: null,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
app: null,
|
|
currentPlaylistEtag: '',
|
|
initialData: {
|
|
rssFeeds: [{ id: 1 }],
|
|
apiSources: [{ id: 2 }],
|
|
timetableGroups: [{ id: 3 }]
|
|
},
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
renderCacheViewportKey: '',
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
getPlaylistRevision(data) {
|
|
return data.signature;
|
|
},
|
|
normalizeSlide(slide) {
|
|
return slide;
|
|
},
|
|
getActiveSlidesFrom(slideList) {
|
|
return slideList;
|
|
},
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
addEventListener() {},
|
|
showCurrent() {
|
|
calls.showCurrent += 1;
|
|
},
|
|
sendCommandState() {},
|
|
scheduleSlideAdvance(delayMs) {
|
|
calls.scheduleSlideAdvance.push(delayMs);
|
|
},
|
|
logDebug(...args) {
|
|
calls.logDebug.push(args.join(' '));
|
|
}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
class XhrStub {
|
|
open(method, url) {
|
|
this.method = method;
|
|
this.url = url;
|
|
}
|
|
|
|
setRequestHeader() {}
|
|
|
|
getResponseHeader(name) {
|
|
return name === 'ETag' ? '"next-etag"' : '';
|
|
}
|
|
|
|
send() {
|
|
this.readyState = 4;
|
|
this.status = 200;
|
|
this.responseText = JSON.stringify({
|
|
signature: 'next-signature',
|
|
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
|
rssFeeds: [{ id: 10 }],
|
|
apiSources: [{ id: 20 }],
|
|
timetableGroups: [{ id: 30 }],
|
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
|
});
|
|
if (typeof this.onreadystatechange === 'function') {
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
}
|
|
|
|
sandbox.XMLHttpRequest = XhrStub;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
|
const prelude = `
|
|
var pendingPlaylistUpdate = null;
|
|
var slides = [{ id: 1, duration_seconds: 12 }];
|
|
var currentPlaylistSignature = 'old-signature';
|
|
var currentPlaylistFadeBetweenSlides = false;
|
|
var currentPlaylistSkipUnavailableRtmp = false;
|
|
var currentPlaylistEtag = '';
|
|
var lastRenderedSlide = { id: 1, duration_seconds: 12 };
|
|
var index = 0;
|
|
var timer = null;
|
|
var slideExpiresAt = null;
|
|
var pausedRemainingMs = null;
|
|
var app = null;
|
|
var activeSlidesCacheKey = '';
|
|
var activeSlidesCacheValue = [];
|
|
var renderCacheViewportKey = '';
|
|
var slideMarkupCache = Object.create(null);
|
|
var templateLayoutCache = Object.create(null);
|
|
var templateRenderPlanCache = Object.create(null);
|
|
`;
|
|
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
|
|
|
|
await sandbox.refresh(true);
|
|
|
|
assert.equal(calls.showCurrent, 0);
|
|
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
|
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
|
assert.deepEqual(sandbox.initialData.rssFeeds, [{ id: 10 }]);
|
|
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 20 }]);
|
|
assert.deepEqual(sandbox.initialData.timetableGroups, [{ id: 30 }]);
|
|
assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false);
|
|
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
|
|
});
|
|
|
|
test('playlist refresh does not overlap an in-flight request', () => {
|
|
const requests = [];
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
slug: 'test',
|
|
initialData: {},
|
|
currentPlaylistEtag: '',
|
|
currentPlaylistSignature: 'cached-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
pendingPlaylistUpdate: null,
|
|
slides: [{ id: 1, duration_seconds: 10 }],
|
|
lastRenderedSlide: { id: 1, duration_seconds: 10 },
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() { return sandbox.slides; },
|
|
getPlaylistRevision(data) { return data.signature; },
|
|
normalizeSlide(slide) { return slide; },
|
|
getActiveSlidesFrom(slideList) { return slideList; },
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
showCurrent() {},
|
|
sendCommandState() {},
|
|
scheduleSlideAdvance() {},
|
|
logDebug() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
class XhrStub {
|
|
open() {
|
|
requests.push(this);
|
|
}
|
|
|
|
setRequestHeader() {}
|
|
getResponseHeader() { return ''; }
|
|
send() {}
|
|
}
|
|
|
|
sandbox.XMLHttpRequest = XhrStub;
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
|
|
|
|
sandbox.refresh();
|
|
sandbox.refresh();
|
|
|
|
assert.equal(requests.length, 1);
|
|
});
|
|
|
|
test('playlist refresh reloads source data after a cached snapshot receives 304', async () => {
|
|
const requests = [];
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
slug: 'test',
|
|
initialData: null,
|
|
currentPlaylistEtag: '"cached-etag"',
|
|
currentPlaylistSignature: 'cached-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
pendingPlaylistUpdate: null,
|
|
slides: [{ id: 1, duration_seconds: 10 }],
|
|
lastRenderedSlide: null,
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() { return sandbox.slides; },
|
|
getPlaylistRevision(data) { return data.signature; },
|
|
normalizeSlide(slide) { return slide; },
|
|
getActiveSlidesFrom(slideList) { return slideList; },
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
showCurrent() {},
|
|
sendCommandState() {},
|
|
scheduleSlideAdvance() {},
|
|
logDebug() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
class XhrStub {
|
|
open(method, url) {
|
|
this.method = method;
|
|
this.url = url;
|
|
requests.push(this);
|
|
}
|
|
|
|
setRequestHeader(name, value) {
|
|
this.headers = this.headers || {};
|
|
this.headers[name] = value;
|
|
}
|
|
|
|
getResponseHeader() { return ''; }
|
|
|
|
send() {
|
|
this.readyState = 4;
|
|
if (requests.length === 1) {
|
|
this.status = 304;
|
|
this.responseText = '';
|
|
} else {
|
|
this.status = 200;
|
|
this.responseText = JSON.stringify({
|
|
signature: 'fresh-signature',
|
|
slides: [{ id: 1, duration_seconds: 10 }],
|
|
rssFeeds: [],
|
|
apiSources: [{ id: 1 }],
|
|
timetableGroups: [],
|
|
weatherLocations: [{ id: 1 }],
|
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
|
});
|
|
}
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
|
|
sandbox.XMLHttpRequest = XhrStub;
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
|
|
|
|
await sandbox.refresh();
|
|
|
|
assert.equal(requests.length, 2);
|
|
assert.equal(requests[1].headers && requests[1].headers['If-None-Match'], undefined);
|
|
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 1 }]);
|
|
assert.deepEqual(sandbox.initialData.weatherLocations, [{ id: 1 }]);
|
|
});
|
|
|
|
test('runtime scripts have one slide timing owner and preserve configured fade timing', () => {
|
|
const sandbox = {
|
|
window: null,
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
slideFadeLengthMs: 560,
|
|
slideFadeOffsetMs: 280,
|
|
getSlideHoldDelay(value) {
|
|
return value;
|
|
}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
require('../src/common');
|
|
const { getPlayerRuntimeScripts } = require('../src/player/render-helpers');
|
|
const runtimeScripts = getPlayerRuntimeScripts();
|
|
const declarations = runtimeScripts.flatMap(([, source]) => source.match(/function getSlideAdvanceDelay\s*\(/g) || []);
|
|
assert.equal(declarations.length, 1);
|
|
for (const [name, source] of runtimeScripts) {
|
|
if (name === 'transition' || name === 'playback') {
|
|
vm.runInNewContext(source, sandbox, { filename: name });
|
|
}
|
|
}
|
|
|
|
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 12000);
|
|
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 12000);
|
|
sandbox.currentPlaylistFadeBetweenSlides = true;
|
|
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10 }), 9720);
|
|
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10, use_video_duration: true }), 9440);
|
|
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 11720);
|
|
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 11440);
|
|
});
|
|
|
|
test('runtime scripts keep DOM transitions and region lifecycle outside command handling', () => {
|
|
require('../src/common');
|
|
const { getPlayerRuntimeScripts } = require('../src/player/render-helpers');
|
|
const scripts = getPlayerRuntimeScripts();
|
|
for (const [symbol, owner] of [
|
|
['renderSlideMarkup', 'transition'],
|
|
['runRegionLifecycle', 'region-registry'],
|
|
['initializeRegionInstances', 'region-registry'],
|
|
['destroyRegionInstances', 'region-registry']
|
|
]) {
|
|
const declaration = new RegExp('function ' + symbol + '\\s*\\(');
|
|
const owners = scripts.filter(([, source]) => declaration.test(source)).map(([name]) => name);
|
|
assert.deepEqual(owners, [owner]);
|
|
assert.ok(scripts.findIndex(([name]) => name === owner) < scripts.findIndex(([name]) => name === 'commands'));
|
|
}
|
|
});
|
|
|
|
test('playlist removal of the current slide waits until its transition before applying', async () => {
|
|
const calls = {
|
|
showCurrent: 0,
|
|
refresh: 0
|
|
};
|
|
const timers = [];
|
|
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout(fn) {
|
|
timers.push(fn);
|
|
return timers.length;
|
|
},
|
|
clearTimeout() {},
|
|
console,
|
|
currentPlaylistSignature: 'old-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
slug: 'test2',
|
|
pendingPlaylistUpdate: null,
|
|
slides: [{ id: 1, duration_seconds: 12 }],
|
|
lastRenderedSlide: { id: 1, duration_seconds: 12 },
|
|
index: 0,
|
|
timer: null,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
currentPlaylistEtag: '',
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
renderCacheViewportKey: '',
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
getPlaylistRevision(data) {
|
|
return data.signature;
|
|
},
|
|
normalizeSlide(slide) {
|
|
return slide;
|
|
},
|
|
getActiveSlidesFrom(slideList) {
|
|
return slideList;
|
|
},
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
addEventListener() {},
|
|
clearActiveSlidesCache() {},
|
|
showCurrent() {},
|
|
sendCommandState() {},
|
|
refresh() {
|
|
calls.refresh += 1;
|
|
},
|
|
logDebug() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
class XhrStub {
|
|
open() {}
|
|
|
|
setRequestHeader() {}
|
|
|
|
getResponseHeader(name) {
|
|
return name === 'ETag' ? '"next-etag"' : '';
|
|
}
|
|
|
|
send() {
|
|
this.readyState = 4;
|
|
this.status = 200;
|
|
this.responseText = JSON.stringify({
|
|
signature: 'next-signature',
|
|
slides: [{ id: 2, duration_seconds: 12, disable_audio: false }],
|
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
|
});
|
|
if (typeof this.onreadystatechange === 'function') {
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
}
|
|
|
|
sandbox.XMLHttpRequest = XhrStub;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
|
|
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
|
|
const playbackScript = fs.readFileSync(scriptPath, 'utf8');
|
|
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
|
|
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
|
|
const prelude = `
|
|
var pendingPlaylistUpdate = null;
|
|
var slides = [{ id: 1, duration_seconds: 12 }];
|
|
var currentPlaylistSignature = 'old-signature';
|
|
var currentPlaylistFadeBetweenSlides = false;
|
|
var currentPlaylistSkipUnavailableRtmp = false;
|
|
var currentPlaylistEtag = '';
|
|
var lastRenderedSlide = { id: 1, duration_seconds: 12 };
|
|
var index = 0;
|
|
var timer = null;
|
|
var slideExpiresAt = null;
|
|
var pausedRemainingMs = null;
|
|
var app = null;
|
|
var activeSlidesCacheKey = '';
|
|
var activeSlidesCacheValue = [];
|
|
var renderCacheViewportKey = '';
|
|
var slideMarkupCache = Object.create(null);
|
|
var templateLayoutCache = Object.create(null);
|
|
var templateRenderPlanCache = Object.create(null);
|
|
`;
|
|
vm.runInNewContext(prelude + '\n' + transitionScript + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath });
|
|
sandbox.showCurrent = function () {
|
|
calls.showCurrent += 1;
|
|
};
|
|
|
|
sandbox.pendingPlaylistUpdate = {
|
|
slides: [{ id: 2, duration_seconds: 12 }],
|
|
signature: 'next-signature',
|
|
fadeBetweenSlides: false,
|
|
skipUnavailableRtmp: false
|
|
};
|
|
|
|
sandbox.scheduleSlideAdvance(100);
|
|
assert.equal(timers.length, 1);
|
|
timers.shift()();
|
|
|
|
assert.equal(calls.refresh, 0);
|
|
assert.equal(calls.showCurrent, 1);
|
|
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
|
|
assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]);
|
|
});
|
|
|
|
test('deferred playlist updates preserve the current slide index', async () => {
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
currentPlaylistSignature: 'old-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
slug: 'test2',
|
|
pendingPlaylistUpdate: null,
|
|
slides: [
|
|
{ id: 1, duration_seconds: 12 },
|
|
{ id: 2, duration_seconds: 12 },
|
|
{ id: 3, duration_seconds: 12 }
|
|
],
|
|
lastRenderedSlide: { id: 2, duration_seconds: 12 },
|
|
index: 1,
|
|
timer: null,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
app: null,
|
|
currentPlaylistEtag: '',
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
renderCacheViewportKey: '',
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
getPlaylistRevision(data) {
|
|
return data.signature;
|
|
},
|
|
normalizeSlide(slide) {
|
|
return slide;
|
|
},
|
|
getActiveSlidesFrom(slideList) {
|
|
return slideList;
|
|
},
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
addEventListener() {},
|
|
clearActiveSlidesCache() {},
|
|
showCurrent() {},
|
|
sendCommandState() {},
|
|
logDebug() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
|
const prelude = `
|
|
var pendingPlaylistUpdate = null;
|
|
var slides = [
|
|
{ id: 1, duration_seconds: 12 },
|
|
{ id: 2, duration_seconds: 12 },
|
|
{ id: 3, duration_seconds: 12 }
|
|
];
|
|
var currentPlaylistSignature = 'old-signature';
|
|
var currentPlaylistFadeBetweenSlides = false;
|
|
var currentPlaylistSkipUnavailableRtmp = false;
|
|
var currentPlaylistEtag = '';
|
|
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
|
|
var index = 1;
|
|
var timer = null;
|
|
var slideExpiresAt = null;
|
|
var pausedRemainingMs = null;
|
|
var app = null;
|
|
var activeSlidesCacheKey = '';
|
|
var activeSlidesCacheValue = [];
|
|
var renderCacheViewportKey = '';
|
|
var slideMarkupCache = Object.create(null);
|
|
var templateLayoutCache = Object.create(null);
|
|
var templateRenderPlanCache = Object.create(null);
|
|
`;
|
|
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
|
|
|
|
sandbox.pendingPlaylistUpdate = {
|
|
slides: [
|
|
{ id: 10, duration_seconds: 12 },
|
|
{ id: 11, duration_seconds: 12 },
|
|
{ id: 12, duration_seconds: 12 }
|
|
],
|
|
signature: 'next-signature',
|
|
fadeBetweenSlides: false,
|
|
skipUnavailableRtmp: false
|
|
};
|
|
|
|
const applied = sandbox.applyPendingPlaylistUpdate();
|
|
|
|
assert.equal(applied, true);
|
|
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
|
|
assert.equal(sandbox.pendingPlaylistUpdate, null);
|
|
assert.deepEqual(sandbox.slides, [
|
|
{ id: 10, duration_seconds: 12 },
|
|
{ id: 11, duration_seconds: 12 },
|
|
{ id: 12, duration_seconds: 12 }
|
|
]);
|
|
assert.equal(sandbox.index, 1);
|
|
});
|
|
|
|
test('player page arrow keys move between slides', async () => {
|
|
const handlers = Object.create(null);
|
|
const calls = [];
|
|
|
|
const sandbox = {
|
|
window: null,
|
|
console,
|
|
Array,
|
|
Object,
|
|
String,
|
|
Boolean,
|
|
Number,
|
|
Math,
|
|
Date,
|
|
JSON,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
addEventListener(type, handler) {
|
|
handlers[type] = handler;
|
|
},
|
|
slides: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
|
lastRenderedSlide: { id: 2 },
|
|
index: 1,
|
|
timer: null,
|
|
slideOutroTimers: [],
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
clearSlideTimer() {},
|
|
applyPendingPlaylistUpdate() {},
|
|
renderSlideAtIndex(_sourceSlides, targetIndex) {
|
|
calls.push(targetIndex);
|
|
}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
|
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
|
vm.runInNewContext(script, sandbox, { filename: scriptPath });
|
|
|
|
assert.equal(typeof handlers.keydown, 'function');
|
|
|
|
let prevented = false;
|
|
handlers.keydown({
|
|
key: 'ArrowLeft',
|
|
target: {},
|
|
preventDefault() {
|
|
prevented = true;
|
|
}
|
|
});
|
|
|
|
assert.deepEqual(calls, [0]);
|
|
assert.equal(prevented, true);
|
|
|
|
sandbox.lastRenderedSlide = { id: 2 };
|
|
sandbox.index = 1;
|
|
prevented = false;
|
|
handlers.keydown({
|
|
key: 'ArrowRight',
|
|
target: {},
|
|
preventDefault() {
|
|
prevented = true;
|
|
}
|
|
});
|
|
|
|
assert.deepEqual(calls, [0, 2]);
|
|
assert.equal(prevented, true);
|
|
|
|
handlers.keydown({
|
|
key: 'ArrowLeft',
|
|
target: { tagName: 'INPUT' },
|
|
preventDefault() {
|
|
throw new Error('should not be called for editable targets');
|
|
}
|
|
});
|
|
|
|
assert.deepEqual(calls, [0, 2]);
|
|
});
|
|
|
|
test('manual forward wrap advances the playlist group pass', () => {
|
|
const handlers = Object.create(null);
|
|
const calls = [];
|
|
const firstPassSlides = [{ id: 1 }, { id: 2 }];
|
|
const nextPassSlides = [{ id: 3 }, { id: 4 }];
|
|
const sandbox = {
|
|
window: null,
|
|
console,
|
|
Array,
|
|
Object,
|
|
String,
|
|
Boolean,
|
|
Number,
|
|
Math,
|
|
Date,
|
|
JSON,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
addEventListener(type, handler) { handlers[type] = handler; },
|
|
slides: firstPassSlides,
|
|
lastRenderedSlide: { id: 2 },
|
|
index: 1,
|
|
playlistPassNumber: 0,
|
|
timer: null,
|
|
slideOutroTimers: [],
|
|
getCurrentActiveSlides() { return sandbox.playlistPassNumber ? nextPassSlides : firstPassSlides; },
|
|
clearSlideTimer() {},
|
|
clearActiveSlidesCache() {},
|
|
applyPendingPlaylistUpdate() {},
|
|
renderSlideAtIndex(sourceSlides, targetIndex) { calls.push({ sourceSlides, targetIndex }); }
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
|
|
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
|
|
sandbox.navigateSlides(1);
|
|
|
|
assert.equal(sandbox.playlistPassNumber, 1);
|
|
assert.deepEqual(calls, [{ sourceSlides: nextPassSlides, targetIndex: 0 }]);
|
|
const repeated = [{ id: 1 }, { id: 2 }, { id: 1 }, { id: 2 }, { id: 9 }];
|
|
sandbox.getCurrentActiveSlides = () => repeated;
|
|
sandbox.index = 3;
|
|
sandbox.lastRenderedSlide = { id: 2 };
|
|
sandbox.navigateSlides(1);
|
|
assert.equal(calls.at(-1).targetIndex, 4);
|
|
sandbox.navigateSlides(-1);
|
|
assert.equal(calls.at(-1).targetIndex, 2);
|
|
});
|
|
|
|
test('playlist removal of the current slide queues without rerendering it immediately', async () => {
|
|
const calls = {
|
|
showCurrent: 0,
|
|
logDebug: []
|
|
};
|
|
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
currentPlaylistSignature: 'old-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
slug: 'test2',
|
|
pendingPlaylistUpdate: null,
|
|
slides: [{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }],
|
|
lastRenderedSlide: { id: 2, duration_seconds: 12 },
|
|
index: 1,
|
|
timer: null,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
app: null,
|
|
currentPlaylistEtag: '',
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
renderCacheViewportKey: '',
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
getPlaylistRevision(data) {
|
|
return data.signature;
|
|
},
|
|
normalizeSlide(slide) {
|
|
return slide;
|
|
},
|
|
getActiveSlidesFrom(slideList) {
|
|
return slideList;
|
|
},
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
addEventListener() {},
|
|
clearActiveSlidesCache() {},
|
|
showCurrent() {
|
|
calls.showCurrent += 1;
|
|
},
|
|
sendCommandState() {},
|
|
refresh() {},
|
|
logDebug(...args) {
|
|
calls.logDebug.push(args.join(' '));
|
|
}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
class XhrStub {
|
|
open() {}
|
|
|
|
setRequestHeader() {}
|
|
|
|
getResponseHeader(name) {
|
|
return name === 'ETag' ? '"next-etag"' : '';
|
|
}
|
|
|
|
send() {
|
|
this.readyState = 4;
|
|
this.status = 200;
|
|
this.responseText = JSON.stringify({
|
|
signature: 'next-signature',
|
|
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
|
});
|
|
if (typeof this.onreadystatechange === 'function') {
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
}
|
|
|
|
sandbox.XMLHttpRequest = XhrStub;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
const playbackScript = fs.readFileSync(scriptPath, 'utf8');
|
|
const prelude = `
|
|
var pendingPlaylistUpdate = null;
|
|
var slides = [{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }];
|
|
var currentPlaylistSignature = 'old-signature';
|
|
var currentPlaylistFadeBetweenSlides = false;
|
|
var currentPlaylistSkipUnavailableRtmp = false;
|
|
var currentPlaylistEtag = '';
|
|
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
|
|
var index = 1;
|
|
var timer = null;
|
|
var slideExpiresAt = null;
|
|
var pausedRemainingMs = null;
|
|
var app = null;
|
|
var activeSlidesCacheKey = '';
|
|
var activeSlidesCacheValue = [];
|
|
var renderCacheViewportKey = '';
|
|
var slideMarkupCache = Object.create(null);
|
|
var templateLayoutCache = Object.create(null);
|
|
var templateRenderPlanCache = Object.create(null);
|
|
`;
|
|
vm.runInNewContext(prelude + '\n' + playbackScript, sandbox, { filename: scriptPath });
|
|
|
|
await sandbox.refresh();
|
|
|
|
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
|
assert.ok(sandbox.pendingPlaylistUpdate);
|
|
assert.equal(JSON.stringify(sandbox.slides), JSON.stringify([{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }]));
|
|
assert.equal(calls.showCurrent, 0);
|
|
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
|
|
});
|
|
|
|
test('player applies animation delays and schedules outro triggers to finish at slide advance', () => {
|
|
const animationPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-animation.js');
|
|
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
|
|
const values = {};
|
|
const classes = new Set();
|
|
const config = JSON.stringify({
|
|
intro: { preset: 'fadeIn', duration_ms: 600, delay_ms: 250 },
|
|
outro: { preset: 'hinge', duration_ms: 700, delay_ms: 400 }
|
|
});
|
|
const element = {
|
|
dataset: {},
|
|
isConnected: true,
|
|
style: {
|
|
setProperty(name, value) { values[name] = value; },
|
|
removeProperty(name) { delete values[name]; }
|
|
},
|
|
classList: {
|
|
add(...names) { names.forEach(name => classes.add(name)); },
|
|
remove(...names) { names.forEach(name => classes.delete(name)); },
|
|
[Symbol.iterator]() { return classes[Symbol.iterator](); }
|
|
},
|
|
getAttribute(name) { return name === 'data-animation-json' ? config : null; },
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
querySelectorAll() { return []; }
|
|
};
|
|
const root = { querySelectorAll() { return [element]; } };
|
|
const timers = [];
|
|
const presetDurations = {
|
|
bounceIn: '750ms',
|
|
flipOutY: '750ms',
|
|
heartBeat: '1.3s',
|
|
hinge: '2s',
|
|
futurePreset: '1.7s'
|
|
};
|
|
const sandbox = {
|
|
window: null,
|
|
app: { querySelectorAll() { return [root]; }, firstElementChild: root },
|
|
document: {
|
|
body: {
|
|
appendChild(probe) { probe.parentNode = this; },
|
|
removeChild(probe) { probe.parentNode = null; }
|
|
},
|
|
createElement() {
|
|
const probeClasses = new Set();
|
|
return {
|
|
parentNode: null,
|
|
classList: { add(...names) { names.forEach(name => probeClasses.add(name)); } },
|
|
style: { setProperty() {} },
|
|
classes: probeClasses
|
|
};
|
|
}
|
|
},
|
|
getComputedStyle(probe) {
|
|
const presetClass = Array.from(probe.classes).find(name => name.indexOf('animate__') === 0 && name !== 'animate__animated');
|
|
return { animationDuration: presetDurations[presetClass.replace('animate__', '')] || '1s' };
|
|
},
|
|
setTimeout(callback, delay) { timers.push({ callback, delay }); return timers.length; },
|
|
clearTimeout() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
vm.runInNewContext(fs.readFileSync(animationPath, 'utf8') + '\n' + fs.readFileSync(transitionPath, 'utf8'), sandbox);
|
|
|
|
assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'bounceIn', duration_ms: 1000 }), 750);
|
|
assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'flipOutY', duration_ms: 1000 }), 750);
|
|
assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'heartBeat', duration_ms: 1000 }), 1300);
|
|
assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'hinge', duration_ms: 1000 }), 2000);
|
|
assert.equal(sandbox.getAnimationStepTimingMs({ preset: 'futurePreset', duration_ms: 1000 }), 1700);
|
|
|
|
sandbox.playRegionAnimation(element, 'intro');
|
|
assert.equal(values['animation-delay'], '250ms');
|
|
|
|
sandbox.scheduleSlideOutro(5000);
|
|
assert.equal(timers[0].delay, 3200);
|
|
timers[0].callback();
|
|
assert.equal(values['--animate-duration'], '700ms');
|
|
assert.equal(values['animation-delay'], undefined);
|
|
}); |