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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface VideoItem {
|
||||
// Live status (only providers exposing it, e.g. Twitch, set these)
|
||||
type?: string; // e.g. 'live' | 'channel' | 'video'
|
||||
isLive?: boolean; // true when actually broadcasting now
|
||||
isShort?: boolean; // true when the provider flagged a Short/clip
|
||||
kind?: string; // e.g. Twitch 'vod' | 'clip'
|
||||
game?: string; // e.g. Twitch game/category name
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user