const test = require('node:test'); const assert = require('node:assert/strict'); require('../src/common'); const { buildPlaylistFormViewModel } = require('../src/web/routes/signage/playlists/form-view-model'); test('playlist add and edit forms share the saved-image preview and accessible thumbnail buttons', () => { const { renderView } = require('../src/web/view'); for (const isEdit of [false, true]) { const html = renderView('playlists/form', { isEdit, playlist: { id: 3, name: 'Preview test' }, selectableSlides: [], playlistSlideTemplate: { scheduleRules: [] }, playlistSlides: [{ slide_id: 124, title: 'Landscape', thumbnail_path: '/media/thumbnails/slides/slide-124.png', canvas_width: 1920, canvas_height: 1080, scheduleRules: [] }] }); assert.equal((html.match(/id="slide-preview-modal"/g) || []).length, 1); assert.match(html, /]*data-slide-preview-url="\/media\/thumbnails\/slides\/slide-124.png"[^>]*data-canvas-width="1920"[^>]*aria-label="Preview slide: Landscape"/); assert.match(html, /data-slide-preview-image/); assert.doesNotMatch(html, /data-slide-preview-frame/); } }); test('new playlist rows receive preview URLs, titles and canvas dimensions', () => { const fs = require('node:fs'); const vm = require('node:vm'); const source = fs.readFileSync(require.resolve('../src/web/public/js/playlists/playlist-schedule'), 'utf8'); const start = source.indexOf(' function populatePlaylistSlideRow('); const end = source.indexOf(' function createRow(', start); const sandbox = { escapeHtmlAttribute: value => value }; vm.runInNewContext(source.slice(start, end), sandbox); const attrs = {}; const thumb = { setAttribute(name, value) { attrs[name] = value; }, style: { setProperty() {} } }; const row = { setAttribute() {}, querySelector(selector) { return selector === '[data-playlist-slide-thumb]' ? thumb : null; } }; sandbox.populatePlaylistSlideRow(row, { slide_id: 125, title: 'Portrait', thumbnail_path: '/media/thumbnails/slides/slide-125.png', canvas_width: 1080, canvas_height: 1920, summary: 'Always visible' }, 'new-1'); assert.equal(attrs['data-slide-preview-url'], '/media/thumbnails/slides/slide-125.png'); assert.equal(attrs['data-slide-preview-title'], 'Portrait'); assert.equal(attrs['data-canvas-width'], '1080'); assert.equal(attrs['data-canvas-height'], '1920'); assert.equal(attrs['aria-label'], 'Preview slide: Portrait'); }); test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => { const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { formatScheduleRulesSummary } = require('../src/web/lib/playlist-schedule'); const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/playlists/playlist-schedule.js'), 'utf8'); const context = {}; vm.runInNewContext(source.slice(0, source.indexOf(' function parseScheduleBoundary(')) + 'globalThis.formatSummary = formatScheduleRulesSummary; })();', context); const cases = [ [[], 'Always visible'], [[{ days: [1, 3], start_time: '09:00', end_time: '17:00' }], 'Rule 1: Mon, Wed 09:00-17:00'], [[{ start_time: '09:00', end_time: '17:00' }], 'Rule 1: Any day 09:00-17:00'], [[{ start_time: '09:00', end_time: '17:00' }, { days: [5] }], 'Rule 1: Any day 09:00-17:00\nRule 2: Fri'], [[{ days: [1] }, { days: [5] }], 'Rule 1: Mon\nRule 2: Fri'], [[{ days: [1] }, { days: [3] }, { days: [5] }], '3 rules'], [[{ days: [1] }, { days: [2] }, { days: [3] }, { days: [4] }], '4 rules'], [[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1] }], 'Rule 1: Dates 2026-10-25 09:00 to 2026-10-26 17:00: Mon'], [[{ start_datetime: '2026-10-25T09:00' }], 'Rule 1: From 2026-10-25 09:00'], [[{ end_datetime: '2026-10-26T17:00' }], 'Rule 1: Until 2026-10-26 17:00'], [[{ start_datetime: new Date(2026, 0, 1, 0, 0), end_datetime: new Date(2026, 9, 30, 20, 0) }], 'Rule 1: Dates 2026-01-01 00:00 to 2026-10-30 20:00'], [[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1, 2], start_time: '09:00', end_time: '17:00' }], '1 rule'], [[{ days: [0, 1, 2, 3, 4, 5, 6], start_time: '09:00', end_time: '17:00' }, { days: [1, 2, 3, 4, 5], start_time: '18:00', end_time: '19:00' }], 'Rule 1: Sun, Mon, Tue, Wed, Thu, Fri, Sat 09:00-17:00\nRule 2: Mon, Tue, Wed, Thu, Fri 18:00-19:00'], [[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1, 2, 3] }], 'Rule 1: Dates 2026-10-25 09:00 to 2026-10-26 17:00: Mon, Tue, Wed'], [[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1, 2, 3, 4, 5] }], '1 rule'], [[{ days: [1] }, { start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1, 2, 3, 4, 5] }], '2 rules'], [[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1, 2], start_time: '09:00', end_time: '17:00' }, { days: [5] }], '2 rules'] ]; for (const [rules, expected] of cases) { assert.equal(formatScheduleRulesSummary(rules), expected); assert.equal(context.formatSummary(rules), expected); assert.equal(formatScheduleRulesSummary(JSON.stringify(rules)), expected); assert.equal(context.formatSummary(JSON.stringify(rules)), expected); } const css = fs.readFileSync(path.join(__dirname, '../src/web/public/css/theme-custom.css'), 'utf8'); assert.match(css, /\.playlist-schedule-summary \.chip\s*\{[^}]*display:\s*inline-block;[^}]*white-space:\s*pre-line;[^}]*overflow-wrap:\s*anywhere;/); }); test('live schedule validator accepts one datetime bound and still rejects reversed ranges', () => { const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/playlists/playlist-schedule.js'), 'utf8'); const start = source.lastIndexOf(' function validateScheduleRuleCard(card)'); const end = source.indexOf(' function updateLiveRuleValidity(card)', start); const fields = { days: [], startDatetime: { value: '2026-10-25T09:00' }, endDatetime: { value: '' }, startTime: { value: '' }, endTime: { value: '' } }; const context = { getRuleFields: () => fields, getRuleDayLabels: () => [], getDateRangeDayIndices: () => [0, 1, 2, 3, 4, 5, 6], clearRuleValidity() {}, setRuleError() {}, setDayInputError() {}, hasScheduleRuleOverlap: () => true }; vm.runInNewContext(source.slice(start, end) + '\nglobalThis.validateRule = validateScheduleRuleCard;', context); assert.equal(context.validateRule({}), true); fields.endDatetime.value = '2026-10-24T09:00'; assert.equal(context.validateRule({}), false); }); test('playlist rows show mute controls for RTMP slides', () => { const model = buildPlaylistFormViewModel( { id: 22, name: 'Playlist 22' }, { playlistSlides: [ { id: 1, playlist_id: 22, slide_id: 101, position: 1, title: 'Video slide', content_json: '{"videoRegion":{"type":"video","value":"/media/video.mp4"}}', duration_seconds: 10, use_video_duration: 0, disable_audio: 1 }, { id: 2, playlist_id: 22, slide_id: 102, position: 2, title: 'RTMP slide', content_json: '{"rtmpRegion":{"type":"rtmp","value":"rtmp://example/live"}}', duration_seconds: 10, use_video_duration: 0, disable_audio: 1 }, { id: 3, playlist_id: 22, slide_id: 103, position: 3, title: 'Image slide', content_json: '{"imageRegion":{"type":"image","value":"/media/image.png"}}', duration_seconds: 10, use_video_duration: 0, disable_audio: 1 } ], slides: [] }, '', null, { isEdit: true } ); assert.equal(model.playlistSlides[0].showMuteButton, true); assert.equal(model.playlistSlides[1].showMuteButton, true); assert.equal(model.playlistSlides[2].showMuteButton, false); }); test('playlist rows use the longest video region duration', () => { const model = buildPlaylistFormViewModel( { id: 23, name: 'Playlist 23' }, { playlistSlides: [ { id: 4, playlist_id: 23, slide_id: 104, position: 1, title: 'Multi-video slide', content_json: '{"firstVideo":{"type":"video","value":"/media/first.mp4","duration_seconds":12.5},"secondVideo":{"type":"video","value":"/media/second.mp4","duration_seconds":27.75},"textRegion":{"type":"text","value":"Hello"}}', duration_seconds: 10, use_video_duration: 1, disable_audio: 1 } ], slides: [] }, '', null, { isEdit: true } ); assert.equal(model.playlistSlides[0].showVideoDurationButton, true); assert.equal(model.playlistSlides[0].videoDurationSeconds, 27.75); assert.equal(model.playlistSlides[0].durationSeconds, 27.75); }); test('disabled canvas sizes are hidden for new playlists but retained for existing assignments', () => { const canvasSizes = [ { id: 1, name: 'Full HD', width: 1920, height: 1080, enabled: 1 }, { id: 2, name: 'Legacy', width: 1280, height: 720, enabled: 0 } ]; const data = { playlistSlides: [], slides: [], canvasSizes: canvasSizes }; const createModel = buildPlaylistFormViewModel({}, data, '', null, {}); assert.deepEqual(createModel.canvasSizes.map((size) => size.id), [1]); const editModel = buildPlaylistFormViewModel({ id: 12, canvas_id: 2 }, data, '', null, { isEdit: true }); assert.deepEqual(editModel.canvasSizes.map((size) => size.id), [1, 2]); assert.match(editModel.canvasSizes[1].label, /Disabled/); assert.equal(editModel.canvasSizes[1].selected, true); });