const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const { createPlayerPlaylistService } = require('../src/player/playlist'); function createFsStub() { const original = { readFile: fs.promises.readFile, writeFile: fs.promises.writeFile, mkdir: fs.promises.mkdir }; const calls = []; fs.promises.readFile = async function (filePath) { calls.push({ method: 'readFile', filePath }); if (filePath.endsWith('fallback.json')) { return JSON.stringify({ fromSnapshot: true, screen: { slug: 'test2' } }); } const error = new Error('missing'); error.code = 'ENOENT'; throw error; }; fs.promises.writeFile = async function (filePath, content) { calls.push({ method: 'writeFile', filePath, content }); }; fs.promises.mkdir = async function (dirPath, options) { calls.push({ method: 'mkdir', dirPath, options }); }; return { calls, restore() { fs.promises.readFile = original.readFile; fs.promises.writeFile = original.writeFile; fs.promises.mkdir = original.mkdir; } }; } test('buildScreenPlaylist assembles slides, templates, and derived values', async () => { const pool = { async query(sql, params) { if (sql.includes('FROM d_screens')) { assert.deepEqual(params, ['test2']); return [[{ id: 7, name: 'Screen 7', slug: 'test2', playlist_id: 22, modified_at: '2026-08-03T00:00:00.000Z' }]]; } if (sql.includes('FROM c_playlists')) { assert.deepEqual(params, [22]); return [[{ id: 22, name: 'Playlist 22', fade_between_slides: 1, skip_unavailable_rtmp: 0, modified_at: '2026-08-02T00:00:00.000Z' }]]; } if (sql.includes('FROM c_playlist_slides ps')) { return [[{ id: 101, title: 'Intro', template_id: 33, content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}', modified_at: '2026-08-03T00:00:01.000Z', position: 1, duration_seconds: 9, use_video_duration: 1, disable_audio: null, template_name: 'Template 33', canvas_size_name: 'HD', canvas_size_width: 1920, canvas_size_height: 1080, canvas_width: 1920, canvas_height: 1080 }]]; } if (sql.includes('FROM c_playlist_slide_schedule_rules')) { return [[ { id: 500, playlist_slide_id: 101, position: 1, start_datetime: null, end_datetime: null, start_time: '08:00', end_time: '12:00', schedule_days_json: '[1,2,3]' }, { id: 501, playlist_slide_id: 101, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' } ]]; } if (sql.includes('FROM c_templates st')) { assert.deepEqual(params, [[33]]); return [[{ id: 33, name: 'Template 33', canvas_size_id: 4, canvas_size_width: 1920, canvas_size_height: 1080, background_image_path: '/media/bg.png', background_color: '#111111', modified_at: '2026-08-03T00:00:02.000Z' }]]; } if (sql.includes('FROM c_template_regions')) { assert.deepEqual(params, [[33]]); return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]]; } throw new Error(`unexpected query: ${sql}`); } }; const common = { parseJsonSafe(value) { return JSON.parse(value); }, fetchRssFeedsData: async () => ({ rssFeeds: [{ id: 1, title: 'Feed 1' }] }), fetchRssFeedItemsByFeedId: async () => ([{ title: 'Item 1', link: 'https://example.com' }]), normalizeRssFeedItem(item) { return Object.assign({}, item, { normalized: true }); }, fetchApiSourcesData: async () => ({ apiSources: [{ id: 2, name: 'Source 2', last_response_json: '{"ok":true}' }] }), fetchTimetablesData: async () => ({ timetableGroups: [{ id: 3, name: 'Group 3' }] }) }; const fsStub = createFsStub(); try { const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' }); const payload = await service.buildScreenPlaylist('test2'); assert.equal(payload.screen.slug, 'test2'); assert.equal(payload.playlist.name, 'Playlist 22'); assert.equal(payload.slides[0].duration_seconds, 12.346); assert.equal(payload.slides[0].disable_audio, true); assert.equal(payload.slides[0].content.videoRegion.disable_audio, true); assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z'); 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].template.regions[0].label, 'Text Region'); assert.equal(payload.rssFeeds[0].items[0].normalized, true); assert.deepEqual(payload.apiSources[0].responseJson, { ok: true }); assert.deepEqual(payload.timetableGroups, [{ id: 3, name: 'Group 3' }]); assert.match(payload.revision, /^[a-f0-9]{40}$/); assert.ok(fsStub.calls.some((call) => call.method === 'writeFile')); } finally { fsStub.restore(); } }); test('buildScreenPlaylist falls back to a snapshot when queries fail', async () => { const pool = { async query() { throw new Error('database unavailable'); } }; const common = { parseJsonSafe() { throw new Error('not expected'); } }; const fsStub = createFsStub(); try { const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' }); const payload = await service.buildScreenPlaylist('fallback'); assert.deepEqual(payload, { fromSnapshot: true, screen: { slug: 'test2' } }); assert.ok(fsStub.calls.some((call) => call.method === 'readFile')); } finally { fsStub.restore(); } });