Release v2.15.2
This commit is contained in:
1 parent
ddcc665f81
commit
7295f83f04
43 files changed
+2526
-104
No files matched your search
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user