CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
271 lines
12 KiB
JavaScript
271 lines
12 KiB
JavaScript
// 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,
|
|
isShortItem, SHORT_MAX_SECONDS, VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO,
|
|
} 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');
|
|
// Phase 2.2 : une durée illisible vaut `undefined` (« inconnue »), plus 0.
|
|
// 0 faisait passer une vidéo sans info pour une vidéo de 0 s : affichage "0:00"
|
|
// et règle « verticale sans durée => pas un short » neutralisée.
|
|
eq(itemDurationSec({ duration: 'PT1H2M' }), undefined, 'ISO duration ignored (not a field format) -> undefined');
|
|
eq(itemDurationSec({ duration: '1:02:03' }), 3723, 'hh:mm:ss duration parsed');
|
|
eq(itemDurationSec({}), undefined, 'missing duration -> undefined');
|
|
eq(itemDurationSec({ duration: 0 }), undefined, 'explicit 0 -> undefined (not a 0 s video)');
|
|
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');
|
|
|
|
// ---------- Détection Short par orientation ----------
|
|
|
|
eq(typeof SHORT_MAX_SECONDS, 'number', 'seuils exportés');
|
|
eq(VERTICAL_SHORT_MAX_SECONDS, 90, 'verticale short jusqu’à 90 s');
|
|
eq(VERTICAL_MAX_RATIO, 0.8, 'ratio vertical max 0.8');
|
|
|
|
// Règle 2 : verticale 60 s (ex. repost TikTok 608x1080) ⇒ short.
|
|
expect(
|
|
isShortItem({ title: 'repost', duration: 60, width: 608, height: 1080, type: 'video' }),
|
|
'vertical 60s is a short',
|
|
);
|
|
// Règle 2 : verticale 200 s ⇒ PAS short (vidéo verticale, pas short).
|
|
expect(
|
|
!isShortItem({ title: 'vlog', duration: 200, width: 720, height: 1280, type: 'video' }),
|
|
'vertical 200s is not a short',
|
|
);
|
|
// Règle 2 : un carré 80 s (> 75 s) n'est PAS short : l'orientation seule
|
|
// (ratio ~1) ne suffit pas ET le repli durée ne s'applique plus.
|
|
expect(
|
|
!isShortItem({ title: 'clip carré', duration: 80, width: 1080, height: 1080, type: 'video' }),
|
|
'square 80s is not a short (orientation alone is not enough)',
|
|
);
|
|
// Dimensions connues non verticales ⇒ JAMAIS short, même à 30 s
|
|
// (l'orientation fait autorité ; plus de repli durée quand w/h sont connus).
|
|
expect(
|
|
!matchesFilters({ id: 'a', title: 'clip carré', duration: 30, width: 1080, height: 1080, type: 'video' }, { type: 'shorts' }),
|
|
'square 30s is not a short (orientation authoritative)',
|
|
);
|
|
// Horizontale 50 s ⇒ PAS short (dimensions connues, non verticale).
|
|
expect(
|
|
!isShortItem({ title: 'paysage', duration: 50, width: 1280, height: 720, type: 'video' }),
|
|
'horizontal 50s is not a short (orientation authoritative)',
|
|
);
|
|
// Repli durée seule réservé aux items SANS dimensions connues.
|
|
expect(
|
|
isShortItem({ title: 'courte sans dims', duration: 50, type: 'video' }),
|
|
'50s without dimensions is a short (duration fallback)',
|
|
);
|
|
// Règle 3 : verticale SANS durée connue ⇒ PAS short.
|
|
expect(
|
|
!isShortItem({ title: 'inconnu', width: 608, height: 1080, type: 'video' }),
|
|
'vertical without duration is not a short',
|
|
);
|
|
// Règle 1 : flag contredit par une durée > 75 s ⇒ PAS short.
|
|
expect(
|
|
!isShortItem({ title: 'faux short', duration: 300, isShort: true, type: 'video' }),
|
|
'native flag contradicted by duration is not a short',
|
|
);
|
|
// Règle 1 : un vrai Short titré « …shorts » n'est plus exclu (ancien bug
|
|
// `!/short/i.test(title)`).
|
|
expect(
|
|
isShortItem({ title: 'mon voyage #shorts', duration: 40, type: 'video' }),
|
|
'title containing shorts is kept',
|
|
);
|
|
// Seuil YouTube (70) vs autres (75) : 72 s.
|
|
expect(
|
|
!isShortItem({ title: 'yt', duration: 72, type: 'video', provider: 'youtube' }, 'yt'),
|
|
'youtube 72s is not a short',
|
|
);
|
|
expect(
|
|
isShortItem({ title: 'dm', duration: 72, type: 'video' }, 'dm'),
|
|
'dailymotion 72s is a short',
|
|
);
|
|
// §4 : une verticale 60 s ne remonte jamais dans le filtre « vidéos »…
|
|
expect(
|
|
!matchesFilters({ id: 'b', title: 'v', duration: 60, width: 608, height: 1080, type: 'video' }, { type: 'video' }),
|
|
'vertical 60s excluded from video filter',
|
|
);
|
|
// …et les dimensions comptent comme signal : l'item n'est plus "muet",
|
|
// donc le filtre `live` l'exclut (une verticale n'est pas un direct).
|
|
expect(
|
|
!matchesFilters({ id: 'c', title: 'v', duration: 0, width: 608, height: 1080 }, { type: 'live' }),
|
|
'dimensions are a signal: vertical without live flag is dropped from live',
|
|
);
|
|
logOk('isShortItem (orientation + durée)');
|
|
|
|
console.log('\nAll search-filters tests passed.');
|