const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { JSDOM } = require('jsdom'); const root = path.resolve(__dirname, '..'); function readPublicScript(relativePath, exposeGlobal = '') { const source = fs.readFileSync(path.join(root, 'public', relativePath), 'utf8'); return exposeGlobal ? `${source}\nwindow.${exposeGlobal} = ${exposeGlobal};` : source; } function createDom() { const dom = new JSDOM(`
`, { url: 'http://nodecast.test/', runScripts: 'outside-only', pretendToBeVisual: true }); const { window } = dom; window.clearInterval = window.clearInterval.bind(window); window.IntersectionObserver = class { observe() {} disconnect() {} }; window.console = console; window.eval(readPublicScript('js/icons.js')); window.eval(readPublicScript('js/services/channelLanguageDetection.js')); window.eval(readPublicScript('js/services/channelLanguageFusion.js')); window.eval(readPublicScript('js/services/channelVariantGrouping.js')); window.eval(readPublicScript('js/services/liveChannelProjection.js')); window.eval(readPublicScript('js/services/layaStatusPresentation.js')); window.eval(readPublicScript('js/services/liveAnalysisPresentation.js')); window.eval(readPublicScript('js/components/ChannelList.js', 'ChannelList')); window.eval(readPublicScript('js/pages/Settings.js', 'SettingsPage')); return { window }; } function projectedChannels() { return { projection: { status: 'success', stale: false }, channels: [{ id: 'logical:sports-one', fingerprint: 'sports one', displayName: 'Sports One', variants: [ { id: 'fr-sd', sourceId: 1, name: '|FR| Sports One SD', groupTitle: 'Sports France', categoryName: 'Sports France', streamIcon: 'fr.png', streamUrl: 'https://streams.test/fr-sd.m3u8', tvgId: 'sports.one.fr', isFavorite: true, isHidden: false }, { id: 'en-4k', sourceId: 1, name: '|UK| Sports One 4K', groupTitle: 'UK Sports', categoryName: 'UK Sports', streamIcon: 'en.png', streamUrl: 'https://streams.test/en-4k.m3u8', tvgId: 'sports.one.uk', isFavorite: false, isHidden: false } ] }] }; } function waitFor(condition, message) { return new Promise((resolve, reject) => { const started = Date.now(); const poll = () => { if (condition()) return resolve(); if (Date.now() - started > 1500) return reject(new Error(message)); setTimeout(poll, 5); }; poll(); }); } test('Settings renders Laya batch progress and removes misleading values when its total is unknown', () => { const { window } = createDom(); const settingsPage = Object.create(window.SettingsPage.prototype); settingsPage.app = { player: { settings: { languageDetectionMode: 'hybrid' } } }; settingsPage.layaStatus = { modelPresent: true, runtimeAvailable: true }; settingsPage.renderLiveAnalysisStatus({ status: 'building', phase: 'laya_identity', layaProcessed: 3, layaTotal: 8, processed: 0, total: 39348 }); const track = window.document.getElementById('live-analysis-progress-track'); const bar = window.document.getElementById('live-analysis-progress-bar'); assert.equal(window.document.getElementById('live-analysis-status').textContent, 'Analyzing with local AI…'); assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'AI batch 3 of 8 · 38%'); assert.equal(track.getAttribute('aria-valuenow'), '38'); assert.equal(bar.style.width, '38%'); settingsPage.renderLiveAnalysisStatus({ status: 'building', phase: 'laya_identity', layaProcessed: 0, layaTotal: null, processed: 0, total: 39348 }); assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'In progress…'); assert.equal(track.classList.contains('indeterminate'), true); assert.equal(track.hasAttribute('aria-valuenow'), false); assert.equal(bar.style.width, ''); }); test('analysis completion reloads the persisted projection', async () => { const { window } = createDom(); let rebuildClicked = false; let projectionReloadStarted = false; let releaseProjectionReload; const projectionReload = new Promise(resolve => { releaseProjectionReload = resolve; }); window.API = { live: { getProjectionStatus: async () => rebuildClicked ? { status: 'success', phase: 'complete', processed: 128, total: 128, projectedCount: 64 } : { status: 'idle', processed: 0, total: 128 }, rebuildProjection: async () => { rebuildClicked = true; return { projection: { status: 'building', phase: 'laya_identity', layaProcessed: 4, layaTotal: 4, processed: 0, total: 128 } }; } }, laya: { getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }) } }; const app = { player: { settings: { languageDetectionMode: 'laya' }, saveSettings() {} }, channelList: { loadChannels: async () => { projectionReloadStarted = true; await projectionReload; }, refreshLayaLanguages: async () => assert.fail('Browser-side Laya refresh must not run') } }; window.app = app; const settingsPage = Object.create(window.SettingsPage.prototype); settingsPage.app = app; settingsPage.layaStatus = { modelPresent: true, runtimeAvailable: true }; settingsPage.initLiveAnalysis(); await Promise.resolve(); window.document.getElementById('live-analysis-button').click(); try { await waitFor(() => projectionReloadStarted, 'Expected persisted projection reload after analysis completion'); assert.equal(window.document.getElementById('live-analysis-status').textContent, 'Analysis complete'); } finally { releaseProjectionReload(); window.clearInterval(settingsPage.liveAnalysisPollTimer); } }); test('user analysis click reloads persisted languages without a browser-side Laya batch', async () => { const { window } = createDom(); let rebuildClicked = 0; let layaBatchCalls = 0; let channelLoads = 0; window.API = { live: { getChannels: async () => { channelLoads += 1; return projectedChannels(); }, rebuildProjection: async () => { rebuildClicked += 1; return { projection: { status: 'building', processed: 0, total: 2 } }; }, getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 }) }, laya: { getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), detectLanguages: async () => { layaBatchCalls += 1; throw new Error('simulated local AI language failure'); } } }; const app = { player: { settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' }, saveSettings() {} }, epgGuide: null, navigateTo() {}, pages: { settings: { switchTab() {} } } }; window.app = app; const channelList = new window.ChannelList(); channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }]; app.channelList = channelList; await channelList.loadChannels(); channelList.expandAll(); const beforeNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); assert.deepEqual(beforeNames, ['Sports One', 'Sports One']); assert.equal(window.document.querySelector('.empty-state'), null); app.player.settings.languageDetectionMode = 'hybrid'; const settingsPage = Object.create(window.SettingsPage.prototype); settingsPage.app = app; settingsPage.initLiveAnalysis(); await Promise.resolve(); window.document.getElementById('live-analysis-button').click(); assert.equal(rebuildClicked, 1); await new Promise(resolve => setTimeout(resolve, 1100)); channelList.expandAll(); window.clearInterval(channelList._epgRefreshTimer); window.clearInterval(settingsPage.liveAnalysisPollTimer); const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); const languageBadges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent); const groupNames = [...window.document.querySelectorAll('.group-name')].map(item => item.textContent); assert.equal(channelLoads, 2); assert.equal(layaBatchCalls, 0); assert.deepEqual(afterNames, ['Sports One', 'Sports One']); assert.deepEqual([...new Set(languageBadges)].sort(), ['EN 100%', 'FR 100%']); assert.deepEqual(groupNames, ['Favorites', 'Sports France']); assert.equal(window.document.querySelector('.empty-state'), null); assert.equal(window.document.querySelector('#channel-list > .loading'), null); assert.equal(channelList.languageDetectionFallback, false); assert.equal(channelList.logicalChannels[0].variants.length, 2); }); test('hybrid analysis completion relies on persisted projection without Standard browser fallback', async () => { const { window } = createDom(); let rebuildClicked = 0; let layaBatchCalls = 0; window.API = { live: { getChannels: async () => projectedChannels(), rebuildProjection: async () => { rebuildClicked += 1; return { projection: { status: 'building', processed: 0, total: 2 } }; }, getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 }) }, laya: { getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), detectLanguages: async () => { layaBatchCalls += 1; throw new Error('simulated local AI language failure'); } } }; const app = { player: { settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' }, saveSettings() {} }, epgGuide: null, navigateTo() {}, pages: { settings: { switchTab() {} } } }; window.app = app; const channelList = new window.ChannelList(); channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }]; app.channelList = channelList; await channelList.loadChannels(); channelList.expandAll(); assert.deepEqual( [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent), ['Sports One', 'Sports One'] ); app.player.settings.languageDetectionMode = 'hybrid'; const settingsPage = Object.create(window.SettingsPage.prototype); settingsPage.app = app; settingsPage.initLiveAnalysis(); await Promise.resolve(); window.document.getElementById('live-analysis-button').click(); assert.equal(rebuildClicked, 1); await new Promise(resolve => setTimeout(resolve, 1100)); channelList.expandAll(); window.clearInterval(channelList._epgRefreshTimer); window.clearInterval(settingsPage.liveAnalysisPollTimer); const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); assert.equal(layaBatchCalls, 0); assert.deepEqual(afterNames, ['Sports One', 'Sports One']); assert.equal(window.document.querySelector('.empty-state'), null); assert.equal(window.document.querySelector('#channel-list > .loading'), null); assert.equal(window.document.querySelector('.laya-language-notice'), null); assert.equal(channelList.languageDetectionFallback, false); }); test('successful Laya language batch updates visible variant languages without Standard fallback', async () => { const { window } = createDom(); let layaBatchCalls = 0; let batchPayload = null; window.API = { live: { getChannels: async () => projectedChannels(), rebuildProjection: async () => ({ projection: { status: 'success', processed: 2, total: 2, projectedCount: 1 } }), getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 }) }, laya: { getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), detectLanguages: async items => { layaBatchCalls += 1; batchPayload = items; return { runtimeAvailable: true, model: 'multilingual', results: items.map(item => ({ id: item.id, language: item.id.endsWith('fr-sd') ? 'en' : 'fr', confidence: item.id.endsWith('fr-sd') ? 0.91 : 0.87 })) }; } } }; const app = { player: { settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' }, saveSettings() {} }, epgGuide: null, navigateTo() {}, pages: { settings: { switchTab() {} } } }; window.app = app; const channelList = new window.ChannelList(); channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }]; app.channelList = channelList; await channelList.loadChannels(); app.player.settings.languageDetectionMode = 'laya'; await channelList.refreshLayaLanguages('laya'); channelList.expandedVariants.add('sports one'); channelList.expandAll(); const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent); const evidenceTypes = channelList.logicalChannels .flatMap(channel => channel.variants) .flatMap(variant => variant.languageEvidence.map(item => item.type)); window.clearInterval(channelList._epgRefreshTimer); assert.equal(layaBatchCalls, 1); assert.equal(JSON.stringify(batchPayload.map(item => item.id).sort()), JSON.stringify(['1:en-4k', '1:fr-sd'])); assert.deepEqual([...new Set(badges)].sort(), ['EN 91%', 'FR 87%']); assert.ok(evidenceTypes.every(type => type === 'laya')); assert.equal(channelList.languageDetectionFallback, false); assert.equal(channelList.languageDetectionNotice, ''); assert.equal(window.document.querySelector('.laya-language-notice'), null); }); test('usable Laya language labels do not leave visible variants as unknown', async () => { const { window } = createDom(); window.API = { live: { getChannels: async () => projectedChannels() }, laya: { getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), detectLanguages: async items => ({ runtimeAvailable: true, model: 'multilingual', results: items.map(item => ({ id: item.id, language: item.id.endsWith('fr-sd') ? 'French' : 'English', confidence: 0.91 })) }) } }; const app = { player: { settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'laya' }, saveSettings() {} }, epgGuide: null, navigateTo() {}, pages: { settings: { switchTab() {} } } }; window.app = app; const channelList = new window.ChannelList(); channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }]; app.channelList = channelList; await channelList.loadChannels(); await channelList.refreshLayaLanguages('laya'); channelList.expandAll(); const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent); window.clearInterval(channelList._epgRefreshTimer); assert.deepEqual([...new Set(badges)].sort(), ['EN 91%', 'FR 91%']); assert.equal(channelList.languageDetectionFallback, false); assert.equal(channelList.languageDetectionNotice, ''); }); test('analysis click does not replace visible channels with an empty successful projection', async () => { const { window } = createDom(); let rebuildClicked = 0; let channelLoads = 0; window.API = { live: { getChannels: async () => { channelLoads += 1; if (channelLoads === 1) return projectedChannels(); return { projection: { status: 'success', stale: false, projectedCount: 0, processed: 3, total: 3 }, channels: [] }; }, rebuildProjection: async () => { rebuildClicked += 1; return { projection: { status: 'building', processed: 0, total: 3 } }; }, getProjectionStatus: async () => ({ status: 'success', processed: 3, total: 3, projectedCount: 0 }) }, laya: { getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), detectLanguages: async () => { throw new Error('simulated local AI language failure'); } } }; const app = { player: { settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'hybrid' }, saveSettings() {} }, epgGuide: null, navigateTo() {}, pages: { settings: { switchTab() {} } } }; window.app = app; const channelList = new window.ChannelList(); channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider', enabled: true }]; app.channelList = channelList; await channelList.loadChannels(); channelList.expandAll(); const beforeNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); assert.deepEqual(beforeNames, ['Sports One', 'Sports One']); assert.equal(window.document.querySelector('.empty-state'), null); assert.equal(channelLoads, 1); const settingsPage = Object.create(window.SettingsPage.prototype); settingsPage.app = app; settingsPage.initLiveAnalysis(); await Promise.resolve(); window.document.getElementById('live-analysis-button').click(); assert.equal(rebuildClicked, 1); await new Promise(resolve => setTimeout(resolve, 1100)); channelList.expandAll(); window.clearInterval(channelList._epgRefreshTimer); window.clearInterval(settingsPage.liveAnalysisPollTimer); const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); const emptyState = window.document.querySelector('.empty-state'); const loading = window.document.querySelector('#channel-list > .loading'); assert.equal(channelLoads, 2); assert.deepEqual(afterNames, ['Sports One', 'Sports One']); assert.equal(emptyState, null); assert.equal(loading, null); assert.equal(channelList.channels.length, 2); });