Release v2.15.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 4m34s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 57s

This commit is contained in:
lzstealth committed 2026-10-15 01:50:39 +01:00
1 parent ddcc665f81
commit 7295f83f04
43 files changed
+2526 -104

No files matched your search

+2 -2
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const { pruneStaleOnboardingDevices } = require('../src/db/common');
test('prunes old unbound onboarding devices without deleting completed bindings', async () => {
test('prunes old unbound pairings and stale screen bindings', async () => {
let queryText = '';
const pool = {
async query(sql) {
@@ -16,7 +16,7 @@ test('prunes old unbound onboarding devices without deleting completed bindings'
assert.match(queryText, /screen_id IS NULL/);
assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 15 MINUTE\)/);
assert.match(queryText, /last_seen_at < \(CURRENT_TIMESTAMP - INTERVAL 24 HOUR\)/);
assert.match(queryText, /COALESCE\(last_seen_at, modified_at\) < \(CURRENT_TIMESTAMP - INTERVAL 24 HOUR\)/);
assert.doesNotMatch(queryText, /d_players\.identifier = d_onboarding_devices\.device_id/);
});
+51
View File
@@ -1003,6 +1003,57 @@ test('player page arrow keys move between slides', async () => {
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,
+17
View File
@@ -63,6 +63,13 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
duration_seconds: 9,
use_video_duration: 1,
disable_audio: null,
group_key: 'news',
group_name: 'News',
group_pick_count: 2,
group_shuffle: 1,
group_mode: 'repeat',
group_repeat_count: 2,
group_schedule_rules_json: '[{"start_time":"08:00","end_time":"12:00","days":[1,2,3]}]',
template_name: 'Template 33',
canvas_size_name: 'HD',
canvas_size_width: 1920,
@@ -134,6 +141,16 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
assert.equal(typeof payload.slides[0].content.qrRegion.qr_preview, 'string');
assert.match(payload.slides[0].content.qrRegion.qr_preview, /^data:image\/svg\+xml/);
assert.equal(payload.slides[0].scheduleRules.length, 2);
assert.equal(payload.slides[0].groupKey, 'news');
assert.equal(payload.slides[0].groupName, 'News');
assert.equal(payload.slides[0].groupPickCount, 2);
assert.equal(payload.slides[0].groupShuffle, true);
assert.equal(payload.slides[0].groupMode, 'repeat');
assert.equal(payload.slides[0].groupRepeatCount, 2);
assert.equal(payload.slides[1].groupMode, 'select');
assert.equal(payload.slides[1].groupRepeatCount, 1);
assert.equal(payload.slides[1].groupShuffle, false);
assert.deepEqual(payload.slides[0].groupScheduleRules, [{ start_time: '08:00', end_time: '12:00', days: [1, 2, 3] }]);
assert.equal(payload.slides[0].template.regions[0].label, 'Text Region');
assert.equal(payload.rssFeeds[0].items[0].normalized, true);
assert.deepEqual(payload.apiSources[0].responseJson, { ok: true });
+93
View File
@@ -46,6 +46,99 @@ test('media preload cleanup releases unpromoted video elements on every early ex
}
});
test('playlist groups rotate in order, fill every pass, and optionally shuffle', () => {
const sandbox = {
window: { pulsePlayerRenderingShared: new Proxy({}, { get() { return function () {}; } }) },
playlistPassNumber: 0
};
const renderingScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js');
const playlistScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
loadScript(renderingScriptPath, sandbox);
loadScript(playlistScriptPath, sandbox);
const groupSlides = Array.from({ length: 5 }, function (_value, index) {
return { id: index + 1, groupKey: 'news', groupPickCount: 2 };
});
sandbox.slides = groupSlides.concat([{ id: 99 }]);
function selectedGroupIds() {
return Array.from(sandbox.getActiveSlidesFrom(sandbox.slides)
.filter(function (slide) { return slide.groupKey; })
.map(function (slide) { return slide.id; }));
}
const firstPass = selectedGroupIds();
assert.equal(firstPass.length, 2);
assert.deepEqual(selectedGroupIds(), firstPass);
sandbox.playlistPassNumber = 1;
const secondPass = selectedGroupIds();
assert.equal(secondPass.length, 2);
assert.equal(firstPass.some(function (id) { return secondPass.includes(id); }), false);
sandbox.playlistPassNumber = 2;
const thirdPass = selectedGroupIds();
assert.equal(thirdPass.length, 2);
assert.equal(new Set(firstPass.concat(secondPass, thirdPass)).size, 5);
assert.deepEqual(firstPass, [1, 2]);
assert.deepEqual(secondPass, [3, 4]);
assert.deepEqual(thirdPass, [5, 1]);
groupSlides.forEach(slide => { slide.groupShuffle = true; });
const shuffled = selectedGroupIds();
assert.equal(shuffled.length, 2);
assert.equal(new Set(shuffled).size, 2);
assert.deepEqual(selectedGroupIds(), shuffled);
const selections = new Set();
for (let pass = 0; pass < 10; pass += 1) {
sandbox.playlistPassNumber = pass;
const selection = selectedGroupIds();
assert.equal(selection.length, 2);
assert.equal(new Set(selection).size, 2);
selections.add(selection.join(','));
}
assert.ok(selections.size > 1);
sandbox.slides = groupSlides.slice(0, 3);
sandbox.slides.forEach(slide => { slide.groupShuffle = false; });
for (const [pass, expected] of [[0, [1, 2]], [1, [3, 1]], [2, [2, 3]]]) {
sandbox.playlistPassNumber = pass;
assert.deepEqual(selectedGroupIds(), expected);
}
sandbox.slides[0].scheduleRules = [{ start_datetime: '2000-01-01T00:00:00Z', end_datetime: '2000-01-02T00:00:00Z' }];
assert.deepEqual(selectedGroupIds(), [2, 3]);
sandbox.slides[1].groupPickCount = 99;
assert.equal(selectedGroupIds().length, 2);
sandbox.slides[2].scheduleRules = sandbox.slides[0].scheduleRules;
assert.deepEqual(selectedGroupIds(), [2]);
const now = new Date(2026, 9, 14, 10, 30);
const gatedSlide = {
groupKey: 'weekday',
groupScheduleRules: [{ start_time: '10:00', end_time: '11:00', days: [now.getDay()] }]
};
assert.equal(sandbox.isSlideActive(gatedSlide, now), true);
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 14, 12, 30)), false);
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 15, 10, 30)), false);
});
test('repeat groups play all eligible members in order and ignore Pick and shuffle', () => {
const sandbox = {
window: { pulsePlayerRenderingShared: new Proxy({}, { get() { return function () {}; } }) },
playlistPassNumber: 0
};
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js'), sandbox);
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
sandbox.slides = [{ id: 9 }].concat([1, 2, 3].map(id => ({
id, groupKey: 'news', groupMode: 'repeat', groupRepeatCount: 2, groupPickCount: 1, groupShuffle: true
})), [{ id: 10 }]);
const ids = () => Array.from(sandbox.getActiveSlidesFrom(sandbox.slides), slide => slide.id);
assert.deepEqual(ids(), [9, 1, 2, 3, 1, 2, 3, 10]);
sandbox.playlistPassNumber = 1;
assert.deepEqual(ids(), [9, 1, 2, 3, 1, 2, 3, 10]);
assert.equal(sandbox.hasSelectableGroupVariants(), false);
sandbox.slides[2].scheduleRules = [{ start_datetime: '2000-01-01T00:00:00Z', end_datetime: '2000-01-02T00:00:00Z' }];
assert.deepEqual(ids(), [9, 1, 3, 1, 3, 10]);
sandbox.slides.forEach(slide => { if (slide.groupKey) slide.groupRepeatCount = 1; });
assert.deepEqual(ids(), [9, 1, 3, 10]);
});
test('warmed video node is promoted into its slide instead of fetched again', () => {
const createdVideos = [];
let nextSource = '';
+181
View File
@@ -46,6 +46,14 @@ test('playlist duplicate form page reuses the copied playlist id for rows', asyn
assert.match(html, /data-playlist-id="12"/);
assert.match(html, /Welcome/);
assert.match(html, /Save and Close/);
assert.ok(html.indexOf('id="playlist-create-group"') < html.indexOf('id="playlist-open-slide-modal"'));
assert.doesNotMatch(html, /<th>Group<\/th>/);
assert.match(html, /name="group_key\[\]"/);
assert.match(html, /name="group_schedule_rules_json\[\]"/);
assert.match(html, /name="group_shuffle\[\]" value="0"/);
assert.match(html, /name="group_mode\[\]" value="select"/);
assert.match(html, /name="group_repeat_count\[\]" value="1"/);
assert.match(html, /name="duration_seconds\[\]" type="number" inputmode="decimal" min="1" step="any"/);
});
test('playlist list template includes duplicate action', () => {
@@ -53,4 +61,177 @@ test('playlist list template includes duplicate action', () => {
assert.match(template, /\/playlists\/\{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
test('playlist group colours differ, survive reorder, and clear on ungrouped slides', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
const functionSource = source.match(/function updatePlaylistGroupColours\(rows, blocks\) \{[\s\S]*?\n \}/)[0];
const updateColours = require('node:vm').runInNewContext('(' + functionSource + ')');
function makeRow(isHeader) {
const attributes = new Map();
return {
style: { setProperty(key, value) { this[key] = value; }, removeProperty(key) { delete this[key]; } },
getAttribute(key) { return attributes.get(key) || null; },
setAttribute(key, value) { attributes.set(key, value); },
matches() { return isHeader; }
};
}
const firstHeader = makeRow(true);
const secondHeader = makeRow(true);
firstHeader.setAttribute('data-group-colour', 'primary');
secondHeader.setAttribute('data-group-colour', 'secondary');
const firstMember = makeRow(false);
const secondMember = makeRow(false);
const blocks = [{ nodes: [firstHeader, firstMember] }, { nodes: [secondHeader, secondMember] }];
updateColours([firstMember, secondMember], blocks);
const firstColour = firstHeader.style['--playlist-group-accent'];
const secondColour = secondHeader.style['--playlist-group-accent'];
assert.notEqual(firstColour, secondColour);
assert.equal(firstHeader.getAttribute('data-group-colour'), 'success');
assert.equal(secondHeader.getAttribute('data-group-colour'), 'warning');
const paletteHeaders = Array.from({ length: 8 }, () => makeRow(true));
updateColours([], paletteHeaders.map(header => ({ nodes: [header] })));
paletteHeaders.forEach(header => {
assert.ok(['success', 'warning', 'info', 'danger'].includes(header.getAttribute('data-group-colour')));
});
assert.equal(firstMember.style['--playlist-group-accent'], firstColour);
assert.equal(secondMember.style['--playlist-group-bg'], secondHeader.style['--playlist-group-bg']);
updateColours([secondMember, firstMember], blocks.slice().reverse());
assert.equal(firstHeader.style['--playlist-group-accent'], firstColour);
assert.equal(secondHeader.style['--playlist-group-accent'], secondColour);
blocks[0].nodes = [firstHeader];
updateColours([firstMember, secondMember], blocks);
assert.equal(firstMember.style['--playlist-group-accent'], undefined);
assert.equal(firstMember.style['--playlist-group-bg'], undefined);
});
test('empty playlist groups keep a drop area until a member is added', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
const functionSource = source.match(/function updatePlaylistGroupEmptyRows\(\) \{[\s\S]*?\n \}/)[0];
const emptyRows = new Set();
const members = [];
const header = { getAttribute() { return 'new-group'; }, after(row) { emptyRows.add(row); } };
const tbody = { querySelectorAll() { return Array.from(emptyRows); } };
const updateEmptyRows = require('node:vm').runInNewContext('(' + functionSource + ')', {
document: {
getElementById() { return tbody; },
createElement() {
return {
attributes: {},
setAttribute(key, value) { this.attributes[key] = value; },
remove() { emptyRows.delete(this); }
};
}
},
getGroupHeaders() { return [header]; },
getGroupRows() { return members; }
});
updateEmptyRows();
assert.equal(emptyRows.size, 1);
const emptyRow = Array.from(emptyRows)[0];
assert.equal(emptyRow.attributes['data-group-key'], 'new-group');
assert.match(emptyRow.innerHTML, /colspan="5"/);
assert.doesNotMatch(emptyRow.innerHTML, /<input/);
updateEmptyRows();
assert.equal(emptyRows.size, 1);
members.push({});
updateEmptyRows();
assert.equal(emptyRows.size, 0);
members.pop();
updateEmptyRows();
assert.equal(emptyRows.size, 1);
});
test('group metadata clamps Pick to membership and propagates shuffle state', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
const functionSource = source.match(/function syncGroupRowMetadata\(header\) \{[\s\S]*?\n \}/)[0];
const pick = { value: '99' };
const controls = {
'[data-playlist-group-name]': { value: 'News' },
'[data-playlist-group-pick-count]': pick,
'[data-playlist-group-repeat-count]': { value: '2' },
'[data-playlist-group-shuffle]': { getAttribute() { return 'true'; } },
'[data-playlist-group-schedule-rules]': { value: '[]' }
};
let mode = 'select';
const header = { getAttribute(key) { return key === 'data-group-mode' ? mode : 'news'; }, querySelector(selector) { return controls[selector]; } };
const members = Array.from({ length: 3 }, () => {
const fields = new Map();
return {
classList: { add() {} }, setAttribute() {},
querySelector(selector) { if (!fields.has(selector)) fields.set(selector, {}); return fields.get(selector); }
};
});
const sync = require('node:vm').runInNewContext('(' + functionSource + ')', { getGroupRows() { return members; }, syncPlaylistGroupModeControls() {} });
sync(header);
assert.equal(pick.value, '3');
assert.equal(pick.max, '3');
assert.equal(members[0].querySelector('[data-playlist-group-shuffle-value]').value, '1');
assert.equal(members[0].querySelector('[data-playlist-group-mode-value]').value, 'select');
mode = 'repeat';
sync(header);
assert.equal(members[0].querySelector('[data-playlist-group-mode-value]').value, 'repeat');
assert.equal(members[0].querySelector('[data-playlist-group-repeat-count-value]').value, '2');
assert.equal(members[0].querySelector('[data-playlist-group-shuffle-value]').value, '1');
controls['[data-playlist-group-repeat-count]'].value = '101';
sync(header);
assert.equal(controls['[data-playlist-group-repeat-count]'].value, '100');
controls['[data-playlist-group-repeat-count]'].value = '0';
sync(header);
assert.equal(controls['[data-playlist-group-repeat-count]'].value, '1');
pick.value = '0';
sync(header);
assert.equal(pick.value, '1');
pick.value = '2.9';
sync(header);
assert.equal(pick.value, '2');
members.splice(1);
sync(header);
assert.equal(pick.value, '1');
assert.equal(pick.max, '1');
members.pop();
sync(header);
assert.equal(pick.disabled, true);
});
test('playlist numbering gives groups whole numbers and members hierarchical numbers', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
const functionSource = source.match(/function updateRowOrder\(markDirty\) \{[\s\S]*?\n \}/)[0];
function makeRow(groupKey, isHeader) {
const number = { textContent: '' };
const key = { value: groupKey };
return {
number, key,
matches() { return Boolean(isHeader); },
querySelector(selector) {
if (selector === '.playlist-order-number' || (isHeader && selector === '[data-playlist-group-order]')) return number;
if (selector === '[data-playlist-group-key]') return key;
return null;
}
};
}
const first = makeRow('');
const header = makeRow('news', true);
const members = [makeRow('news'), makeRow('news')];
const last = makeRow('');
const rows = [first, ...members, last];
const blocks = [{ key: '', nodes: [first] }, { key: 'news', nodes: [header, ...members] }, { key: '', nodes: [last] }];
const sandbox = {
getRows() { return rows; }, getGroupHeaders() { return [header]; }, getTopLevelPlaylistBlocks() { return blocks; }
};
for (const name of ['updatePlaylistGroupEmptyRows', 'syncGroupRowMetadata', 'updatePlaylistGroupColours', 'syncAddSlideOptions', 'updateGroupScheduleWarnings', 'syncPlaylistCanvasLock', 'syncAddSlideButtonState', 'updateEmptyState']) {
sandbox[name] = function () {};
}
const update = require('node:vm').runInNewContext('(' + functionSource + ')', sandbox);
update(false);
assert.deepEqual([first, header, ...members, last].map(row => row.number.textContent), ['1', '2', '2.1', '2.2', '3']);
blocks.unshift(blocks.splice(1, 1)[0]);
update(false);
assert.deepEqual([header, ...members, first, last].map(row => row.number.textContent), ['1', '1.1', '1.2', '2', '3']);
members[1].key.value = '';
blocks[0].nodes.pop();
blocks.splice(1, 0, { key: '', nodes: [members[1]] });
update(false);
assert.equal(members[1].number.textContent, '2');
assert.equal(last.number.textContent, '4');
});
+60 -5
View File
@@ -28,7 +28,8 @@ function createResponse() {
this.statusCode = code;
return this;
},
send() {
send(body) {
this.body = body;
return this;
}
};
@@ -36,12 +37,13 @@ function createResponse() {
test('playlist create and update redirect to the canonical edit path', async () => {
const calls = [];
let groupInsertParams = null;
const connection = {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async query(sql) {
async query(sql, params) {
if (sql.startsWith('INSERT INTO c_playlists')) {
return [{ insertId: 4 }];
}
@@ -53,6 +55,14 @@ test('playlist create and update redirect to the canonical edit path', async ()
calls.push('delete-slides');
return [{ affectedRows: 1 }];
}
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) {
return [[{ id: 11, canvas_size_id: 7 }]];
}
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
groupInsertParams = params;
calls.push('insert-slide');
return [{ insertId: 101 }];
}
return [[]];
}
};
@@ -73,7 +83,14 @@ test('playlist create and update redirect to the canonical edit path', async ()
fetchOrderedPlaylistSlides: async () => [],
fetchScreensByPlaylistId: async () => [],
fetchPlaylistCanvasId: async () => null,
readArrayField: () => [],
readArrayField(body, keys) {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(body, key)) {
return Array.isArray(body[key]) ? body[key] : [body[key]];
}
}
return [];
},
getAuditUserId: () => 12,
redirectAfterSave(req, res, url) {
calls.push({ kind: 'redirectAfterSave', url, path: req.path });
@@ -110,17 +127,55 @@ test('playlist create and update redirect to the canonical edit path', async ()
params: { id: '4' },
body: {
name: 'Lunch Loop',
slide_id: []
slide_id: ['11'],
row_key: ['row-11'],
duration_seconds: ['10'],
use_video_duration: ['0'],
disable_audio: ['1'],
group_key: ['news'],
group_name: ['News'],
group_pick_count: ['1'],
group_shuffle: ['1'],
group_mode: ['repeat'],
group_repeat_count: ['2'],
group_schedule_rules_json: [JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }])]
}
}, updateRes, () => {});
assert.equal(createRes.redirectedTo, '/playlists/4/edit');
assert.equal(updateRes.redirectedTo, '/playlists/4/edit');
assert.equal(updateRes.redirectedTo, '/playlists/4/edit', updateRes.statusCode + ': ' + String(updateRes.body || ''));
assert.deepEqual(groupInsertParams.slice(6, 10), [
'news',
'News',
1,
JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }])
]);
assert.equal(groupInsertParams[10], 1);
assert.equal(groupInsertParams[11], 'repeat');
assert.equal(groupInsertParams[12], 2);
assert.deepEqual(calls, [
'delete-slides',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
'update-playlist',
'delete-slides',
'insert-slide',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
]);
for (const pickCount of ['0', '2']) {
const invalidRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4',
params: { id: '4' },
body: { name: 'Lunch Loop', slide_id: ['11'], group_key: ['news'], group_name: ['News'], group_pick_count: [pickCount] }
}, invalidRes, () => {});
assert.equal(invalidRes.statusCode, 400);
}
for (const settings of [{ group_mode: ['unknown'] }, { group_repeat_count: ['0'] }, { group_repeat_count: ['101'] }, { group_repeat_count: ['1.5'] }]) {
const invalidRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4', params: { id: '4' },
body: Object.assign({ name: 'Lunch Loop', slide_id: ['11'], group_key: ['news'], group_name: ['News'], group_pick_count: ['1'] }, settings)
}, invalidRes, () => {});
assert.equal(invalidRes.statusCode, 400);
}
});
+47 -1
View File
@@ -44,11 +44,57 @@ test('pending migrations are empty when the schema already matches the app versi
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.13.1' });
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: require('../package.json').version });
assert.equal(pendingMigrations.length, 0);
});
test('playlist shuffle migration upgrades an existing 2.15.0 installation', async () => {
const pool = createPool([]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.0' });
const shuffleMigration = pending.find(migration => migration.version === '2.15.1');
assert.ok(shuffleMigration);
await shuffleMigration.run(pool);
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_shuffle')));
});
test('playlist repeat migration upgrades an existing 2.15.1 installation', async () => {
const pool = createPool([]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.1' });
assert.equal(pending.length, 1);
assert.equal(pending[0].version, '2.15.2');
await pending[0].run(pool);
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_mode')));
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_repeat_count')));
});
test('playlist group schema migration is pending for version 2.14.3', async () => {
const pool = createPool([
{
match(sql) {
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
},
result: [[{ column_count: 1 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_schedule_groups';
},
result: [[{ table_count: 0 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_schedule_entries';
},
result: [[{ table_count: 0 }]]
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.14.3' });
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.15.0'; }));
});
test('pending migrations are reported when an older schema still needs scripts', async () => {
const pool = createPool([
{
+49
View File
@@ -5,6 +5,55 @@ require('../src/common');
const registerManageRoutes = require('../src/web/routes/admin/manage');
test('force-deleting a screen requires a verified empty live-connection list', async () => {
const handlers = {};
const queries = [];
let connectionResponse = { connections: [{ id: 'active-1' }] };
const app = {
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
get() {}
};
registerManageRoutes(app, {
pool: {
async query(sql, params) {
queries.push({ sql, params });
return [[]];
}
},
common: { async fetchScreenById() { return { id: 7, name: 'Demo', slug: 'demo' }; } },
pages: {},
getScreenDeleteBlockMessage: async () => 'This screen is still linked to onboarding devices.',
getScreenConnections: async () => connectionResponse,
requirePermission() { return function (_req, _res, next) { next(); }; }
});
const handler = handlers['/screens/:id/delete'][1];
const createResponse = function () {
return {
redirect(url) { this.redirectedTo = url; },
status(code) { this.statusCode = code; return this; },
send(body) { this.body = body; }
};
};
const activeResponse = createResponse();
await handler({ params: { id: '7' }, body: { force_delete: '1' } }, activeResponse, () => {});
assert.match(decodeURIComponent(activeResponse.redirectedTo), /still in use by connected players/);
assert.equal(queries.some(function (query) { return /DELETE FROM d_screens/.test(query.sql); }), false);
connectionResponse = { connections: [], degraded: true };
const unverifiableResponse = createResponse();
await handler({ params: { id: '7' }, body: { force_delete: '1' } }, unverifiableResponse, () => {});
assert.match(decodeURIComponent(unverifiableResponse.redirectedTo), /Unable to verify/);
assert.equal(queries.some(function (query) { return /DELETE FROM d_screens/.test(query.sql); }), false);
connectionResponse = { connections: [] };
const idleResponse = createResponse();
await handler({ params: { id: '7' }, body: { force_delete: '1' } }, idleResponse, () => {});
assert.equal(queries.some(function (query) { return /DELETE FROM d_screens/.test(query.sql); }), true);
});
test('screen update keeps the existing slug on edit', async () => {
const handlers = {};
const app = {
+15
View File
@@ -259,6 +259,21 @@ test('screen edit page does not render a player URL table', async () => {
assert.match(html, /Slug cannot be changed after creation/);
});
test('screen edit page offers force delete for disconnected onboarding bindings', () => {
const html = renderScreenEditPage(
{ id: 7, name: 'Demo Conference', slug: 'demo-conference', canForceDelete: true },
{ playlists: [] },
'',
{ id: 1 }
);
assert.match(html, /Force delete/);
assert.match(html, /name="force_delete" value="1"/);
assert.match(html, /Force delete this screen group and unlink its onboarding devices\?/);
assert.ok(html.indexOf('form="screen-force-delete-form"') < html.indexOf('class="btn-group save-action-group"'));
assert.match(html, /class="ms-auto">\s*<div class="btn-group save-action-group"/);
});
test('screen launcher downloads are shared and attached', async () => {
const handlers = {};
const app = {