diff --git a/test/liveDetectionFunctionalChain.test.js b/test/liveDetectionFunctionalChain.test.js new file mode 100644 index 0000000..5583419 --- /dev/null +++ b/test/liveDetectionFunctionalChain.test.js @@ -0,0 +1,166 @@ +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); +});