feat(search): panneau de filtres unifié, clavier/focus du typeahead
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:
2026-09-28 17:42:19 -04:00
parent 9fb0ce7549
commit f1f673040e
40 changed files with 3111 additions and 938 deletions
+8 -1
View File
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { withFilterParams } from './filter-params';
// TODO: Make SearchResult generic
export class DmAdapter implements ProviderAdapter<VideoItem> {
@@ -12,6 +13,9 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
// le sait, affinés en post-traitement sinon.
httpParams = withFilterParams(httpParams, params.filters);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined;
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
@@ -25,7 +29,10 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
publishedAt: undefined, // Dailymotion API does not provide published date in search results
channelExternalId: it.channelId || undefined,
channelAvatarUrl: it.uploaderAvatar || undefined,
}));
// Signaux de type pour le raffinement client des filtres.
type: typeof it.type === 'string' ? it.type : undefined,
isShort: it.isShort === true ? true : undefined,
} as VideoItem));
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError };
}
}
+17
View File
@@ -0,0 +1,17 @@
import { HttpParams } from '@angular/common/http';
import { activeFilters, type SearchFilters } from '../filters';
/**
* Ajoute les filtres de recherche à une `HttpParams` d'adapter.
* Les valeurs par défaut ne sont pas envoyées (URL/backend plus lisibles, cache
* serveur plus efficace : deux recherches sans filtre partagent la même entrée).
*/
export function withFilterParams(params: HttpParams, filters: Partial<SearchFilters> | null | undefined): HttpParams {
const active = activeFilters(filters);
let out = params;
if (active.type) out = out.set('type', active.type);
if (active.duration) out = out.set('duration', active.duration);
if (active.period) out = out.set('period', active.period);
if (active.sort) out = out.set('sort', active.sort);
return out;
}
+7
View File
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { withFilterParams } from './filter-params';
export class OdAdapter implements ProviderAdapter<VideoItem> {
key = 'od' as const;
@@ -11,6 +12,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'od');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
// le sait, affinés en post-traitement sinon.
httpParams = withFilterParams(httpParams, params.filters);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined;
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
@@ -34,6 +38,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
// Nom de chaîne LBRY (ex. @MaChaine) : le backend /api/channels/od
// sait le résoudre avec ou sans préfixe @.
channelExternalId: it.uploaderName || undefined,
// Signaux de type pour le raffinement client des filtres.
type: typeof it.type === 'string' ? it.type : undefined,
isShort: it.isShort === true ? true : undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError };
+8 -1
View File
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { withFilterParams } from './filter-params';
export class PtAdapter implements ProviderAdapter<VideoItem> {
key = 'pt' as const;
@@ -11,6 +12,9 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
// le sait, affinés en post-traitement sinon.
httpParams = withFilterParams(httpParams, params.filters);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined;
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => {
@@ -31,7 +35,10 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
viewCount: undefined,
publishedAt: undefined,
channelExternalId,
};
// Signaux de type pour le raffinement client des filtres.
type: typeof it.type === 'string' ? it.type : undefined,
isShort: it.isShort === true ? true : undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError };
}
+8 -1
View File
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { withFilterParams } from './filter-params';
export class RuAdapter implements ProviderAdapter<VideoItem> {
key = 'ru' as const;
@@ -11,6 +12,9 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
// le sait, affinés en post-traitement sinon.
httpParams = withFilterParams(httpParams, params.filters);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined;
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
@@ -22,7 +26,10 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : undefined,
publishedAt: it.publishedAt,
}));
// Signaux de type (Shorts / direct) pour le raffinement client.
type: typeof it.type === 'string' ? it.type : undefined,
isShort: it.isShort === true ? true : undefined,
} as VideoItem));
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError };
}
}
+5
View File
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { withFilterParams } from './filter-params';
export class TwAdapter implements ProviderAdapter<VideoItem> {
key = 'tw' as const;
@@ -11,6 +12,9 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
// le sait, affinés en post-traitement sinon.
httpParams = withFilterParams(httpParams, params.filters);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined;
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
@@ -41,6 +45,7 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
type: typeof it.type === 'string' ? it.type : undefined,
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
kind: typeof it.kind === 'string' ? it.kind : undefined,
isShort: it.isShort === true ? true : undefined,
game: typeof it.game === 'string' ? it.game : undefined,
} as VideoItem;
}).filter((v: VideoItem) => v.id != null && String(v.id).length > 0 && (v.thumbnailUrl || v.channelAvatarUrl));
+10 -1
View File
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { withFilterParams } from './filter-params';
export class YtAdapter implements ProviderAdapter<VideoItem> {
key = 'yt' as const;
@@ -11,6 +12,9 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
// le sait, affinés en post-traitement sinon.
httpParams = withFilterParams(httpParams, params.filters);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined;
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
@@ -25,7 +29,12 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
// ID chaîne conservé : rend le logo/nom cliquables vers /channel
// (l'avatar est résolu via /api/channels, l'API search n'en fournit pas).
channelExternalId: it.channelExternalId || it.channelId || undefined,
}));
// Signaux de type : indispensables au raffinement client des filtres
// (Shorts / direct / chaîne) quand le serveur les a déjà appliqués.
type: typeof it.type === 'string' ? it.type : undefined,
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
isShort: it.isShort === true ? true : undefined,
} as VideoItem));
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError };
}
}
+2
View File
@@ -18,6 +18,8 @@ export interface SearchResponseV1 {
page?: number;
pageSize?: number;
sort?: 'relevance' | 'date' | 'views';
/** Filtres appliqués côté serveur (les valeurs par défaut sont omises). */
filters?: Record<string, string>;
/** Per-provider backend failures, keyed by provider id. */
errors?: Partial<Record<ProviderId, string>>;
}
+437
View File
@@ -0,0 +1,437 @@
/**
* Modèle de filtres de recherche — partagé par la barre de recherche, le panneau
* de filtres, la page de résultats et les adapters.
*
* Les filtres sont *un* objet plat (`SearchFilters`) : ils voyagent dans l'URL
* (`?type=…&duration=…&period=…&sort=…`), dans la requête `/api/search` et dans
* l'état de `SearchService`. Toute valeur inconnue retombe sur 'all' (jamais de
* 400, jamais de liste vide côté rendu : un filtre typo doit être ignoré).
*/
import { isShortVideo } from '../shared/utils/video-kind';
/** Type de contenu attendu. */
export type FilterType = 'all' | 'video' | 'shorts' | 'live' | 'channel';
/** Borne de durée (alignée sur YouTube : court < 4 min, moyen 4-20 min, long > 20 min). */
export type FilterDuration = 'all' | 'short' | 'medium' | 'long';
/** Période de mise en ligne. */
export type FilterPeriod = 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
/** Tri. */
export type FilterSort = 'relevance' | 'date' | 'views';
export interface SearchFilters {
type: FilterType;
duration: FilterDuration;
period: FilterPeriod;
sort: FilterSort;
}
export const FILTER_TYPES: FilterType[] = ['all', 'video', 'shorts', 'live', 'channel'];
export const FILTER_DURATIONS: FilterDuration[] = ['all', 'short', 'medium', 'long'];
export const FILTER_PERIODS: FilterPeriod[] = ['all', 'hour', 'today', 'week', 'month', 'year'];
export const FILTER_SORTS: FilterSort[] = ['relevance', 'date', 'views'];
export const DEFAULT_FILTERS: SearchFilters = {
type: 'all',
duration: 'all',
period: 'all',
sort: 'relevance',
};
function pick<T extends string>(raw: unknown, allowed: T[], fallback: T): T {
const s = String(raw ?? '').trim().toLowerCase();
return (allowed as string[]).includes(s) ? (s as T) : fallback;
}
/** Normalise n'importe quelle entrée (URL, patch, payload serveur) en filtres valides. */
export function normalizeFilters(raw: Partial<SearchFilters> | null | undefined): SearchFilters {
const src = raw || {};
return {
['type']: pick(src['type'], FILTER_TYPES, 'all'),
['duration']: pick(src['duration'], FILTER_DURATIONS, 'all'),
['period']: pick(src['period'], FILTER_PERIODS, 'all'),
['sort']: pick(src['sort'], FILTER_SORTS, 'relevance'),
} as SearchFilters;
}
/** Nombre de filtres réellement actifs (le tri par défaut ne compte pas). */
export function activeFilterCount(f: Partial<SearchFilters> | null | undefined): number {
const n = normalizeFilters(f);
let count = 0;
if (n.type !== 'all') count++;
if (n.duration !== 'all') count++;
if (n.period !== 'all') count++;
if (n.sort !== 'relevance') count++;
return count;
}
export function filtersEqual(a: Partial<SearchFilters> | null, b: Partial<SearchFilters> | null): boolean {
const x = normalizeFilters(a);
const y = normalizeFilters(b);
return x.type === y.type && x.duration === y.duration && x.period === y.period && x.sort === y.sort;
}
/** Les filtres « non par défaut » seuls — pour l'URL et le cache. */
export function activeFilters(f: Partial<SearchFilters> | null | undefined): Partial<SearchFilters> {
const n = normalizeFilters(f);
const out: Partial<SearchFilters> = {};
if (n.type !== 'all') out.type = n.type;
if (n.duration !== 'all') out.duration = n.duration;
if (n.period !== 'all') out.period = n.period;
if (n.sort !== 'relevance') out.sort = n.sort;
return out;
}
/** Query params (les valeurs par défaut sont omises : URL lisible et partageable). */
export function filtersToQueryParams(f: Partial<SearchFilters> | null | undefined): Record<string, string> {
const a = activeFilters(f);
const out: Record<string, string> = {};
if (a.type) out['type'] = a.type;
if (a.duration) out['duration'] = a.duration;
if (a.period) out['period'] = a.period;
if (a.sort) out['sort'] = a.sort;
return out;
}
/** Lecture inverse depuis une `ParamMap` (ou un objet de query params). */
export function filtersFromQueryParams(params: {
get(key: string): string | null;
has?(key: string): boolean;
} | Record<string, unknown> | null | undefined): SearchFilters {
const read = (key: string): string | null => {
if (!params) return null;
if (typeof (params as any).get === 'function') {
try { return (params as any).get(key) ?? null; } catch { return null; }
}
const rec = params as Record<string, unknown>;
const v = rec?.[key];
if (v == null) return null;
return Array.isArray(v) ? String(v[0] ?? '') : String(v);
};
return normalizeFilters({
type: read('type') as FilterType | undefined,
duration: read('duration') as FilterDuration | undefined,
period: read('period') as FilterPeriod | undefined,
sort: read('sort') as FilterSort | undefined,
});
}
/** Durée de la période en ms (0 = pas de borne). */
export function periodMs(period: FilterPeriod): number {
const HOUR = 3600 * 1000;
const DAY = 24 * HOUR;
switch (period) {
case 'hour': return HOUR;
case 'today': return DAY;
case 'week': return 7 * DAY;
case 'month': return 30 * DAY;
case 'year': return 365 * DAY;
default: return 0;
}
}
/** Bornes de durée en secondes (null = pas de borne). */
export function durationBounds(duration: FilterDuration): { min: number; max: number } | null {
switch (duration) {
case 'short': return { min: 1, max: 240 };
case 'medium': return { min: 240, max: 1200 };
case 'long': return { min: 1200, max: Number.POSITIVE_INFINITY };
default: return null;
}
}
// ---------------------------------------------------------------------------
// Affichage
// ---------------------------------------------------------------------------
export type FilterKey = keyof SearchFilters;
export interface FilterOption<T extends string = string> {
value: T;
/** Clé i18n (voir i18n.service.ts) + repli FR. */
key: string;
label: string;
hint?: string;
}
/** Une option d'un groupe de filtres, avec son état sélectionné. */
export interface FilterChoice<T extends string = string> extends FilterOption<T> {
selected: boolean;
}
function opt<T extends string>(value: T, key: string, label: string, hint?: string): FilterOption<T> {
return hint ? { value, key, label, hint } : { value, key, label };
}
/** Groupes de filtres dans l'ordre d'affichage du panneau. */
export function filterGroups(f: Partial<SearchFilters> | null | undefined): Array<{
key: string;
labelKey: string;
label: string;
multi: boolean;
options: FilterChoice[];
}> {
const n = normalizeFilters(f);
return [
{
key: 'type', labelKey: 'filters.type', label: 'Type de contenu', multi: false,
options: [
opt('all', 'filters.type.all', 'Tout'),
opt('video', 'filters.type.video', 'Vidéos'),
opt('shorts', 'filters.type.shorts', 'Shorts / Clips'),
opt('live', 'filters.type.live', 'En direct'),
opt('channel', 'filters.type.channel', 'Chaînes'),
].map(o => ({ ...o, selected: n.type === o.value })),
},
{
key: 'period', labelKey: 'filters.period', label: 'Période', multi: false,
options: [
opt('all', 'filters.period.all', "N'importe quand"),
opt('hour', 'filters.period.hour', 'Dernière heure'),
opt('today', 'filters.period.today', "Aujourd'hui"),
opt('week', 'filters.period.week', 'Cette semaine'),
opt('month', 'filters.period.month', 'Ce mois'),
opt('year', 'filters.period.year', 'Cette année'),
].map(o => ({ ...o, selected: n.period === o.value })),
},
{
key: 'duration', labelKey: 'filters.duration', label: 'Durée', multi: false,
options: [
opt('all', 'filters.duration.all', 'Toutes'),
opt('short', 'filters.duration.short', 'Courte < 4 min'),
opt('medium', 'filters.duration.medium', 'Moyenne 4-20 min'),
opt('long', 'filters.duration.long', 'Longue > 20 min'),
].map(o => ({ ...o, selected: n.duration === o.value })),
},
{
key: 'sort', labelKey: 'filters.sort', label: 'Trier par', multi: false,
options: [
opt('relevance', 'filters.sort.relevance', 'Pertinence'),
opt('date', 'filters.sort.date', 'Date'),
opt('views', 'filters.sort.views', 'Vues'),
].map(o => ({ ...o, selected: n.sort === o.value })),
},
];
}
/** Résumé court (une ligne) des filtres actifs, pour le bouton sur mobile. */
export function filtersSummary(f: Partial<SearchFilters> | null | undefined, groups = filterGroups(f)): string {
const n = normalizeFilters(f);
const parts: string[] = [];
if (n.type !== 'all') parts.push(groups[0].options.find(o => o.value === n.type)?.label || n.type);
if (n.period !== 'all') parts.push(groups[1].options.find(o => o.value === n.period)?.label || n.period);
if (n.duration !== 'all') parts.push(groups[2].options.find(o => o.value === n.duration)?.label || n.duration);
return parts.length ? parts.join(' · ') : 'Filtres';
}
// ---------------------------------------------------------------------------
// Syntaxe « opérateur » dans la requête : `linux today: long: live:`
// ---------------------------------------------------------------------------
/**
* Aliases de jetons d'opérateur, tolérés en français et en anglais.
* Un jeton n'est consommé que s'il est *connu* : `https:`, `12:30` ou un
* pseudo-inconnu restent dans la requête telle quelle.
*/
const TOKEN_ALIASES: Record<string, { key: FilterKey; value: string }> = {
// période
'heure': { key: 'period', value: 'hour' },
'hour': { key: 'period', value: 'hour' },
'aujourdhui': { key: 'period', value: 'today' },
'today': { key: 'period', value: 'today' },
'jour': { key: 'period', value: 'today' },
'day': { key: 'period', value: 'today' },
'semaine': { key: 'period', value: 'week' },
'week': { key: 'period', value: 'week' },
'mois': { key: 'period', value: 'month' },
'month': { key: 'period', value: 'month' },
'annee': { key: 'period', value: 'year' },
'année': { key: 'period', value: 'year' },
'year': { key: 'period', value: 'year' },
// durée
'court': { key: 'duration', value: 'short' },
'courte': { key: 'duration', value: 'short' },
'short': { key: 'duration', value: 'short' },
'moyen': { key: 'duration', value: 'medium' },
'moyenne': { key: 'duration', value: 'medium' },
'medium': { key: 'duration', value: 'medium' },
'long': { key: 'duration', value: 'long' },
'longue': { key: 'duration', value: 'long' },
// type
'live': { key: 'type', value: 'live' },
'direct': { key: 'type', value: 'live' },
'video': { key: 'type', value: 'video' },
'videos': { key: 'type', value: 'video' },
'vidéo': { key: 'type', value: 'video' },
'vidéos': { key: 'type', value: 'video' },
'shorts': { key: 'type', value: 'shorts' },
'clip': { key: 'type', value: 'shorts' },
'clips': { key: 'type', value: 'shorts' },
'chaine': { key: 'type', value: 'channel' },
'chaîne': { key: 'type', value: 'channel' },
'chaines': { key: 'type', value: 'channel' },
'chaînes': { key: 'type', value: 'channel' },
'channel': { key: 'type', value: 'channel' },
'channels': { key: 'type', value: 'channel' },
};
/** Jeton = mot (lettres accentuées autorisées) suivi de « : » en fin d'espace/mot. */
const TOKEN_RE = /(^|\s)([a-zA-Zà-ÿ]{2,12})\s*:(?=\s|$)/g;
export interface ParsedQuery {
/** Requête nettoyée des jetons d'opérateur reconnus. */
query: string;
/** Jeton inconnu rencontré (l'utilisateur a peut-être fait une faute). */
unknownTokens: string[];
/** Filtres déduits des jetons. */
filters: Partial<SearchFilters>;
/** Jeton le plus recently aperçu (pour l'auto-complétion `:`). */
activeToken: string | null;
}
/**
* Extrait les opérateurs de la requête (`live:`, `today:`, `long:`, …) et les
* convertit en filtres. Le dernier jeton rencontré gagne, la requête est
* renvoyée nettoyée.
*/
export function parseQueryTokens(input: string): ParsedQuery {
const source = String(input ?? '');
const filters: Partial<SearchFilters> = {};
const unknownTokens: string[] = [];
let activeToken: string | null = null;
// 1) Jeton en cours de frappe (pas encore suivi de « : ») → pas d'extraction,
// on garde la requête telle quelle.
const trailing = /(^|\s)([a-zA-Zà-ÿ]{1,12})$/.exec(source);
if (trailing) activeToken = trailing[2].toLowerCase();
const cleaned = source.replace(TOKEN_RE, (whole, lead: string, word: string) => {
const alias = TOKEN_ALIASES[String(word).toLowerCase()];
if (!alias) {
unknownTokens.push(String(word).toLowerCase());
return whole;
}
(filters as any)[alias.key] = alias.value;
return lead ? ' ' : '';
}).replace(/\s+/g, ' ').trim();
return { query: cleaned, unknownTokens, filters, activeToken };
}
/** Suggestions de jetons d'opérateur (autocomplétion après « mot : »). */
export function operatorTokenSuggestions(partial: string, limit = 8): string[] {
const p = String(partial ?? '').trim().toLowerCase();
const names = Object.keys(TOKEN_ALIASES);
const filtered = p ? names.filter(n => n.startsWith(p)) : names;
// Un seul alias par couple (clé,valeur) pour éviter 3 variantes de « short ».
const seen = new Set<string>();
const out: string[] = [];
for (const n of filtered) {
const sig = `${TOKEN_ALIASES[n].key}:${TOKEN_ALIASES[n].value}`;
if (seen.has(sig)) continue;
seen.add(sig);
out.push(n);
if (out.length >= limit) break;
}
return out;
}
// ---------------------------------------------------------------------------
// Prédicat client (réfraction des résultats déjà filtrés par le serveur)
// ---------------------------------------------------------------------------
export function itemDurationSec(item: any): number {
const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0;
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
if (typeof raw === 'string') {
// "1:23:45" / "12:34"
if (raw.includes(':')) {
const parts = raw.split(':').map(p => Number(p));
if (parts.every(p => Number.isFinite(p))) {
const sec = parts.reduce((acc, p) => acc * 60 + p, 0);
return sec;
}
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
export function itemPublishedTs(item: any): number {
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null;
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
if (typeof raw === 'string' && raw.trim()) {
const parsed = Date.parse(raw);
if (!Number.isNaN(parsed)) return parsed;
}
return 0;
}
export function isLiveItem(item: any): boolean {
if (!item) return false;
if (item.isLive === true) return true;
return String(item.type || '').toLowerCase() === 'live';
}
export function isShortItem(item: any): boolean {
if (!item) return false;
return isShortVideo({
duration: itemDurationSec(item),
type: typeof item.type === 'string' ? item.type : undefined,
provider: typeof item.provider === 'string' ? item.provider : undefined,
});
}
export function isChannelItem(item: any): boolean {
return String(item?.type || '').toLowerCase() === 'channel' && item?.isLive !== true;
}
export function isClipItem(item: any): boolean {
return String(item?.kind || '').toLowerCase() === 'clip';
}
/**
* Le provider expose-t-il un signal de type exploitable ? Si non, on ne peut
* pas conclure : mieux vaut garder le résultat (le serveur a déjà filtré) que
* de vider la liste.
*/
function hasTypeSignal(item: any): boolean {
if (!item) return false;
if (item.isLive === true || item.isLive === false) return true;
if (item.isShort === true || item.isShort === false) return true;
if (String(item.type || '').trim()) return true;
if (String(item.kind || '').trim()) return true;
return false;
}
/**
* Prédicat de réfinement client. Règle importante : un champ *absent* ne fait
* jamais disparaître un résultat (les providers ne renvoient pas tous les
* métadonnées) ; seule une valeur présente et hors borne est écartée.
*/
export function matchesFilters(item: any, filters: Partial<SearchFilters> | null | undefined): boolean {
const n = normalizeFilters(filters);
if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return true;
const bounds = durationBounds(n.duration);
if (bounds) {
const d = itemDurationSec(item);
if (d > 0 && (d < bounds.min || d >= bounds.max)) return false;
}
const maxAge = periodMs(n.period);
if (maxAge > 0) {
const ts = itemPublishedTs(item);
if (ts > 0 && (Date.now() - ts) > maxAge) return false;
}
if (n.type === 'all') return true;
if (!hasTypeSignal(item)) return true; // provider muet : on ne peut pas exclure
if (n.type === 'live') return isLiveItem(item);
if (n.type === 'channel') return isChannelItem(item);
if (n.type === 'shorts') return isShortItem(item) || isClipItem(item);
if (n.type === 'video') {
return !isLiveItem(item) && !isChannelItem(item) && !isClipItem(item) && !isShortItem(item);
}
return true;
}
+3 -3
View File
@@ -1,4 +1,5 @@
export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru';
import type { SearchFilters } from './filters';
export interface SearchItem {
id: string;
@@ -26,9 +27,8 @@ export interface ProviderSearchParams {
q: string;
pageToken?: string; // or page
sort?: 'relevance'|'date'|'views'|'duration';
time?: 'today'|'7d'|'30d'|'all';
length?: 'short'|'medium'|'long'|'all';
type?: 'video'|'live'|'shorts'|'short'|'recorded'|'channel'|'all';
/** Filtres utilisateur (type de contenu, durée, période). Vides = aucun filtre. */
filters?: Partial<SearchFilters>;
}
export interface ProviderAdapter<T = SearchItem> {
+369 -40
View File
@@ -1,13 +1,23 @@
import 'zone.js/node';
import '@angular/compiler';
// Les schedulers internes d'Angular sont exportés sous leur nom ɵ (le nom
// public `ChangeDetectionScheduler` n'existe pas dans le barrel ESM : l'import
// échouait au link sur les runtimes ESM stricts).
import { Injector, runInInjectionContext, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core';
/**
* Step 11 — Unit tests for the unified search UX components.
* Step 11 - Unit tests for the unified search UX components.
*
* ts-node compiles with isolated transpilation (no decorator output => no ɵfac),
* ts-node compiles with isolated transpilation (no decorator output => no ?fac),
* so TestBed cannot instantiate Angular classes here. We import dynamically and
* construct components inside a minimal injection context with fakes.
*
* Coverage added with the filter panel rework:
* - keyboard navigation of the suggestion panel (↑ ↓ Home End PageUp/Dn Tab
* Enter Escape, wrapping) and the '@' popover,
* - focus-out closes the panels (blur bookkeeping),
* - the filter panel (draft, roving tabindex, apply payload),
* - operator tokens (`live:`, `today:`, …) folded into filters on submit.
*/
function fmtError(e: unknown): string {
@@ -37,15 +47,43 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Faux KeyboardEvent : le composant n'appelle que preventDefault/stopPropagation. */
function key(k: string, opts: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } = {}): any {
let defaultPrevented = false;
let propagationStopped = false;
return {
key: k,
altKey: !!opts.altKey,
ctrlKey: !!opts.ctrlKey,
metaKey: !!opts.metaKey,
shiftKey: !!opts.shiftKey,
preventDefault: () => { defaultPrevented = true; },
stopPropagation: () => { propagationStopped = true; },
stopImmediatePropagation: () => { propagationStopped = true; },
get isDefaultPrevented() { return defaultPrevented; },
get isPropagationStopped() { return propagationStopped; },
};
}
(async () => {
const [{ SearchBoxComponent }, { ProviderPickerComponent }, { SearchService }, { UserService }, { HistoryService }, { TelemetryService }, { PROVIDERS }] = await Promise.all([
const [
{ SearchBoxComponent },
{ SearchFilterPanelComponent },
{ SearchService },
{ UserService },
{ HistoryService },
{ TelemetryService },
{ PROVIDERS },
{ normalizeFilters, parseQueryTokens, operatorTokenSuggestions },
] = await Promise.all([
import('../../components/search/search-box.component'),
import('../../components/search/provider-picker.component'),
import('../../components/search/search-filter-panel.component'),
import('./search.service'),
import('../../services/user.service'),
import('../../services/history.service'),
import('../../services/telemetry.service'),
import('../core/providers/provider-registry'),
import('./filters'),
]);
// --- Fakes for SearchBox dependencies ---
@@ -53,6 +91,7 @@ function sleep(ms: number): Promise<void> {
const makeSearchServiceFake = (initialProviders: any = 'all') => ({
q$: { value: '' },
providers$: { value: initialProviders },
filters$: { value: normalizeFilters(null) },
});
const makeUsersServiceFake = (prefs: any = null) => ({
@@ -139,59 +178,332 @@ function sleep(ms: number): Promise<void> {
const box = newBox();
const yt = PROVIDERS[0].id;
const ev = {
key: '1',
altKey: true,
ctrlKey: false,
metaKey: false,
shiftKey: false,
preventDefault: () => {},
stopImmediatePropagation: () => {},
} as unknown as KeyboardEvent;
const ev = key('1', { altKey: true });
box.handleKeydown(ev);
assertEqual(box.selected(), [yt], 'Alt+1 toggles the first chip (yt) on');
box.handleKeydown(ev);
box.handleKeydown(key('1', { altKey: true }));
assertEqual(box.selected(), [], 'Alt+1 again toggles the chip off');
}
// --- ProviderPicker toggles ---
// --- SearchBox: panneau de suggestions au clavier ---
function testPickerDraftSelection(): void {
const picker = new ProviderPickerComponent();
picker.selected = 'all';
assert(picker.isChecked('yt'), 'All mode checks every provider');
/** Force l'ouverture du panneau avec `n` suggestions locales. */
function openSuggestions(box: any, query: string, count = 3): void {
box.queryUpdate(query);
// Le panneau s'ouvre quand `suggestFlat()` n'est pas vide : on l'ouvre à la
// main (le réseau est absent dans les tests) puis on vérifie le clavier.
box.suggestionsOpen.set(true);
void count;
}
picker.toggle('yt');
function testSuggestionKeyboardNavigation(): void {
const box = newBox();
openSuggestions(box, 'lin', 3);
const total = box.suggestFlat().length + 1; // +1 = ligne « Rechercher … »
assert(total > 1, 'suggestion panel has rows');
// Rien de sélectionné au départ : ↓ entre par le haut.
assertEqual(box.suggestIndex(), -1, 'no row is highlighted on open');
box.handleKeydown(key('ArrowDown'));
assertEqual(box.suggestIndex(), 0, 'ArrowDown highlights the first row (search as typed)');
box.handleKeydown(key('ArrowDown'));
assertEqual(box.suggestIndex(), 1, 'ArrowDown moves to the next row');
box.handleKeydown(key('ArrowUp'));
assertEqual(box.suggestIndex(), 0, 'ArrowUp goes back');
// Home / End
box.handleKeydown(key('End'));
assertEqual(box.suggestIndex(), total - 1, 'End jumps to the last row');
box.handleKeydown(key('Home'));
assertEqual(box.suggestIndex(), 0, 'Home jumps to the first row');
// PageDown / PageUp
box.handleKeydown(key('PageDown'));
assert(box.suggestIndex() > 0, 'PageDown moves forward');
box.handleKeydown(key('PageUp'));
assertEqual(box.suggestIndex(), 0, 'PageUp moves back');
}
function testSuggestionKeyboardWrapsAndEscape(): void {
const box = newBox();
openSuggestions(box, 'lin', 3);
const total = box.suggestFlat().length + 1;
// Wrapping : on remonte de la première ligne -> dernière ligne
box.handleKeydown(key('ArrowUp'));
assertEqual(box.suggestIndex(), total - 1, 'ArrowUp from the top wraps to the last row');
box.handleKeydown(key('ArrowDown'));
assertEqual(box.suggestIndex(), 0, 'ArrowDown from the bottom wraps to the first row');
// Échap ferme le panneau sans vider la saisie
const ev = key('Escape');
box.handleKeydown(ev);
assert(box.suggestionsOpen() === false, 'Escape closes the suggestion panel');
assertEqual(box.query(), 'lin', 'Escape keeps the typed text');
assert(ev.isDefaultPrevented === true, 'Escape preventDefault (no browser default)');
}
function testSuggestionEnterSubmitsRow(): void {
const box = newBox();
openSuggestions(box, 'lin', 2);
const submitted: any[] = [];
box.submitted.subscribe((v: any) => submitted.push(v));
// Entrée sans surbrillance = recherche brute
box.handleKeydown(key('Enter'));
assertEqual(submitted.length, 1, 'Enter without highlight runs the typed search');
assertEqual(submitted[0].q, 'lin', 'the raw query is submitted');
assert(box.suggestionsOpen() === false, 'the panel closes on submit');
// Entrée sur la ligne 0 = « Rechercher … »
openSuggestions(box, 'lin', 2);
box.suggestIndex.set(0);
box.handleKeydown(key('Enter'));
assertEqual(submitted.length, 2, 'Enter on row 0 re-runs the typed search');
assertEqual(submitted[1].q, 'lin', 'row 0 submits the query as typed');
// Entrée sur une suggestion = cette suggestion
openSuggestions(box, 'lin', 2);
box.suggestIndex.set(1);
box.handleKeydown(key('Enter'));
assertEqual(submitted.length, 3, 'Enter on a suggestion submits it');
assertEqual(submitted[2].q, box.query(), 'the input holds the picked suggestion');
}
function testSuggestionTabCompletes(): void {
const box = newBox();
openSuggestions(box, 'lin', 3);
const submitted: any[] = [];
box.submitted.subscribe((v: any) => submitted.push(v));
const ev = key('Tab');
box.handleKeydown(ev);
assert(ev.isDefaultPrevented === true, 'Tab is intercepted while the panel is open');
assertEqual(submitted.length, 0, 'Tab completes without searching');
assert(box.suggestionsOpen() === true, 'the panel stays open after a Tab completion');
}
function testEscapeClearsQueryWhenNothingOpen(): void {
const box = newBox();
box.queryUpdate('linux');
box.handleKeydown(key('Escape'));
assertEqual(box.query(), '', 'Escape with no panel open clears the query');
}
// --- SearchBox: focus (le panneau se ferme quand le focus sort) ---
async function testFocusOutClosesPanels(): Promise<void> {
const box = newBox();
openSuggestions(box, 'lin', 3);
assert(box.suggestionsOpen() === true, 'panel is open before the focus leaves');
// relatedTarget hors du formulaire -> fermeture
const form = { contains: (el: any) => el === inside };
const inside = { id: 'inside' };
box.formRef = { nativeElement: form } as any;
const ev: any = { relatedTarget: { id: 'elsewhere' } };
box.onFocusOut(ev);
await sleep(20);
assert(box.suggestionsOpen() === false, 'focusout with an outside target closes the panel');
}
async function testFocusOutKeepsPanelInside(): Promise<void> {
const box = newBox();
openSuggestions(box, 'lin', 3);
const inside = { id: 'inside' };
box.formRef = { nativeElement: { contains: (el: any) => el === inside } } as any;
box.onFocusOut({ relatedTarget: inside } as any);
await sleep(20);
assert(box.suggestionsOpen() === true, 'focusout toward a node inside the form keeps the panel open');
}
async function testFocusInCancelsPendingClose(): Promise<void> {
const box = newBox();
openSuggestions(box, 'lin', 3);
const inside = { id: 'inside' };
box.formRef = { nativeElement: { contains: () => false } } as any;
box.onFocusOut({ relatedTarget: null } as any); // blur « sale » -> timer armé
box.onFocusIn({} as any); // le focus revient
await sleep(20);
assert(box.suggestionsOpen() === true, 'focusin cancels the pending close (no flicker)');
}
function testPointerDownKeepsFocusInInput(): void {
const box = newBox();
const ev: any = { preventDefault: () => { ev.defaultPrevented = true; } };
box.onPanelPointerDown(ev);
assert(ev.defaultPrevented === true, 'pointerdown inside a panel is neutralized (input keeps focus)');
}
// --- SearchBox: opérateurs et filtres ---
function testOperatorTokensFoldIntoFilters(): void {
const parsed = parseQueryTokens('linux today: long: live:');
assertEqual(parsed.query, 'linux', 'operators are stripped from the query');
assertEqual(parsed.filters, { period: 'today', duration: 'long', type: 'live' }, 'operators map to filters');
// Un mot inconnu reste dans la requête (pas de typo destructive)
const unknown = parseQueryTokens('tuto android:');
assertEqual(unknown.query, 'tuto android:', 'unknown token stays in the query');
assertEqual(unknown.unknownTokens, ['android'], 'unknown token is reported');
// Une URL / heure ne doit pas être interprétée
assertEqual(parseQueryTokens('visite https://x.com').query, 'visite https://x.com', 'URLs are untouched');
assert(operatorTokenSuggestions('li').includes('live'), 'operator autocompletion offers live:');
}
function testSubmitAppliesOperatorTokens(): void {
const box = newBox();
const submitted: any[] = [];
box.submitted.subscribe((v: any) => submitted.push(v));
box.queryUpdate('concert live:');
box.onSubmit({ preventDefault: () => {} } as any);
assertEqual(submitted.length, 1, 'search is submitted');
assertEqual(submitted[0].q, 'concert', 'the operator is not part of the query');
assertEqual(submitted[0].filters, normalizeFilters({ type: 'live' }), 'the operator became a filter');
assertEqual(box.activeFilterCount(), 1, 'one filter is active');
}
function testActiveFilterChipsAndRemoval(): void {
const box = newBox();
box.queryUpdate('chat');
box.applyPanel({
selection: 'all',
filters: normalizeFilters({ type: 'live', duration: 'short', period: 'week' }),
rememberDefault: false,
});
assertEqual(box.activeFilterCount(), 3, 'three filters are active');
assertEqual(box.activeFilterChips().map((c: any) => c.key), ['type', 'period', 'duration'], 'chips list every active filter');
box.removeFilter('duration');
assertEqual(box.activeFilterCount(), 2, 'removing a chip updates the counter');
box.clearAllFilters();
assertEqual(box.activeFilterCount(), 0, 'clear-all resets every filter');
assertEqual(box.activeFilterChips().length, 0, 'no chip left');
}
function testApplyPanelPersistsProvidersAndFilters(): void {
const box = newBox();
const submitted: any[] = [];
box.submitted.subscribe((v: any) => submitted.push(v));
box.queryUpdate('rust');
box.applyPanel({
selection: ['yt', 'tw'],
filters: normalizeFilters({ period: 'month' }),
rememberDefault: false,
});
assertEqual(box.providersForRequest(), ['yt', 'tw'], 'sources are applied');
assertEqual(box.filters().period, 'month', 'filters are applied');
assert(box.panelOpen() === false, 'the panel closes after apply');
assertEqual(submitted.length, 1, 'an in-flight search is re-run with the new filters');
assertEqual(submitted[0].filters.period, 'month', 'the submitted payload carries the filters');
}
function testPanelDoesNotRelaunchSearchWithoutQuery(): void {
const box = newBox();
const submitted: any[] = [];
box.submitted.subscribe((v: any) => submitted.push(v));
box.applyPanel({
selection: 'all',
filters: normalizeFilters({ type: 'shorts' }),
rememberDefault: false,
});
assertEqual(submitted.length, 0, 'no search is launched while the query is empty');
assertEqual(box.filters().type, 'shorts', 'filters are still remembered');
}
// --- Panneau de filtres ---
function newPanel(providers: any = 'all', filters: any = null, history: string[] = []): any {
const panel = new SearchFilterPanelComponent();
panel.open = true;
panel.history = history;
if (providers !== undefined) panel.selected = providers;
if (filters !== undefined) panel.filters = filters;
return panel;
}
function testPanelDraftSelection(): void {
const panel = newPanel('all');
assert(panel.isChecked('yt'), 'All mode checks every provider');
panel.toggle('yt');
const yt = 'yt';
assert(picker.isChecked(yt) === false, 'unchecking from All removes only that provider');
assert(picker.isChecked('dm'), 'other providers stay checked after unchecking one');
assertEqual(picker.allMode(), false, 'All mode turns off after unchecking');
assert(panel.isChecked(yt) === false, 'unchecking from All removes only that provider');
assert(panel.isChecked('dm'), 'other providers stay checked after unchecking one');
assertEqual(panel.allMode(), false, 'All mode turns off after unchecking');
picker.toggle(yt);
assert(picker.isChecked(yt), 're-checking adds the provider back');
panel.toggle(yt);
assert(panel.isChecked(yt), 're-checking adds the provider back');
let emitted: any = null;
picker.apply.subscribe((v: any) => { emitted = v; });
picker.onApply();
panel.apply.subscribe((v: any) => { emitted = v; });
panel.onApply();
assert(Array.isArray(emitted?.selection), 'apply emits a list selection');
assertEqual(emitted.selection.length, PROVIDERS.length, 'apply emits every provider when all are checked');
assertEqual(emitted.filters, normalizeFilters(null), 'apply emits normalized (default) filters');
picker.toggleAllMode();
picker.onApply();
panel.toggleAllMode();
panel.onApply();
assertEqual(emitted.selection, 'all', 'apply emits all after toggleAllMode');
assert(emitted.rememberDefault === false, 'rememberDefault defaults to false');
}
function testPickerRememberDefault(): void {
const picker = new ProviderPickerComponent();
picker.selected = ['yt'];
picker.rememberDefault.set(true);
function testPanelFiltersAndRememberDefault(): void {
const panel = newPanel(['yt'], null);
panel.rememberDefault.set(true);
let emitted: any = null;
picker.apply.subscribe((v: any) => { emitted = v; });
picker.toggle('dm');
picker.onApply();
panel.apply.subscribe((v: any) => { emitted = v; });
panel.toggle('dm');
panel.selectFilter('type', 'live');
panel.selectFilter('period', 'week');
panel.selectFilter('duration', 'short');
panel.selectFilter('sort', 'date');
panel.onApply();
assertEqual(emitted.selection, ['yt', 'dm'], 'draft selection is emitted on apply');
assert(emitted.rememberDefault === true, 'rememberDefault flag is emitted');
assertEqual(emitted.filters, { type: 'live', duration: 'short', period: 'week', sort: 'date' }, 'every filter dimension is emitted');
}
function testPanelRovingTabindexFollowsSelection(): void {
const panel = newPanel('all', normalizeFilters({ type: 'live', period: 'month' }));
// L'onglet actif d'un groupe démarre sur l'option sélectionnée.
assertEqual(panel.activeIndex('type'), panel.optionIndexOf(panel.groups()[0], 'live'), 'type group starts on the selected option');
assertEqual(panel.activeIndex('period'), panel.optionIndexOf(panel.groups()[1], 'month'), 'period group starts on the selected option');
// Une sélection déplace l'onglet actif.
panel.selectFilter('type', 'shorts');
assertEqual(panel.activeIndex('type'), panel.optionIndexOf(panel.groups()[0], 'shorts'), 'selecting moves the roving tabindex');
}
function testPanelKeyboardRovingAndEscape(): void {
const panel = newPanel('all');
const focused: any[] = [];
// Sans DOM, `focusTab` ne trouve rien : on vérifie l'index mis à jour.
(panel as any).dialogRef = { nativeElement: { querySelector: () => null, focus: () => {} } };
const target: any = { closest: () => ({ getAttribute: () => 'type' }) };
const ev: any = key('ArrowRight');
(ev as any).target = target;
panel.onDialogKeydown(ev);
assert(ev.isDefaultPrevented === true, 'ArrowRight is handled (no page scroll)');
assertEqual(panel.activeIndex('type'), 1, 'ArrowRight moves to the next option of the group');
assertEqual(focused.length, 0, 'no crash without a real DOM');
void focused;
// Début / fin de groupe
(panel as any).dialogRef = { nativeElement: { querySelector: () => null, focus: () => {} } };
const end: any = key('End');
(end as any).target = target;
panel.onDialogKeydown(end);
assert(panel.activeIndex('type') > 1, 'End jumps to the last option');
// Échap ferme
let closed = 0;
panel.close.subscribe(() => { closed++; });
panel.onDialogKeydown(key('Escape'));
assertEqual(closed, 1, 'Escape closes the panel');
}
function testPanelReset(): void {
const panel = newPanel(['yt'], normalizeFilters({ type: 'live' }));
panel.toggle('dm');
panel.reset();
assertEqual(panel.allMode(), true, 'reset returns to All sources');
assertEqual(panel.draftFilters(), normalizeFilters(null), 'reset clears every filter');
}
const tests: Array<[string, () => void | Promise<void>]> = [
@@ -199,18 +511,35 @@ function sleep(ms: number): Promise<void> {
['SearchBox applyAtOption strips term and toggles chip', testApplyAtOptionStripsTermAndTogglesChip],
['SearchBox applies defaultProviders preference', testDefaultProvidersPreferenceApplied],
['SearchBox Alt+1..6 shortcut toggles chips', testAltShortcutsToggleChips],
['ProviderPicker draft selection + apply', testPickerDraftSelection],
['ProviderPicker rememberDefault flag', testPickerRememberDefault],
['SearchBox suggestion panel: ↑ ↓ Home End PageUp/Down', testSuggestionKeyboardNavigation],
['SearchBox suggestion panel: wrapping + Escape', testSuggestionKeyboardWrapsAndEscape],
['SearchBox suggestion panel: Enter validates the highlighted row', testSuggestionEnterSubmitsRow],
['SearchBox suggestion panel: Tab completes without searching', testSuggestionTabCompletes],
['SearchBox Escape clears the query when nothing is open', testEscapeClearsQueryWhenNothingOpen],
['SearchBox focusout closes the panel when focus leaves', testFocusOutClosesPanels],
['SearchBox focusout keeps the panel when focus stays inside', testFocusOutKeepsPanelInside],
['SearchBox focusin cancels the pending close', testFocusInCancelsPendingClose],
['SearchBox pointerdown inside a panel does not steal focus', testPointerDownKeepsFocusInInput],
['SearchBox operator tokens (live:, today:, long:)', testOperatorTokensFoldIntoFilters],
['SearchBox submit turns operators into filters', testSubmitAppliesOperatorTokens],
['SearchBox active filter chips + removal', testActiveFilterChipsAndRemoval],
['SearchBox applyPanel persists sources/filters and re-runs', testApplyPanelPersistsProvidersAndFilters],
['SearchBox applyPanel without query does not search', testPanelDoesNotRelaunchSearchWithoutQuery],
['FilterPanel draft selection + apply', testPanelDraftSelection],
['FilterPanel filters + rememberDefault', testPanelFiltersAndRememberDefault],
['FilterPanel roving tabindex follows the selection', testPanelRovingTabindexFollowsSelection],
['FilterPanel arrow keys + Escape', testPanelKeyboardRovingAndEscape],
['FilterPanel reset', testPanelReset],
];
let failures = 0;
for (const [name, fn] of tests) {
try {
await fn();
console.log(`✓ ${name}`);
console.log(`V ${name}`);
} catch (error) {
failures += 1;
console.error(`✗ ${name}`);
console.error(`? ${name}`);
console.error(error instanceof Error ? error.stack : fmtError(error));
}
}
+33 -5
View File
@@ -11,6 +11,7 @@ import { OdAdapter } from './adapters/od';
import { RuAdapter } from './adapters/ru';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
import { DEFAULT_FILTERS, activeFilters, normalizeFilters, type SearchFilters } from './filters';
export type SuggestionItem = SuggestionItemV1;
export type SearchResponse = SearchResponseV1;
@@ -28,6 +29,11 @@ export class SearchService {
readonly page$ = new BehaviorSubject<number>(1);
readonly pageSize$ = new BehaviorSubject<number>(24);
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
/**
* Filtres de recherche (type / durée / période / tri). Source de vérité des
* filtres UI : le tri y est aussi reflété (`sort$` reste pour compat).
*/
readonly filters$ = new BehaviorSubject<SearchFilters>({ ...DEFAULT_FILTERS });
// In-memory cache 60s per (provider, q, params)
private cache = new Map<CacheKey, CacheEntry<VideoItem>>();
@@ -43,7 +49,7 @@ export class SearchService {
ru: new RuAdapter(this.http)
};
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$]).pipe(
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$, this.filters$]).pipe(
debounceTime(120),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
);
@@ -51,11 +57,13 @@ export class SearchService {
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
readonly request$ = this.params$.pipe(
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
switchMap(([q, prov, page, pageSize, sort]) => from(this.runAdapters({
switchMap(([q, prov, page, pageSize, sort, filters]) => from(this.runAdapters({
q: String(q),
pageToken: String(page || 1),
sort: (sort as any) || 'relevance',
// global filter knobs could be added here: time/length/type in the future
// Filtres globaux (type / durée / période) : envoyés à chaque adapter,
// appliqués nativement par le provider quand c'est possible.
filters: normalizeFilters(filters),
}, prov)))
,
// Map provider results to the format expected by the UI
@@ -88,7 +96,7 @@ export class SearchService {
const tasks = active.map(async (pid) => {
const adapter = this.adapters[pid];
if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`;
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}|${this.filterKey(params.filters)}`;
const now = Date.now();
const cached = this.cache.get(key);
if (cached && (now - cached.t) < this.cacheTtlMs) {
@@ -127,6 +135,26 @@ export class SearchService {
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') {
const allowed: Array<'relevance' | 'date' | 'views' | 'duration'> = ['relevance', 'date', 'views', 'duration'];
this.sort$.next(allowed.includes(sort) ? sort : 'relevance');
const next = allowed.includes(sort) ? sort : 'relevance';
this.sort$.next(next);
// Le tri vit aussi dans `filters` (source de vérité des filtres) : on garde
// les deux BehaviorSubjects synchronisés sans boucle (next -> next).
const current = this.filters$.value;
const mapped = next === 'duration' ? 'relevance' : next;
if (current.sort !== mapped) this.filters$.next({ ...current, sort: mapped as SearchFilters['sort'] });
}
/** Applique un patch de filtres (normalisé) et synchronise `sort$`. */
setFilters(patch: Partial<SearchFilters> | null | undefined) {
const next = normalizeFilters({ ...this.filters$.value, ...(patch || {}) });
if (JSON.stringify(next) === JSON.stringify(this.filters$.value)) return;
this.filters$.next(next);
if (this.sort$.value !== next.sort) this.sort$.next(next.sort as any);
}
resetFilters() { this.setFilters({ ...DEFAULT_FILTERS }); }
private filterKey(filters: Partial<SearchFilters> | null | undefined): string {
const n = normalizeFilters(filters);
return `${n.type}.${n.duration}.${n.period}.${n.sort}`;
}
/** Filtres actifs seulement (pour l'URL / le partage). */
activeFilters(): Partial<SearchFilters> { return activeFilters(this.filters$.value); }
}