// Filtres de recherche — tests unitaires (offline, aucun réseau). // Run with: npm run test:filters // Couverture : normalisation, bornes, post-filtrage par provider, mapping // InnerTube / Data API v3. import { normalizeFilters, parseSearchFilters, activeFilters, filtersCacheKey, periodMs, durationBounds, matchesFilters, applySearchFilters, innertubeSearchFilters, apiSearchParams, itemDurationSec, itemPublishedTs, } from '../search-filters.mjs'; function expect(cond, msg) { if (!cond) throw new Error(`Assertion failed: ${msg}`); } function eq(actual, expected, msg) { const a = JSON.stringify(actual); const b = JSON.stringify(expected); if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`); } function logOk(msg) { console.log(`✓ ${msg}`); } // ---------- Normalisation ---------- eq(normalizeFilters(null), { type: 'all', duration: 'all', period: 'all', sort: 'relevance' }, 'null filters -> defaults'); eq( normalizeFilters({ type: 'LIVE', duration: 'moyen', period: 'nope', sort: 'views' }), { type: 'live', duration: 'all', period: 'all', sort: 'views' }, 'values are case-insensitive, unknown values fall back per key (no cross-contamination)', ); eq( parseSearchFilters({ type: 'shorts', duration: 'short', period: 'week', sort: 'date', junk: 'x' }), { type: 'shorts', duration: 'short', period: 'week', sort: 'date' }, 'parseSearchFilters reads the Express query', ); eq( parseSearchFilters({ type: 'plaid' }).type, 'all', 'unknown type is ignored (never a 400)', ); eq(activeFilters({ type: 'live', period: 'all' }), { type: 'live' }, 'activeFilters keeps only non-defaults'); eq( filtersCacheKey({ type: 'live', sort: 'date' }), 'live.all.all.date', 'cache key signature is stable and ordered', ); logOk('normalizeFilters / parseSearchFilters / activeFilters / filtersCacheKey'); // ---------- Bornes ---------- eq(periodMs('all'), 0, 'period all -> no bound'); expect(periodMs('hour') === 3600 * 1000, 'hour bound'); expect(periodMs('week') === 7 * 24 * 3600 * 1000, 'week bound'); eq(durationBounds('short'), { min: 1, max: 240 }, 'short duration bound'); eq(durationBounds('medium'), { min: 240, max: 1200 }, 'medium duration bound'); eq(durationBounds('long'), { min: 1200, max: Infinity }, 'long duration bound'); eq(durationBounds('all'), null, 'no duration bound'); logOk('periodMs / durationBounds'); // ---------- Lectures de métadonnées ---------- eq(itemDurationSec({ duration: 120 }), 120, 'numeric duration'); eq(itemDurationSec({ duration: 'PT1H2M' }), 0, 'ISO duration ignored (not a field format)'); eq(itemDurationSec({ duration: '1:02:03' }), 3723, 'hh:mm:ss duration parsed'); eq(itemDurationSec({}), 0, 'missing duration -> 0'); expect(itemPublishedTs({ publishedAt: new Date().toISOString() }) > 0, 'ISO publishedAt parsed'); expect(itemPublishedTs({ timestamp: 1700000000 }) === 1700000000000, 'yt-dlp seconds timestamps up-scaled'); eq(itemPublishedTs({}), 0, 'missing date -> 0'); logOk('itemDurationSec / itemPublishedTs'); // ---------- Post-filtrage ---------- const now = Date.now(); // `type: 'video'` par défaut = payload réaliste d'un provider qui expose le type. const mk = (over) => ({ id: Math.random().toString(36).slice(2), title: 'x', type: 'video', duration: 100, publishedAt: new Date(now - 60 * 1000).toISOString(), ...over, }); expect(matchesFilters(mk({ duration: 100 }), { duration: 'short' }), 'short keeps 100s'); expect(!matchesFilters(mk({ duration: 300 }), { duration: 'short' }), 'short drops 5min'); expect(matchesFilters(mk({ duration: 300 }), { duration: 'medium' }), 'medium keeps 5min'); expect(matchesFilters(mk({ duration: 3600 }), { duration: 'long' }), 'long keeps 1h'); expect(matchesFilters(mk({ duration: null }), { duration: 'short' }), 'missing duration is never dropped'); expect( matchesFilters(mk({ publishedAt: new Date(now - 3 * 3600 * 1000).toISOString() }), { period: 'today' }), 'today keeps 3h ago', ); expect( !matchesFilters(mk({ publishedAt: new Date(now - 10 * 24 * 3600 * 1000).toISOString() }), { period: 'week' }), 'week drops 10 days ago', ); expect( matchesFilters(mk({ publishedAt: new Date(now - 3 * 24 * 3600 * 1000).toISOString() }), { period: 'week' }), 'week keeps 3 days ago', ); expect(matchesFilters(mk({ publishedAt: null }), { period: 'today' }), 'missing date is never dropped'); expect(matchesFilters(mk({ isLive: true }), { type: 'live' }), 'live type keeps isLive items'); expect(!matchesFilters(mk({}), { type: 'live' }), 'live type drops regular videos'); expect(matchesFilters(mk({ isShort: true, duration: 40 }), { type: 'shorts' }), 'shorts keeps flagged items'); expect(matchesFilters(mk({ kind: 'clip' }), { type: 'shorts' }), 'shorts keeps clips'); // Item sans aucun signal de type : on ne peut pas conclure -> jamais écarté. expect(matchesFilters(mk({ duration: 0, type: undefined }), { type: 'live' }), 'item without type signal is kept'); expect(matchesFilters(mk({ duration: 0, type: undefined }), { type: 'shorts' }), 'item without type signal is kept for shorts'); expect( matchesFilters(mk({ duration: 500 }), { type: 'video' }), 'video type keeps plain videos', ); expect(!matchesFilters(mk({ isLive: true }), { type: 'video' }), 'video type drops live items'); logOk('matchesFilters (durée / période / type)'); // ---------- applySearchFilters par provider ---------- const items = [ mk({ duration: 100, isShort: true }), mk({ duration: 1800 }), mk({ isLive: true, duration: 60 }), mk({ duration: 0, publishedAt: null }), // métadonnées absentes : jamais écarté ]; eq( applySearchFilters('yt', items, { duration: 'short' }).length, 3, 'yt post-filters duration (100s + 60s live kept, 1800s dropped, unknown kept)', ); eq( applySearchFilters('tw', items, { duration: 'short' }).length, items.length, 'twitch does not post-filter duration (no reliable value)', ); eq( applySearchFilters('dm', items, { type: 'channel' }).length, items.length, 'channel filter is not applied outside YouTube (would empty results)', ); eq( applySearchFilters('yt', items, { type: 'channel' }).length, 0, 'yt channel filter drops videos', ); eq( applySearchFilters('yt', items, {}).length, items.length, 'no filter -> list untouched (same reference semantics)', ); eq( applySearchFilters('yt', [], { type: 'live' }), [], 'empty list stays empty', ); logOk('applySearchFilters (dimensions par provider)'); // ---------- Mapping providers ---------- eq(innertubeSearchFilters({ period: 'week' }), { upload_date: 'week' }, 'period -> upload_date'); eq(innertubeSearchFilters({ period: 'hour' }), {}, 'hour has no InnerTube enum (post-filter only)'); eq(innertubeSearchFilters({ type: 'shorts' }), { type: 'shorts' }, 'type shorts'); eq(innertubeSearchFilters({ type: 'live' }), { features: ['live'] }, 'live is a feature, not a SearchType'); eq(innertubeSearchFilters({ type: 'channel' }), { type: 'channel' }, 'type channel'); eq( innertubeSearchFilters({ duration: 'short' }), { duration: 'UNDER_THREE_MINS' }, 'short -> UNDER_THREE_MINS proto enum', ); eq( innertubeSearchFilters({ duration: 'medium' }), { duration: 'THREE_TO_TWENTY_MINS' }, 'medium -> THREE_TO_TWENTY_MINS proto enum', ); eq( innertubeSearchFilters({ duration: 'long' }), { duration: 'OVER_TWENTY_MINS' }, 'long -> OVER_TWENTY_MINS proto enum', ); eq(innertubeSearchFilters({}), {}, 'no filters -> empty object'); eq(apiSearchParams({ type: 'video' }), { type: 'video' }, 'api type video'); eq(apiSearchParams({ duration: 'long' }), { videoDuration: 'long' }, 'api videoDuration'); eq(apiSearchParams({ type: 'shorts' }), {}, 'api has no shorts type (post-filter)'); expect( typeof apiSearchParams({ period: 'today' }).publishedAfter === 'string', 'api publishedAfter ISO date', ); logOk('innertubeSearchFilters / apiSearchParams'); console.log('\nAll search-filters tests passed.');