fix(live): preserve channels after empty projection rebuild (#18)
Treat successful but empty live projections with existing live input as stale, reject them on the frontend, and keep the previous visible channel state during rebuild failures. Add DOM coverage for the Settings analysis flow and backend serialization coverage for empty projection state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
257
test/liveAnalysisUserFlowDom.test.js
Normal file
257
test/liveAnalysisUserFlowDom.test.js
Normal file
@ -0,0 +1,257 @@
|
||||
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('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);
|
||||
});
|
||||
@ -3,6 +3,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { isAdmin } = require('../server/authz');
|
||||
const { serializeState } = require('../server/routes/live');
|
||||
|
||||
const routeSource = fs.readFileSync(path.join(__dirname, '..', 'server', 'routes', 'live.js'), 'utf8');
|
||||
|
||||
@ -19,3 +20,19 @@ test('projection authorization distinguishes viewer and admin roles', () => {
|
||||
assert.equal(isAdmin({ role: 'admin' }), true);
|
||||
assert.equal(isAdmin(null), false);
|
||||
});
|
||||
|
||||
test('projection success with live input but zero projected channels is exposed as stale', () => {
|
||||
const state = serializeState({
|
||||
status: 'success',
|
||||
started_at: 100,
|
||||
completed_at: 200,
|
||||
input_hash: 'hash',
|
||||
projected_count: 0,
|
||||
processed_count: 3,
|
||||
total_count: 3,
|
||||
error: null
|
||||
});
|
||||
assert.equal(state.stale, true);
|
||||
assert.equal(state.projectedCount, 0);
|
||||
assert.equal(state.total, 3);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user