feat(search): panneau de filtres unifié, clavier/focus du typeahead
CI / build-and-test (push) Successful in 14m10s
CI / build-and-test (push) Successful in 14m10s
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.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// 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.');
|
||||
@@ -155,6 +155,33 @@ function scenario(name, fn) {
|
||||
// 2b) Invalid providers fall back to all
|
||||
await scenario('invalid providers param falls back to the full registry', async () => validateProvidersFallback());
|
||||
|
||||
// 2c) Filtres de recherche : ils sont renvoyés tels quels, valeurs inconnues ignorées
|
||||
await scenario('search filters are echoed back and unknown values ignored', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search?q=test&providers=yt,dm&pageSize=3&type=live&duration=short&period=week&sort=date`);
|
||||
assert(res.ok, `search responds 200 (got ${res.status})`);
|
||||
const body = await res.json();
|
||||
assert(body.filters && body.filters.type === 'live', `type filter echoed (got ${JSON.stringify(body.filters)})`);
|
||||
assert(body.filters.duration === 'short', 'duration filter echoed');
|
||||
assert(body.filters.period === 'week', 'period filter echoed');
|
||||
assert(body.sort === 'date', 'sort comes from the filters');
|
||||
|
||||
// Valeurs inconnues : ignorées, jamais 400.
|
||||
const bad = await fetch(`${baseUrl}/api/search?q=test&providers=dm&type=plaid&period=yesterday&duration=epic`);
|
||||
assert(bad.ok, `unknown filter values do not fail (got ${bad.status})`);
|
||||
const badBody = await bad.json();
|
||||
assert(!badBody.filters || Object.keys(badBody.filters).length === 0,
|
||||
`unknown filters are dropped (got ${JSON.stringify(badBody.filters)})`);
|
||||
assert(badBody.sort === 'relevance', 'sort falls back to relevance');
|
||||
});
|
||||
|
||||
// 2d) Un filtre ne doit jamais renvoyer d'erreur 500 (post-filtrage sûr)
|
||||
await scenario('filters never break the fan-out (200 + groups)', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search?q=test&pageSize=3&period=today&duration=long`);
|
||||
assert(res.ok, `search responds 200 with filters (got ${res.status})`);
|
||||
const body = await res.json();
|
||||
assert(body.groups && typeof body.groups === 'object', 'groups present with filters');
|
||||
});
|
||||
|
||||
// 3) Preference: defaultProviders persisted and returned after re-login
|
||||
await scenario('default providers preference round-trip (persisted per user)', async () => {
|
||||
await patchPreferences(token, { defaultProviders: ['ru', 'od'] });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Step 18 — tests offline InnerTube (AUCUN réseau : mappers purs + modes).
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapVideoNode, mapLockupView, mapNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs';
|
||||
import { mapVideoNode, mapLockupView, mapNodes, mapShelfNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs';
|
||||
import { pickTrack, orderedTracks } from '../transcript.mjs';
|
||||
import { getSearchMode, YT_SEARCH_MODES } from '../providers/youtube-common.mjs';
|
||||
|
||||
@@ -96,7 +96,50 @@ function ok(msg) { console.log(`✓ ${msg}`); }
|
||||
ok('mapLockupView watch-next');
|
||||
}
|
||||
|
||||
// 5) modes innertube connus
|
||||
// 5) mapShelfNodes + ShortsLockupView : les Shorts d'une recherche « Shorts »
|
||||
// n'ont ni `id` ni `title` classiques (endpoint + overlay_metadata), et
|
||||
// vivent dans un GridShelfView -> sans ce complément la recherche renvoie 0.
|
||||
{
|
||||
// Forme réelle observée (youtubei.js v18, ShortsLockupView)
|
||||
const shortsNode = (id) => ({
|
||||
type: 'ShortsLockupView',
|
||||
entity_id: `shorts-shelf-item-${id}`,
|
||||
on_tap_endpoint: { name: 'reelWatchEndpoint', payload: { videoId: id } },
|
||||
overlay_metadata: {
|
||||
primary_text: { text: `Short ${id}` },
|
||||
secondary_text: { text: '219 M de vues' },
|
||||
},
|
||||
thumbnail: [{ url: 'http://t/low.jpg', width: 100 }, { url: 'http://t/high.jpg', width: 1080 }],
|
||||
});
|
||||
const mapped = mapVideoNode(shortsNode('s1'));
|
||||
assert.equal(mapped.id, 's1', 'l\'id du Short est lu dans on_tap_endpoint');
|
||||
assert.equal(mapped.title, 'Short s1', 'le titre vient de overlay_metadata');
|
||||
assert.equal(mapped.isShort, true, 'un Short est marqué isShort');
|
||||
assert.equal(mapped.views, 219000000, 'les vues viennent de la 2e ligne d\'overlay');
|
||||
assert.equal(mapped.thumbnail, 'http://t/high.jpg', 'la plus grande vignette est retenue');
|
||||
// Forme « brute » (avant parsing youtubei.js) également gérée
|
||||
assert.equal(mapVideoNode({
|
||||
type: 'ShortsLockupView',
|
||||
on_tap: { innertubeCommand: { reelWatchEndpoint: { videoId: 'raw1' } } },
|
||||
overlay_metadata: { primary_text: { text: 'Raw' } },
|
||||
}).id, 'raw1', 'forme brute (on_tap) gérée');
|
||||
|
||||
const feed = {
|
||||
results: [
|
||||
{ type: 'Video', id: 'regular1', title: { text: 'Vidéo normale' } },
|
||||
{ type: 'GridShelfView', contents: [shortsNode('s1'), shortsNode('s2'), { type: 'ChipCloud' }] },
|
||||
],
|
||||
};
|
||||
const shelf = mapShelfNodes(feed);
|
||||
assert.equal(shelf.length, 2, 'les 2 Shorts du shelf sont mappés');
|
||||
assert.deepEqual(shelf.map((s) => s.id).sort(), ['s1', 's2']);
|
||||
assert.equal(mapShelfNodes({ results: [{ type: 'Video', id: 'v' }] }).length, 0, 'hors shelf, rien à faire');
|
||||
assert.deepEqual(mapShelfNodes(null), []);
|
||||
assert.deepEqual(mapShelfNodes({}), []);
|
||||
ok('mapShelfNodes + ShortsLockupView (Shorts dans un shelf)');
|
||||
}
|
||||
|
||||
// 6) modes innertube connus
|
||||
{
|
||||
assert.ok(YT_SEARCH_MODES.includes('innertube-first'));
|
||||
assert.ok(YT_SEARCH_MODES.includes('innertube-only'));
|
||||
@@ -107,7 +150,7 @@ function ok(msg) { console.log(`✓ ${msg}`); }
|
||||
ok('modes innertube-first/only');
|
||||
}
|
||||
|
||||
// 6) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack
|
||||
// 7) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack
|
||||
{
|
||||
const cap = mapCaptionTracks([
|
||||
{ base_url: 'https://x/t?caps=asr&x=1', language_code: 'en', kind: 'asr', name: { text: 'English (auto-generated)' } },
|
||||
|
||||
Reference in New Issue
Block a user