Apply remaining changes

This commit is contained in:
2026-08-05 21:57:16 +01:00
parent a6a5d71ef0
commit b9dd4178c4
10 changed files with 469 additions and 20 deletions
+271
View File
@@ -37,6 +37,7 @@ test('playlist refresh queues updates until the next slide transition', async ()
timer: null,
slideExpiresAt: null,
pausedRemainingMs: null,
app: null,
currentPlaylistEtag: '',
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
@@ -117,6 +118,7 @@ test('playlist refresh queues updates until the next slide transition', async ()
var timer = null;
var slideExpiresAt = null;
var pausedRemainingMs = null;
var app = null;
var activeSlidesCacheKey = '';
var activeSlidesCacheValue = [];
var renderCacheViewportKey = '';
@@ -133,4 +135,273 @@ test('playlist refresh queues updates until the next slide transition', async ()
assert.equal(sandbox.slides[0].disable_audio, undefined);
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('single-slide playlists re-render the active slide instead of refreshing after a queued update', 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() {},
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 commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const playbackScript = fs.readFileSync(scriptPath, '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' + 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('removing the currently visible slide from a two-slide playlist applies the one-slide update 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() {},
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, 'next-signature');
assert.equal(sandbox.pendingPlaylistUpdate, null);
assert.deepEqual(sandbox.slides, [{ id: 1, duration_seconds: 12, disable_audio: false }]);
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), false);
});
+124
View File
@@ -0,0 +1,124 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
function createAppAndHandlers() {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
return { app, handlers };
}
function createResponse() {
return {
redirectedTo: '',
statusCode: 200,
redirect(url) {
this.redirectedTo = url;
},
status(code) {
this.statusCode = code;
return this;
},
send() {
return this;
}
};
}
test('playlist create and update redirect to the canonical edit path', async () => {
const calls = [];
const connection = {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async query(sql) {
if (sql.startsWith('INSERT INTO c_playlists')) {
return [{ insertId: 4 }];
}
if (sql.startsWith('UPDATE c_playlists SET')) {
calls.push('update-playlist');
return [{ affectedRows: 1 }];
}
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
calls.push('delete-slides');
return [{ affectedRows: 1 }];
}
return [[]];
}
};
const { app, handlers } = createAppAndHandlers();
registerPlaylistRoutes(app, {
pool: {
async getConnection() {
return connection;
}
},
common: {
async fetchDuplicateName() { return null; },
async fetchCanvasSizeById() { return { id: 7 }; },
async fetchPlaylistById() { return { id: 4, canvas_id: 7 }; }
},
pages: {},
fetchOrderedPlaylistSlides: async () => [],
fetchScreensByPlaylistId: async () => [],
fetchPlaylistCanvasId: async () => null,
readArrayField: () => [],
getAuditUserId: () => 12,
redirectAfterSave(req, res, url) {
calls.push({ kind: 'redirectAfterSave', url, path: req.path });
res.redirect(url);
},
notifyPlayerScreens: async () => {},
broadcastDashboardState: async () => {},
getPlaylistDeleteBlockMessage: async () => '',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const createHandlers = handlers['/playlists'];
const updateHandlers = handlers['/playlists/:id'];
assert.equal(Array.isArray(createHandlers), true);
assert.equal(Array.isArray(updateHandlers), true);
const createRes = createResponse();
await createHandlers[1]({
path: '/playlists',
body: {
name: 'Lunch Loop',
canvas_size_id: '7',
slide_id: []
}
}, createRes, () => {});
const updateRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4',
params: { id: '4' },
body: {
name: 'Lunch Loop',
slide_id: []
}
}, updateRes, () => {});
assert.equal(createRes.redirectedTo, '/playlists/4/edit');
assert.equal(updateRes.redirectedTo, '/playlists/4/edit');
assert.deepEqual(calls, [
'delete-slides',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
'update-playlist',
'delete-slides',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
]);
});