Ensure Docker images keep their container-built native dependencies, wait for the Laya sidecar healthcheck, expose accurate Laya readiness, and treat empty AI language batches as fallback failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
391 lines
16 KiB
JavaScript
391 lines
16 KiB
JavaScript
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(`<!doctype html>
|
|
<input id="channel-search">
|
|
<select id="source-select"><option value="">All Sources</option></select>
|
|
<input id="show-hidden" type="checkbox">
|
|
<button id="toggle-groups" type="button"></button>
|
|
<div id="context-menu"></div>
|
|
<div id="channel-list"></div>
|
|
|
|
<button type="button" id="live-analysis-button">Analyze channels</button>
|
|
<div id="live-analysis-status" role="status"></div>
|
|
<div id="live-analysis-progress" hidden>
|
|
<div id="live-analysis-progress-track"><span id="live-analysis-progress-bar"></span></div>
|
|
<div id="live-analysis-progress-text"></div>
|
|
</div>
|
|
<div id="live-analysis-fallback"></div>
|
|
<button type="button" id="live-analysis-details-toggle" hidden></button>
|
|
<pre id="live-analysis-error-details" hidden></pre>
|
|
`, {
|
|
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('successful Standard fallback after Local AI failure keeps channels and reports the AI failure', 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);
|
|
const notice = window.document.querySelector('.laya-language-notice');
|
|
|
|
assert.equal(layaBatchCalls, 1);
|
|
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.ok(notice);
|
|
assert.match(notice.textContent, /Local AI language detection failed/);
|
|
assert.match(notice.textContent, /Standard matching/);
|
|
assert.equal(channelList.languageDetectionFallback, true);
|
|
});
|
|
|
|
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('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);
|
|
});
|