const test = require('node:test'); const assert = require('node:assert/strict'); 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 frontendProjection = require('../public/js/services/liveChannelProjection.js'); const grouping = require('../public/js/services/channelVariantGrouping.js'); const { channelListWarning } = require('../public/js/services/layaStatusPresentation.js'); function createFunctionalDb() { 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, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, source_id, item_id, item_type) ); `); ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); db.exec(LIVE_PROJECTION_SCHEMA_SQL); return db; } function seedCategory(db, categoryId, name, extra = {}) { db.prepare(` INSERT INTO categories (id, source_id, category_id, type, name, is_hidden, data) VALUES (?, 1, ?, 'live', ?, ?, '{}') `).run(`1:${categoryId}`, categoryId, name, extra.isHidden ? 1 : 0); } function seedChannel(db, itemId, name, categoryId, extra = {}) { db.prepare(` INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_icon, stream_url, is_hidden, data) VALUES (?, 1, ?, 'live', ?, ?, ?, ?, ?, ?) `).run( `1:${itemId}`, itemId, name, categoryId, `${itemId}.png`, `https://streams.test/${itemId}.m3u8`, extra.isHidden ? 1 : 0, JSON.stringify({ tvgId: extra.tvgId || null, groupTitle: extra.groupTitle || undefined }) ); } test('functional live detection chain keeps categories and language selection correct after Laya fallback', async () => { const db = createFunctionalDb(); seedCategory(db, 'sports-fr', 'Sports France'); seedCategory(db, 'sports-uk', 'UK Sports'); seedCategory(db, 'events', 'Sports Events'); seedChannel(db, 'fr-sd', '|FR| Sports One SD', 'sports-fr', { tvgId: 'sports.one.fr' }); seedChannel(db, 'fr-4k-hidden', '|FR| Sports One 4K', 'sports-fr', { isHidden: true, tvgId: 'sports.one.fr.4k' }); seedChannel(db, 'en-4k', '|UK| Sports One 4K', 'sports-uk', { tvgId: 'sports.one.uk' }); seedChannel(db, 'bein-main', 'beIN Sports 1 HD', 'events'); seedChannel(db, 'bein-event', 'Partner beIN Sports 1 Event', 'events'); db.prepare(` INSERT INTO favorites (user_id, source_id, item_id, item_type) VALUES (7, 1, 'fr-sd', 'channel') `).run(); let layaRequests = 0; const state = await liveProjection.rebuildWithSignals(db, { mode: 'hybrid', layaClient: { identityHintBatch: async () => { layaRequests += 1; throw new Error('simulated local AI failure'); } } }); assert.equal(layaRequests, 1); assert.equal(state.status, 'success'); assert.equal(state.error, null); const rawRows = db.prepare(` SELECT item_id, logical_channel_id, grouping_fingerprint FROM playlist_items WHERE item_id IN ('fr-sd', 'fr-4k-hidden', 'en-4k') ORDER BY item_id `).all(); assert.deepEqual(rawRows.map(row => row.item_id), ['en-4k', 'fr-4k-hidden', 'fr-sd']); assert.equal(new Set(rawRows.map(row => row.logical_channel_id)).size, 1); assert.deepEqual([...new Set(rawRows.map(row => row.grouping_fingerprint))], ['sports one']); const apiChannels = liveProjection.getChannels({ includeHidden: true, userId: 7 }, db); const sportsOne = apiChannels.find(channel => channel.fingerprint === 'sports one'); assert.ok(sportsOne); assert.deepEqual( sportsOne.variants .map(variant => [variant.id, variant.categoryName, variant.isHidden, variant.isFavorite]) .sort((left, right) => left[0].localeCompare(right[0])), [ ['en-4k', 'UK Sports', false, false], ['fr-4k-hidden', 'Sports France', true, false], ['fr-sd', 'Sports France', false, true] ] ); const mapped = frontendProjection.mapResponse({ projection: { status: 'success', stale: false }, channels: apiChannels }, [{ id: 1, type: 'm3u', name: 'Fixture Provider France' }]); const warning = channelListWarning('hybrid', { modelPresent: true, runtimeAvailable: true }, true); const logical = grouping.groupChannels(mapped.channels, { languageMode: warning ? 'algo' : 'hybrid', preferredLanguage: 'fr', isFavorite: variant => mapped.favorites.has(`${variant.sourceId}:${variant.id}`), isHidden: variant => mapped.hiddenItems.has(`channel:${variant.sourceId}:${variant.streamId || variant.id}`) }); const frontendSportsOne = logical.find(channel => channel.fingerprint === 'sports one'); assert.equal( warning, 'Local AI language detection failed. Channels were grouped with Standard matching and remain available.' ); assert.ok(frontendSportsOne); assert.equal(frontendSportsOne.variants.length, 3); assert.deepEqual(frontendSportsOne.availableLanguages.map(language => language.code).sort(), ['en', 'fr']); assert.equal(frontendSportsOne.preferredVariant.id, 'fr-sd'); assert.equal(frontendSportsOne.preferredVariant.groupTitle, 'Sports France'); assert.equal(frontendSportsOne.isFavorite, true); assert.equal(frontendSportsOne.isHidden, false); assert.equal(grouping.orderFlatChannels(logical).length, logical.length); });