Files
pulse-signage/test/playlists-duplicate.test.js
T
lzstealth 24f1603b43
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 2m2s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s
Release v2.18.0: template default content
2026-10-24 18:03:38 +01:00

315 lines
15 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
require('../src/common');
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('../src/web/routes/signage/playlists/duplicate');
const renderPlaylistFormPage = require('../src/web/routes/signage/playlists/add');
test('buildDuplicatePlaylist copies playlist fields for the create page', () => {
const duplicate = buildDuplicatePlaylist({
id: 12,
name: 'Lunch Loop',
fade_between_slides: 1,
skip_unavailable_rtmp: 0,
canvas_id: 7
}, buildDuplicatePlaylistName('Lunch Loop'));
assert.equal(duplicate.id, 12);
assert.equal(duplicate.name, 'Copy of Lunch Loop');
assert.equal(duplicate.fade_between_slides, true);
assert.equal(duplicate.skip_unavailable_rtmp, false);
assert.equal(duplicate.canvas_id, 7);
});
test('playlist duplicate form page reuses the copied playlist id for rows', async () => {
const html = await renderPlaylistFormPage(
{ id: 12, name: 'Copy of Lunch Loop', fade_between_slides: true, skip_unavailable_rtmp: false, canvas_id: 7 },
'Review the copied values and save when ready.',
{ permissionKeys: ['playlists.create'] },
{
playlists: [],
canvasSizes: [{ id: 7, name: 'HD', width: 1280, height: 720 }],
slides: [
{ id: 3, title: 'Welcome', template_id: 8, content_json: '{}', thumbnail_path: '', canvas_size_id: 7, canvas_width: 1280, canvas_height: 720 }
],
playlistSlides: [
{ id: 21, playlist_id: 12, position: 0, duration_seconds: 10, use_video_duration: 0, disable_audio: 1, slide_id: 3, title: 'Welcome', content_json: '{}', thumbnail_path: '', canvas_size_id: 7, canvas_width: 1280, canvas_height: 720, scheduleRules: [] }
]
},
null
);
assert.match(html, /Copy of Lunch Loop/);
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', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'playlists', 'list.hbs'), 'utf8');
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('schedule rules use the same native AdminLTE icons as slide regions', () => {
const root = path.join(__dirname, '..', 'src', 'web');
const sources = [
fs.readFileSync(path.join(root, 'views', 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'),
fs.readFileSync(path.join(root, 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8'),
fs.readFileSync(path.join(root, 'public', 'js', 'slides', 'slide-form-regions.js'), 'utf8')
];
for (const source of sources) {
assert.match(source, /<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"><\/i>/);
assert.match(source, /<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"><\/i>/);
assert.doesNotMatch(source, /data-schedule-rule-collapse-icon|getCollapseRuleIcon/);
}
});
test('schedule rule accessibility and drafts follow completed collapse and expand events', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
const syncSource = source.match(/function syncRuleCollapseButtonState\(card\) \{[\s\S]*?\n \}/)[0];
const handlerSource = source.match(/function handleRuleCollapseComplete\(event\) \{[\s\S]*?\n \}/)[0];
const listeners = {};
const attributes = {};
const label = { textContent: '' };
const button = {
setAttribute(name, value) { attributes[name] = value; },
querySelector() { return label; }
};
let collapsed = false;
let persisted = 0;
const card = {
classList: { contains() { return collapsed; } },
closest() { return card; }
};
const sandbox = {
getCollapseRuleButton() { return button; },
getRuleNumberNode() { return { textContent: '1' }; },
persistScheduleDraft() { persisted += 1; },
scheduleRuleList: { addEventListener(name, handler) { listeners[name] = handler; } }
};
const registrations = source.match(/ scheduleRuleList.addEventListener\('collapsed\.lte\.card-widget'[^\n]+\n scheduleRuleList.addEventListener\('expanded\.lte\.card-widget'[^\n]+/)[0];
require('node:vm').runInNewContext(syncSource + '\n' + handlerSource + '\n' + registrations, sandbox);
for (const isCollapsed of [true, false, true]) {
collapsed = isCollapsed;
listeners[isCollapsed ? 'collapsed.lte.card-widget' : 'expanded.lte.card-widget']({ target: card });
assert.equal(attributes['aria-expanded'], String(!isCollapsed));
assert.equal(attributes['aria-label'], (isCollapsed ? 'Expand' : 'Collapse') + ' rule 1');
assert.equal(label.textContent, attributes['aria-label']);
}
assert.equal(persisted, 3);
});
test('removing a playlist group removes all of its member rows', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
const functionSource = source.match(/function removePlaylistGroup\(header\) \{[\s\S]*?\n \}/)[0];
const removedRows = [];
let removedHeader = false;
let refreshedOrder = false;
const rows = [0, 1, 2].map((index) => ({ remove() { removedRows.push(index); } }));
const header = {
getAttribute(name) { return name === 'data-group-key' ? 'group-1' : null; },
remove() { removedHeader = true; }
};
const removeGroup = require('node:vm').runInNewContext('(' + functionSource + ')', {
getGroupRows(groupKey) {
assert.equal(groupKey, 'group-1');
return rows;
},
updateRowOrder(refresh) {
refreshedOrder = refresh;
}
});
removeGroup(header);
assert.deepEqual(removedRows, [0, 1, 2]);
assert.equal(removedHeader, true);
assert.equal(refreshedOrder, true);
});
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');
});