Files
Nodecast/test/t39FullRefreshAndVariantRegression.test.js
blomios 8f2825f697 feat(live): show language-scoped stream variants
Keep live stream variant expansion scoped to the selected language while preserving the legacy all-language variants response when no language is provided.

Display projected current EPG programme data in live rows and distinguish selected streams from actively playing streams in the collapsed variant UI.

Refs #43

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-08 19:00:14 +02:00

231 lines
9.4 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 Database = require('better-sqlite3');
const {
ensureColumns,
LIVE_PROJECTION_COLUMNS,
LIVE_PROJECTION_SCHEMA_SQL
} = require('../server/db/liveProjectionSchema');
const liveProjection = require('../server/services/liveProjectionService');
const root = path.resolve(__dirname, '..');
const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
function createDb() {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE categories (
id TEXT PRIMARY KEY,
source_id INTEGER NOT NULL,
category_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
parent_id TEXT,
is_hidden INTEGER DEFAULT 0,
data JSON
);
CREATE TABLE playlist_items (
id TEXT PRIMARY KEY,
source_id INTEGER NOT NULL,
item_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
category_id TEXT,
parent_id TEXT,
stream_icon TEXT,
stream_url TEXT,
container_extension TEXT,
rating REAL,
year TEXT,
added_at TEXT,
is_hidden INTEGER DEFAULT 0,
is_favorite INTEGER DEFAULT 0,
data JSON
);
CREATE TABLE favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
source_id INTEGER NOT NULL,
item_id TEXT NOT NULL,
item_type TEXT NOT NULL
);
`);
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
return db;
}
function seedLive(db, id, name) {
db.prepare(`
INSERT INTO playlist_items
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data)
VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, ?)
`).run(
`1:${id}`,
id,
name,
`https://streams.test/${id}.m3u8`,
JSON.stringify({ groupTitle: 'Sports' })
);
}
function createChannelListDom() {
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"></button>
<div id="channel-language-filter"></div>
<div id="channel-list-status"></div>
<div id="context-menu"></div>
<div id="channel-list"></div>
`, { url: 'http://nodecast.test/', runScripts: 'outside-only', pretendToBeVisual: true });
const { window } = dom;
window.IntersectionObserver = class { observe() {} disconnect() {} };
window.eval(script('js/icons.js'));
window.eval(script('js/services/channelLanguageDetection.js'));
window.eval(script('js/services/channelLanguageFusion.js'));
window.eval(script('js/services/channelVariantGrouping.js'));
window.eval(script('js/services/layaStatusPresentation.js'));
window.eval(script('js/services/liveChannelProjection.js'));
window.eval(`${script('js/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`);
window.app = {
player: { settings: { liveChannelListMode: 'flat', languageDetectionMode: 'algo' } },
epgGuide: { getCurrentProgram: () => null, loadError: null }
};
const channelList = new window.ChannelList();
channelList.sources = [{ id: 1, type: 'xtream', name: 'Fixture source', enabled: true }];
return { dom, window, channelList };
}
test('full refresh analyzeAll runs Laya language detection after identity and persists recalculated languages', async () => {
const db = createDb();
seedLive(db, 'bein-east', 'beIN Sports 1 East');
seedLive(db, 'bein-west', 'beIN Sports 1 West');
const order = [];
const languagePayloads = [];
const layaClient = {
identityHintBatch: async pairs => {
order.push('identity');
return pairs.map(pair => ({
id: pair.id,
sameIdentityProbability: 0.9,
confidence: 0.9,
family: 'bein sports',
number: '1'
}));
},
detectBatch: async payload => {
order.push('language');
languagePayloads.push(payload);
return {
results: payload.items.map(item => ({
id: item.id,
language: 'fr',
confidence: 0.97
}))
};
}
};
try {
await liveProjection.rebuildWithSignals(db, { mode: 'laya', layaClient, analyzeAll: true });
const rows = db.prepare(`
SELECT id, language_code, language_detector, language_confidence
FROM playlist_items
WHERE type = 'live'
ORDER BY id
`).all();
assert.deepEqual(order, ['identity', 'language']);
assert.equal(languagePayloads.length, 1);
assert.deepEqual(rows.map(row => row.language_code), ['fr', 'fr']);
assert.deepEqual(rows.map(row => row.language_detector), ['laya', 'laya']);
assert.ok(rows.every(row => Number(row.language_confidence) === 0.97));
} finally {
db.close();
}
});
test('search result variant expansion keeps language and distinguishes selected from playing stream', async () => {
const { dom, window, channelList } = createChannelListDom();
let variantsRequestedFor = null;
let variantRequestOptions = null;
window.API = {
live: {
getChannels: async options => {
assert.equal(options.q, 'bein sports 1');
return {
projection: { stale: false },
filters: { availableLanguages: [{ code: 'fr', short: 'FR', label: 'French' }] },
channels: [{
id: 'logical:bein-sports-1:fr',
logicalChannelId: 'logical:bein-sports-1',
displayName: 'beIN Sports 1',
language: { code: 'fr', short: 'FR', label: 'French', confidence: 97 },
selectedVariantId: '1:bein-4k',
variantCount: 3,
variants: [{
id: 'bein-hd',
sourceId: 1,
name: 'beIN Sports 1 HD',
categoryName: 'Sports',
isHidden: false,
language: { code: 'fr', short: 'FR', label: 'French', confidence: 97 }
}]
}]
};
},
getChannelVariants: async (logicalChannelId, options) => {
variantsRequestedFor = logicalChannelId;
variantRequestOptions = options;
if (logicalChannelId !== 'logical:bein-sports-1') return { variants: [], page: { hasMore: false } };
return {
page: { hasMore: false },
variants: [
{ id: 'bein-hd', sourceId: 1, name: 'beIN Sports 1 HD', categoryName: 'Sports', isHidden: false },
{ id: 'bein-4k', sourceId: 1, name: 'beIN Sports 1 4K', categoryName: 'Sports', isHidden: false },
{ id: 'bein-backup', sourceId: 1, name: 'beIN Sports 1 Backup', categoryName: 'Sports', isHidden: false }
]
};
}
}
};
try {
channelList.searchInput.value = 'bein sports 1';
await channelList.loadChannels();
const toggle = window.document.querySelector('.variant-toggle');
assert.ok(toggle, 'search result should expose a stream list toggle');
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal(toggle.getAttribute('aria-label'), 'Show 3 streams for beIN Sports 1');
toggle.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
await wait(20);
assert.equal(variantsRequestedFor, 'logical:bein-sports-1');
assert.equal(variantRequestOptions.language, 'fr');
assert.match(channelList.container.textContent, /beIN Sports 1 4K/);
assert.equal(channelList.container.querySelector('.variant-playing-badge'), null);
const selected = channelList.container.querySelector('.variant-selected-badge');
assert.equal(selected?.textContent, 'Selected');
assert.equal(selected?.closest('.channel-variant')?.dataset.variantId, 'bein-4k');
window.app.player.currentChannel = { id: 'bein-4k', sourceId: 1 };
channelList.render();
const playing = channelList.container.querySelector('.variant-playing-badge');
assert.equal(playing?.textContent, 'Playing');
assert.equal(playing?.closest('.channel-variant')?.dataset.variantId, 'bein-4k');
assert.equal(channelList.container.querySelector('.variant-selected-badge'), null);
assert.equal(channelList.container.querySelector('.variant-toggle').getAttribute('aria-label'), 'Hide streams for beIN Sports 1');
} finally {
dom.window.close();
}
});