From 6dfd2ba0931480c4aae2816a30351b51ab2ab50a Mon Sep 17 00:00:00 2001 From: blomios Date: Mon, 5 Oct 2026 19:34:42 +0200 Subject: [PATCH 1/2] test: cover functional live detection chain with Laya fallback (#18) --- test/liveDetectionFunctionalChain.test.js | 166 ++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 test/liveDetectionFunctionalChain.test.js 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); +}); From 6bb1914906c7abc8cddc5805d204525adbbb6551 Mon Sep 17 00:00:00 2001 From: blomios Date: Mon, 5 Oct 2026 22:30:37 +0200 Subject: [PATCH 2/2] 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 --- package-lock.json | 534 +++++++++++++++++++- package.json | 3 + public/js/components/ChannelList.js | 32 +- public/js/services/liveChannelProjection.js | 12 +- server/routes/live.js | 3 +- test/liveAnalysisUserFlowDom.test.js | 257 ++++++++++ test/liveProjectionRoutes.test.js | 17 + 7 files changed, 844 insertions(+), 14 deletions(-) create mode 100644 test/liveAnalysisUserFlowDom.test.js diff --git a/package-lock.json b/package-lock.json index 3113d49..7595c62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "sax": "^1.4.3", "xml2js": "^0.6.2" }, + "devDependencies": { + "jsdom": "^22.1.0" + }, "engines": { "node": ">=18.0.0" }, @@ -175,6 +178,16 @@ "win32" ] }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/node": { "version": "10.17.60", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", @@ -182,6 +195,14 @@ "license": "MIT", "optional": true }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -199,8 +220,8 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -212,8 +233,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -230,8 +251,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -239,6 +260,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -414,6 +442,19 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -466,6 +507,34 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -475,6 +544,13 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -499,6 +575,16 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -527,6 +613,20 @@ "node": ">=8" } }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -586,6 +686,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -626,6 +739,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -756,6 +885,23 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -856,10 +1002,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -868,6 +1030,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -888,6 +1063,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/http-response-object": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", @@ -902,8 +1117,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -916,8 +1131,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -934,8 +1149,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.4.24", @@ -990,6 +1205,56 @@ "node": ">= 0.10" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -1210,6 +1475,13 @@ "node": ">=10" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -1264,6 +1536,19 @@ "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", "optional": true }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1397,6 +1682,19 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -1407,6 +1705,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1422,6 +1730,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -1484,6 +1799,20 @@ "node": ">= 6" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1516,6 +1845,19 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -1723,6 +2065,13 @@ "node": ">=0.10.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1760,6 +2109,35 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1804,6 +2182,16 @@ "node": ">= 0.8" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1813,6 +2201,17 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1837,12 +2236,118 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -1864,6 +2369,13 @@ "engines": { "node": ">=4.0" } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 504f49c..b5bd8f5 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ }, "engines": { "node": ">=18.0.0" + }, + "devDependencies": { + "jsdom": "^22.1.0" } } diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 6e90171..5af913d 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -906,6 +906,13 @@ class ChannelList { this.isLoading = true; this.currentRenderId = null; // Reset render tracking + const previousState = { + channels: this.channels, + groups: this.groups, + visibleFavorites: this.visibleFavorites, + hiddenItems: this.hiddenItems + }; + const sourceValue = this.sourceSelect.value; const self = this; @@ -943,7 +950,15 @@ class ChannelList { void this.refreshLayaLanguages(); } catch (err) { console.error('Error loading channels:', err); - this.container.innerHTML = `

Error loading channels

${err.message}

`; + if (previousState.channels.length > 0) { + this.channels = previousState.channels; + this.groups = previousState.groups; + this.visibleFavorites = previousState.visibleFavorites; + this.hiddenItems = previousState.hiddenItems; + this.render(); + } else { + this.container.innerHTML = `

Error loading channels

${err.message}

`; + } } finally { this.isLoading = false; } @@ -953,6 +968,12 @@ class ChannelList { * Load channels from all enabled sources */ async loadAllChannels() { + const previousState = { + channels: this.channels, + groups: this.groups, + visibleFavorites: this.visibleFavorites, + hiddenItems: this.hiddenItems + }; this.channels = []; this.groups = []; @@ -985,6 +1006,15 @@ class ChannelList { void this.refreshLayaLanguages(); } catch (err) { console.error('Error loading all channels:', err); + if (previousState.channels.length > 0) { + this.channels = previousState.channels; + this.groups = previousState.groups; + this.visibleFavorites = previousState.visibleFavorites; + this.hiddenItems = previousState.hiddenItems; + this.render(); + } else { + this.container.innerHTML = `

Error loading channels

${err.message}

`; + } } } diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js index f2a1e9f..a213a1b 100644 --- a/public/js/services/liveChannelProjection.js +++ b/public/js/services/liveChannelProjection.js @@ -43,7 +43,17 @@ } function canUse(response) { - return Boolean(response && Array.isArray(response.channels) && !response.projection?.stale); + if (!response || !Array.isArray(response.channels) || response.projection?.stale) return false; + + const projection = response.projection || {}; + const expectedChannelCount = [ + projection.total, + projection.processed, + projection.catalogCount, + projection.sourceChannelCount + ].some(count => Number(count) > 0); + + return response.channels.length > 0 || !expectedChannelCount; } return { canUse, mapResponse }; diff --git a/server/routes/live.js b/server/routes/live.js index 46a1f74..9c61b09 100644 --- a/server/routes/live.js +++ b/server/routes/live.js @@ -5,9 +5,10 @@ const liveProjection = require('../services/liveProjectionService'); const router = express.Router(); function serializeState(state) { + const emptyProjectionWithLiveInput = state.total_count > 0 && state.projected_count === 0; return { status: state.status, - stale: state.status !== 'success', + stale: state.status !== 'success' || emptyProjectionWithLiveInput, startedAt: state.started_at, completedAt: state.completed_at, inputHash: state.input_hash, diff --git a/test/liveAnalysisUserFlowDom.test.js b/test/liveAnalysisUserFlowDom.test.js new file mode 100644 index 0000000..d2c8b04 --- /dev/null +++ b/test/liveAnalysisUserFlowDom.test.js @@ -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(` + + + + +
+
+ + +
+ +
+ + + `, { + 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); +}); diff --git a/test/liveProjectionRoutes.test.js b/test/liveProjectionRoutes.test.js index e6eb85a..2832dce 100644 --- a/test/liveProjectionRoutes.test.js +++ b/test/liveProjectionRoutes.test.js @@ -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); +});