Files
NewTube/server/tests/search-filters.test.mjs
T
bruno f1f673040e
CI / build-and-test (push) Successful in 14m10s
feat(search): panneau de filtres unifié, clavier/focus du typeahead
Barre de recherche
- Panneau de filtres unifié (remplace le ProviderPicker) : sources, type
  (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année),
  durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex
  (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F,
  recherches récentes, « mémoriser par défaut », « Réinitialiser ».
- Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur.
- Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants,
  Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon
  recherche brute), Tab complète sans chercher, Échap ferme puis vide,
  ligne « Rechercher <q> », défilement de l'option active, aria à jour.
- Focus : le panneau se referme dès que le focus quitte la barre
  (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les
  lignes pour garder le focus dans l'input (plus de scintillement).
- Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des
  filtres et sont retirés de la requête (autocomplétion après le `:`).

Filtres côté serveur
- Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs.
- /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type,
  duration, features), Data API v3 (type, videoDuration, publishedAfter),
  yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif.
  Un champ manquant ne fait jamais disparaître un résultat.
- Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache).
- Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView
  non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues.

Tests / docs
- 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres),
  server/tests/search-filters.test.mjs (npm run test:filters) + CI,
  scénarios e2e des filtres, README/api/README/todo/MCP à jour.
2026-09-28 17:42:19 -04:00

190 lines
7.8 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,
} 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.');