const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const test = require('node:test'); const assert = require('node:assert/strict'); function loadAnnouncementTemplateHelpers() { const sharedScriptPath = path.join(__dirname, '..', 'src', 'player', 'rendering-shared.js'); const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-announcement-templates.js'); const sharedScript = fs.readFileSync(sharedScriptPath, 'utf8'); const script = fs.readFileSync(scriptPath, 'utf8'); const sandbox = { window: {}, console }; sandbox.window = sandbox; vm.runInNewContext(sharedScript, sandbox, { filename: sharedScriptPath }); vm.runInNewContext(script, sandbox, { filename: scriptPath }); return sandbox; } function createTextNode(width) { return { width: width, attributes: {}, cloneNode() { return createTextNode(width); }, setAttribute(name, value) { this.attributes[name] = String(value); }, getBoundingClientRect() { return { width: this.width }; } }; } test('announcement marquee helper repeats enough content and measures a real cycle width', () => { const sandbox = loadAnnouncementTemplateHelpers(); assert.equal(typeof sandbox.initPlayerAnnouncementMarquee, 'function'); const textNodes = [createTextNode(100), createTextNode(100)]; const track = { dataset: {}, style: { values: {}, setProperty(name, value) { this.values[name] = String(value); } }, attributes: { 'data-announcement-marquee-rate': '50' }, parentElement: null, appended: [], getAttribute(name) { return this.attributes[name]; }, querySelectorAll() { return textNodes.slice(); }, appendChild(node) { this.appended.push(node); textNodes.push(node); return node; }, getBoundingClientRect() { const gap = 20; const width = textNodes.length * 100 + Math.max(0, textNodes.length - 1) * gap; return { width: width }; } }; const messageContainer = { getBoundingClientRect() { return { width: 420 }; } }; sandbox.window.getComputedStyle = function () { return { columnGap: '20px', gap: '20px' }; }; sandbox.initPlayerAnnouncementMarquee(track, { container: messageContainer }); assert.equal(track.dataset.marqueeMeasured, 'true'); assert.equal(track.style.values['--announcement-scroll-distance'], '120.00px'); assert.equal(track.style.values['--announcement-scroll-duration'], '8.00s'); assert.equal(track.style.animationDuration, '8.00s'); assert.ok(track.appended.length >= 3); }); test('announcement markup strips line breaks from the message', () => { const sandbox = loadAnnouncementTemplateHelpers(); sandbox.playerAnnouncementTemplates = { topBanner: '
{{MESSAGE}}
' }; const markup = sandbox.renderPlayerAnnouncementMarkup({ announcement_type: 'top-banner', message: 'First line\nSecond line\r\nThird line', short_label: 'Breaking News', color_key: 'primary', icon_key: 'info-circle' }); assert.match(markup, /First line Second line Third line/); assert.doesNotMatch(markup, //i); }); test('announcement fallback polling stops when the websocket opens and resumes when it closes', () => { const intervals = new Map(); const listeners = new Map(); let nextTimerId = 1; let announcementSocket = null; let announcementRequests = 0; class MockWebSocket { constructor() { this.readyState = MockWebSocket.CONNECTING; announcementSocket = this; } open() { this.readyState = MockWebSocket.OPEN; if (this.onopen) this.onopen(); } close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(); } } MockWebSocket.CONNECTING = 0; MockWebSocket.OPEN = 1; MockWebSocket.CLOSED = 3; class MockXhr { open() {} setRequestHeader() {} send() { announcementRequests += 1; } } const window = { slug: 'test', location: { origin: 'http://localhost:8081' }, WebSocket: MockWebSocket, setInterval(callback, delay) { const id = nextTimerId++; intervals.set(id, { callback: callback, delay: delay }); return id; }, clearInterval(id) { intervals.delete(id); }, setTimeout() { return nextTimerId++; }, clearTimeout() {}, addEventListener() {} }; const sandbox = { window: window, document: { readyState: 'loading', addEventListener(name, callback) { listeners.set(name, callback); } }, WebSocket: MockWebSocket, XMLHttpRequest: MockXhr, URL, Date, JSON, String, Array, Math }; const scriptPath = path.join(__dirname, '..', 'src', 'player', 'player-page-announcements.js'); vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath }); listeners.get('DOMContentLoaded')(); assert.equal(announcementRequests, 0); assert.equal(intervals.size, 1); assert.equal(Array.from(intervals.values())[0].delay, 15000); announcementSocket.open(); assert.equal(announcementRequests, 1); assert.equal(intervals.size, 0); announcementSocket.close(); assert.equal(intervals.size, 1); });