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 } ] }] }; } test('user analysis click keeps visible channels after Local AI language failure', async () => { const { window } = createDom(); let rebuildClicked = 0; let layaBatchCalls = 0; let channelLoads = 0; const consoleWarnings = []; window.console = { ...console, warn: (...args) => { consoleWarnings.push(args.join(' ')); console.warn(...args); } }; 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 notice = window.document.querySelector('.laya-language-notice'); 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, 1); assert.ok(notice); assert.match(notice.textContent, /Local AI language detection failed/); 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, true); assert.equal(channelList.logicalChannels[0].variants.length, 2); assert.ok(consoleWarnings.some(line => line.includes('Local AI language detection unavailable'))); }); 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); });