Persist live channel language metadata across background projection rebuilds and target Local AI identity analysis to newly detected streams unless a manual full analysis is requested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
532 lines
21 KiB
JavaScript
532 lines
21 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
|
|
}
|
|
]
|
|
}]
|
|
};
|
|
}
|
|
|
|
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);
|
|
});
|