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, /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('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, / { 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'); });