Merge branch 'fix/t25-live-search-performance' into develop (#25)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1214,9 +1214,9 @@
|
|||||||
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
|
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
|
||||||
<script src="/js/services/layaStatusPresentation.js?v=5"></script>
|
<script src="/js/services/layaStatusPresentation.js?v=5"></script>
|
||||||
<script src="/js/services/liveAnalysisPresentation.js?v=1"></script>
|
<script src="/js/services/liveAnalysisPresentation.js?v=1"></script>
|
||||||
<script src="/js/services/liveChannelProjection.js?v=2"></script>
|
<script src="/js/services/liveChannelProjection.js?v=3"></script>
|
||||||
<script src="/js/services/channelVariantGrouping.js?v=7"></script>
|
<script src="/js/services/channelVariantGrouping.js?v=7"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=12"></script>
|
<script src="/js/components/ChannelList.js?v=13"></script>
|
||||||
<script src="/js/components/SourceManager.js?v=4"></script>
|
<script src="/js/components/SourceManager.js?v=4"></script>
|
||||||
<script src="/js/components/EpgGuide.js?v=3"></script>
|
<script src="/js/components/EpgGuide.js?v=3"></script>
|
||||||
<script src="/js/pages/HomePage.js?v=2"></script>
|
<script src="/js/pages/HomePage.js?v=2"></script>
|
||||||
|
|||||||
@ -189,13 +189,14 @@ class ChannelList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Search handler (debounced)
|
// Search locally whenever a catalog is already available.
|
||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
this.searchInput.addEventListener('input', () => {
|
this.searchInput.addEventListener('input', () => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
this.loadChannels({ refreshLanguages: false });
|
if (this.projectedLogicalChannels || this.channels.length) this.render();
|
||||||
}, 300);
|
else this.loadChannels({ refreshLanguages: false });
|
||||||
|
}, this.projectedLogicalChannels || this.channels.length ? 75 : 250);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Source filter handler
|
// Source filter handler
|
||||||
@ -205,7 +206,18 @@ class ChannelList {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', event => {
|
document.addEventListener('keydown', event => {
|
||||||
if (event.key === 'Escape') this.closeLanguagePanel();
|
if (event.key !== 'Escape') return;
|
||||||
|
const panel = this.languageFilter?.querySelector('.language-more-panel');
|
||||||
|
if (panel && !panel.hidden) {
|
||||||
|
this.closeLanguagePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.searchInput?.value) {
|
||||||
|
this.searchInput.value = '';
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
this.render();
|
||||||
|
this.searchInput.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.toggleGroupsBtn?.addEventListener('click', () => {
|
this.toggleGroupsBtn?.addEventListener('click', () => {
|
||||||
@ -496,15 +508,10 @@ class ChannelList {
|
|||||||
const groupedChannels = {};
|
const groupedChannels = {};
|
||||||
|
|
||||||
// 1. Filter
|
// 1. Filter
|
||||||
this.filteredChannels = this.logicalChannels;
|
this.filteredChannels = this.logicalChannels.filter(channel => {
|
||||||
if (searchTerm && !this.projectedLogicalChannels) {
|
const languageMatches = !this.selectedLanguages.size || this.selectedLanguages.has(channel.language?.code);
|
||||||
this.filteredChannels = this.logicalChannels.filter(ch =>
|
return languageMatches && LiveChannelProjection.matchesSearch(channel, searchTerm);
|
||||||
String(ch.displayName ?? '').toLowerCase().includes(searchTerm) ||
|
});
|
||||||
ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) ||
|
|
||||||
ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm)) ||
|
|
||||||
ch.variants.some(variant => `${variant.language.code} ${variant.language.label}`.toLowerCase().includes(searchTerm))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let filteredChannels = this.filteredChannels;
|
let filteredChannels = this.filteredChannels;
|
||||||
if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`;
|
if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`;
|
||||||
@ -525,7 +532,7 @@ class ChannelList {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Add Favorites
|
// 3. Add Favorites
|
||||||
const favoritedChannels = this.logicalChannels.filter(ch => ch.isFavorite);
|
const favoritedChannels = this.filteredChannels.filter(ch => ch.isFavorite);
|
||||||
if (favoritedChannels.length > 0) {
|
if (favoritedChannels.length > 0) {
|
||||||
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
|
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
groupedChannels['Favorites'] = favoritedChannels;
|
groupedChannels['Favorites'] = favoritedChannels;
|
||||||
@ -1057,33 +1064,22 @@ class ChannelList {
|
|||||||
|
|
||||||
async loadProjectedChannels(sourceId = null) {
|
async loadProjectedChannels(sourceId = null) {
|
||||||
try {
|
try {
|
||||||
let response = await API.live.getChannels({
|
const response = await API.live.getChannels({
|
||||||
sourceId,
|
sourceId,
|
||||||
includeHidden: true,
|
includeHidden: true,
|
||||||
q: this.searchInput?.value?.trim() || '',
|
q: '',
|
||||||
languages: [...this.selectedLanguages]
|
languages: []
|
||||||
});
|
});
|
||||||
if (!LiveChannelProjection.canUse(response)) {
|
if (!LiveChannelProjection.canUse(response)) {
|
||||||
this.projectedLogicalChannels = null;
|
this.projectedLogicalChannels = null;
|
||||||
this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.';
|
this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
||||||
this.channels = mapped.channels;
|
this.channels = mapped.channels;
|
||||||
this.projectedLogicalChannels = mapped.logicalChannels;
|
this.projectedLogicalChannels = mapped.logicalChannels;
|
||||||
this.availableLanguages = mapped.availableLanguages;
|
this.availableLanguages = mapped.availableLanguages;
|
||||||
if (this.reconcileLanguageSelection()) {
|
this.reconcileLanguageSelection();
|
||||||
response = await API.live.getChannels({
|
|
||||||
sourceId,
|
|
||||||
includeHidden: true,
|
|
||||||
q: this.searchInput?.value?.trim() || '',
|
|
||||||
languages: [...this.selectedLanguages]
|
|
||||||
});
|
|
||||||
mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
|
||||||
this.channels = mapped.channels;
|
|
||||||
this.projectedLogicalChannels = mapped.logicalChannels;
|
|
||||||
this.availableLanguages = mapped.availableLanguages;
|
|
||||||
}
|
|
||||||
this.renderLanguageFilter();
|
this.renderLanguageFilter();
|
||||||
this.visibleFavorites = mapped.favorites;
|
this.visibleFavorites = mapped.favorites;
|
||||||
this.hiddenItems = mapped.hiddenItems;
|
this.hiddenItems = mapped.hiddenItems;
|
||||||
@ -1127,7 +1123,8 @@ class ChannelList {
|
|||||||
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
|
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
|
||||||
} catch {}
|
} catch {}
|
||||||
this.renderLanguageFilter();
|
this.renderLanguageFilter();
|
||||||
this.loadChannels({ refreshLanguages: false });
|
if (this.projectedLogicalChannels || this.channels.length) this.render();
|
||||||
|
else this.loadChannels({ refreshLanguages: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
closeLanguagePanel() {
|
closeLanguagePanel() {
|
||||||
|
|||||||
@ -3,6 +3,26 @@
|
|||||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||||
if (root) root.LiveChannelProjection = service;
|
if (root) root.LiveChannelProjection = service;
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
function normalizeSearch(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||||
|
.split(/\s+/).filter(Boolean)
|
||||||
|
.map(token => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSearch(channel, query) {
|
||||||
|
const wanted = normalizeSearch(query);
|
||||||
|
if (!wanted.length) return true;
|
||||||
|
const corpus = normalizeSearch([
|
||||||
|
channel.displayName, channel.standardName, channel.fingerprint,
|
||||||
|
...(channel.variants || []).flatMap(variant => [
|
||||||
|
variant.name, variant.tvgId, variant.categoryName, variant.groupTitle
|
||||||
|
])
|
||||||
|
].filter(Boolean).join(' '));
|
||||||
|
return wanted.every(token => corpus.some(candidate => candidate.includes(token)));
|
||||||
|
}
|
||||||
|
|
||||||
function mapResponse(response, sources = []) {
|
function mapResponse(response, sources = []) {
|
||||||
if (!response || !Array.isArray(response.channels)) {
|
if (!response || !Array.isArray(response.channels)) {
|
||||||
throw new Error('Invalid live channel projection response');
|
throw new Error('Invalid live channel projection response');
|
||||||
@ -89,5 +109,5 @@
|
|||||||
return response.channels.length > 0 || !expectedChannelCount;
|
return response.channels.length > 0 || !expectedChannelCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { canUse, mapResponse };
|
return { canUse, mapResponse, matchesSearch, normalizeSearch };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -46,13 +46,9 @@ router.get('/channels', requireAuth, async (req, res) => {
|
|||||||
state = await liveProjection.startRebuild().promise;
|
state = await liveProjection.startRebuild().promise;
|
||||||
}
|
}
|
||||||
const epgSources = await sources.getByType('epg');
|
const epgSources = await sources.getByType('epg');
|
||||||
const allChannels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id, epgSources });
|
const { channels, availableLanguages } = liveProjection.getChannelPage({
|
||||||
const availableLanguages = [...new Map(allChannels.map(channel => [channel.language.code, channel.language])).values()]
|
sourceId, includeHidden, userId: req.user.id, epgSources, q, languages
|
||||||
.map(language => ({ code: language.code, short: language.short, label: language.label }))
|
});
|
||||||
.sort((left, right) => left.label.localeCompare(right.label));
|
|
||||||
const channels = allChannels.filter(channel =>
|
|
||||||
(!languages.length || languages.includes(channel.language.code)) && liveProjection.matchesSearch(channel, q)
|
|
||||||
);
|
|
||||||
return res.json({ projection: serializeState(state), filters: { availableLanguages }, channels });
|
return res.json({ projection: serializeState(state), filters: { availableLanguages }, channels });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
|
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
|
||||||
|
|||||||
@ -346,8 +346,10 @@ function parseData(value) {
|
|||||||
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChannels({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null) {
|
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null, dependencies = {}) {
|
||||||
database ||= defaultDatabase();
|
database ||= defaultDatabase();
|
||||||
|
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
|
||||||
|
const epgMatcher = dependencies.epgMatching || epgMatching;
|
||||||
const params = [];
|
const params = [];
|
||||||
let sourceFilter = '';
|
let sourceFilter = '';
|
||||||
if (sourceId !== null) {
|
if (sourceId !== null) {
|
||||||
@ -366,18 +368,28 @@ function getChannels({ sourceId = null, includeHidden = false, userId = null, q
|
|||||||
WHERE p.type = 'live' ${sourceFilter}
|
WHERE p.type = 'live' ${sourceFilter}
|
||||||
ORDER BY l.display_name, p.variant_rank
|
ORDER BY l.display_name, p.variant_rank
|
||||||
`).all(userId ?? -1, ...params);
|
`).all(userId ?? -1, ...params);
|
||||||
return serializeChannels(rows, includeHidden).map(channel => {
|
const serialized = serializeChannels(rows, includeHidden);
|
||||||
const match = iptvOrgCatalog.findMatch(database, channel);
|
const availableLanguages = [...new Map(serialized.map(channel => [channel.language.code, channel.language])).values()]
|
||||||
|
.map(language => ({ code: language.code, short: language.short, label: language.label }))
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label));
|
||||||
|
const filtered = serialized.filter(channel =>
|
||||||
|
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
||||||
|
);
|
||||||
|
const channels = filtered.map(channel => {
|
||||||
|
const match = standardCatalog.findMatch(database, channel);
|
||||||
if (match) {
|
if (match) {
|
||||||
channel.standardName = match.name;
|
channel.standardName = match.name;
|
||||||
channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence };
|
channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence };
|
||||||
}
|
}
|
||||||
const epgMatch = epgMatching.findBestMatch(database, channel, epgSources);
|
const epgMatch = epgMatcher.findBestMatch(database, channel, epgSources);
|
||||||
if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code };
|
if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code };
|
||||||
return channel;
|
return channel;
|
||||||
}).filter(channel =>
|
});
|
||||||
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
return { channels, availableLanguages };
|
||||||
);
|
}
|
||||||
|
|
||||||
|
function getChannels(options, database = null) {
|
||||||
|
return getChannelPage(options, database).channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeChannels(rows, includeHidden = false) {
|
function serializeChannels(rows, includeHidden = false) {
|
||||||
@ -455,6 +467,7 @@ module.exports = {
|
|||||||
collectIdentityHints,
|
collectIdentityHints,
|
||||||
collectOptionalIdentityHints,
|
collectOptionalIdentityHints,
|
||||||
displayNameForFingerprint,
|
displayNameForFingerprint,
|
||||||
|
getChannelPage,
|
||||||
getChannels,
|
getChannels,
|
||||||
identityCandidatePairs,
|
identityCandidatePairs,
|
||||||
normalizeChannelName,
|
normalizeChannelName,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const assert = require('node:assert/strict');
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const {
|
const {
|
||||||
normalizeChannelName,
|
normalizeChannelName,
|
||||||
|
getChannelPage,
|
||||||
projectCatalog,
|
projectCatalog,
|
||||||
markProjectionBuilding,
|
markProjectionBuilding,
|
||||||
projectionState,
|
projectionState,
|
||||||
@ -55,6 +56,13 @@ function createProjectionDb() {
|
|||||||
is_favorite INTEGER DEFAULT 0,
|
is_favorite INTEGER DEFAULT 0,
|
||||||
data JSON
|
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
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
const { ensureColumns } = require('../server/db/liveProjectionSchema');
|
const { ensureColumns } = require('../server/db/liveProjectionSchema');
|
||||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||||
@ -260,3 +268,24 @@ test('API serialization preserves an M3U groupTitle over the stored category fal
|
|||||||
assert.equal(channel.groupTitle, 'M3U Sports France');
|
assert.equal(channel.groupTitle, 'M3U Sports France');
|
||||||
assert.equal(channel.variants[0].categoryName, 'M3U Sports France');
|
assert.equal(channel.variants[0].categoryName, 'M3U Sports France');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('search and language filters run before expensive standard and EPG enrichment', () => {
|
||||||
|
const db = createProjectionDb();
|
||||||
|
seedProjectionChannel(db, 'bein-fr', '|FR| beIN Sports 1 HD');
|
||||||
|
seedProjectionChannel(db, 'bein-en', '|UK| beIN Sports 1 4K');
|
||||||
|
seedProjectionChannel(db, 'euro-fr', '|FR| Eurosport HD');
|
||||||
|
require('../server/services/liveProjectionService').rebuild(db);
|
||||||
|
|
||||||
|
let standardCalls = 0;
|
||||||
|
let epgCalls = 0;
|
||||||
|
const page = getChannelPage({ q: 'Bein Sport 1', languages: ['fr'], epgSources: [] }, db, {
|
||||||
|
iptvOrgCatalog: { findMatch: () => { standardCalls += 1; return null; } },
|
||||||
|
epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(page.channels.length, 1);
|
||||||
|
assert.equal(page.channels[0].language.code, 'fr');
|
||||||
|
assert.deepEqual(page.availableLanguages.map(language => language.code).sort(), ['en', 'fr']);
|
||||||
|
assert.equal(standardCalls, 1);
|
||||||
|
assert.equal(epgCalls, 1);
|
||||||
|
});
|
||||||
|
|||||||
107
test/t25Frontend.test.js
Normal file
107
test/t25Frontend.test.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
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, '..');
|
||||||
|
const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
|
||||||
|
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||||
|
|
||||||
|
function createChannelList() {
|
||||||
|
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/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 }
|
||||||
|
};
|
||||||
|
return { dom, window, channelList: new window.ChannelList() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedResponse() {
|
||||||
|
const channel = (id, name, language) => ({
|
||||||
|
id: `${id}:${language}`, logicalChannelId: id, displayName: name,
|
||||||
|
language: { code: language, short: language.toUpperCase(), label: language === 'fr' ? 'French' : 'English', confidence: 95 },
|
||||||
|
selectedVariantId: `1:${id}-${language}`,
|
||||||
|
variants: [{ id: `${id}-${language}`, sourceId: 1, name: `${name} ${language.toUpperCase()}`, categoryName: 'Sports', isHidden: false }]
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
projection: { stale: false },
|
||||||
|
filters: { availableLanguages: [
|
||||||
|
{ code: 'en', short: 'EN', label: 'English' },
|
||||||
|
{ code: 'fr', short: 'FR', label: 'French' }
|
||||||
|
] },
|
||||||
|
channels: [
|
||||||
|
channel('bein-1', 'beIN Sports 1', 'fr'),
|
||||||
|
channel('bein-1', 'beIN Sports 1', 'en'),
|
||||||
|
channel('news', 'World News', 'en')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('typing filters a loaded projection locally with flexible matching and no API request', async () => {
|
||||||
|
const { dom, window, channelList } = createChannelList();
|
||||||
|
let calls = 0;
|
||||||
|
window.API = { live: { getChannels: async options => {
|
||||||
|
calls++;
|
||||||
|
assert.equal(options.q, '');
|
||||||
|
assert.deepEqual([...options.languages], []);
|
||||||
|
return projectedResponse();
|
||||||
|
} } };
|
||||||
|
await channelList.loadProjectedChannels();
|
||||||
|
channelList.render();
|
||||||
|
calls = 0;
|
||||||
|
|
||||||
|
channelList.searchInput.value = 'Bein Sport 1';
|
||||||
|
channelList.searchInput.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||||
|
await wait(100);
|
||||||
|
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(channelList.filteredChannels.length, 2);
|
||||||
|
assert.ok(channelList.filteredChannels.every(channel => channel.displayName === 'beIN Sports 1'));
|
||||||
|
assert.equal(channelList.listStatus.textContent, '2 channel entries shown');
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('language selection filters the complete loaded catalog immediately without API', async () => {
|
||||||
|
const { dom, window, channelList } = createChannelList();
|
||||||
|
let calls = 0;
|
||||||
|
window.API = { live: { getChannels: async () => { calls++; return projectedResponse(); } } };
|
||||||
|
await channelList.loadProjectedChannels();
|
||||||
|
channelList.render();
|
||||||
|
calls = 0;
|
||||||
|
|
||||||
|
channelList.setLanguageSelection('fr');
|
||||||
|
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(channelList.filteredChannels.length, 1);
|
||||||
|
assert.equal(channelList.filteredChannels[0].language.code, 'fr');
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape clears channel search while preserving language-panel Escape behavior', async () => {
|
||||||
|
const { dom, window, channelList } = createChannelList();
|
||||||
|
window.API = { live: { getChannels: async () => projectedResponse() } };
|
||||||
|
await channelList.loadProjectedChannels();
|
||||||
|
channelList.searchInput.value = 'news';
|
||||||
|
window.document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
assert.equal(channelList.searchInput.value, '');
|
||||||
|
assert.equal(channelList.filteredChannels.length, 3);
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user