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.expandedVariants.add('sports one');
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.expandedVariants.add('sports one');
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);
});