feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s

- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts)
- ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage
- ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error
- Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
2026-09-25 10:15:12 -04:00
parent fe35340948
commit 97e5de74f5
147 changed files with 7954 additions and 77497 deletions
+11 -2
View File
@@ -13,7 +13,13 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
const isChannel = String(it?.type || '').toLowerCase() === 'channel';
// /api/search (tw) ne retourne que des chaînes : le player exige le login
// (?channel=<login>), jamais l'id numérique. Sans `channel`, /watch tente
// ?video=<id> et Twitch répond par une erreur.
const login: string | undefined =
it.channelExternalId || it.channelHandle || undefined;
const kind = String(it?.type || '').toLowerCase();
const isChannel = kind === 'channel' || kind === 'live' || Boolean(login);
return {
id: it.id,
provider: 'twitch',
@@ -23,7 +29,10 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : undefined,
publishedAt: it.publishedAt,
channel: isChannel ? it.id : undefined,
channelExternalId: login,
channelAvatarUrl: it.uploaderAvatar || undefined,
// Login pour l'embed ?channel=... (video-card -> watchQueryParams).
channel: isChannel ? login : undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
+223
View File
@@ -0,0 +1,223 @@
import 'zone.js/node';
import '@angular/compiler';
import { Injector, runInInjectionContext, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core';
/**
* Step 11 — Unit tests for the unified search UX components.
*
* 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.
*/
function fmtError(e: unknown): string {
if (!e) return String(e);
if (e instanceof Error) return e.stack || `${e.name}: ${e.message}`;
try { return JSON.stringify(e, Object.getOwnPropertyNames(e)); } catch { return String(e); }
}
process.on('uncaughtException', (e) => { console.error('UNCAUGHT:', fmtError(e)); process.exit(1); });
process.on('unhandledRejection', (e) => { console.error('UNHANDLED:', fmtError(e)); process.exit(1); });
function assertEqual(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual);
const b = JSON.stringify(expected);
if (a !== b) {
throw new Error(`${message} (expected ${b}, got ${a})`);
}
}
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
(async () => {
const [{ SearchBoxComponent }, { ProviderPickerComponent }, { SearchService }, { UserService }, { HistoryService }, { TelemetryService }, { PROVIDERS }] = await Promise.all([
import('../../components/search/search-box.component'),
import('../../components/search/provider-picker.component'),
import('./search.service'),
import('../../services/user.service'),
import('../../services/history.service'),
import('../../services/telemetry.service'),
import('../core/providers/provider-registry'),
]);
// --- Fakes for SearchBox dependencies ---
const makeSearchServiceFake = (initialProviders: any = 'all') => ({
q$: { value: '' },
providers$: { value: initialProviders },
});
const makeUsersServiceFake = (prefs: any = null) => ({
preferences: () => prefs,
updatePreferences: (_patch: any) => ({ subscribe: () => {} }),
});
const makeHistoryServiceFake = () => ({
getSearchHistory: (_n: number) => ({ subscribe: () => {} }),
});
function newBox(opts: { prefs?: any; providers?: any } = {}): any {
// Constructor uses inject() + effect(): provide a minimal injection context with
// the Angular internal schedulers plus fakes for the component's own deps.
// The EffectScheduler mock runs the effect work immediately so component effects
// (hydration, preference application) execute deterministically in tests.
const injector = Injector.create({
providers: [
{ provide: ChangeDetectionScheduler, useValue: { notify: () => {} } },
{ provide: EffectScheduler, useValue: { add: (e: any) => { try { e?.run?.(); } catch {} }, remove: () => {}, schedule: (e: any) => { try { e?.run?.(); } catch {} }, flush: () => {} } },
{ provide: SearchService, useValue: makeSearchServiceFake(opts.providers ?? 'all') },
{ provide: UserService, useValue: makeUsersServiceFake(opts.prefs ?? null) },
{ provide: HistoryService, useValue: makeHistoryServiceFake() },
{ provide: TelemetryService, useValue: { track: () => {} } },
],
});
return runInInjectionContext(injector, () => new SearchBoxComponent());
}
// --- SearchBox: inline @ parsing ---
function testAtParsingDetectsAtQuery(): void {
const box = newBox();
box.queryUpdate('hello world');
assert(box.atOptions().length === 0, 'plain text yields no @ options');
assert(box.atOpen() === false, 'atOpen stays false on plain text');
box.queryUpdate('look for @yo');
const opts = box.atOptions();
assert(opts.length === 1, `'@yo' filters to one provider (got ${opts.length})`);
assertEqual(opts[0].id, 'yt', "'@yo' matches YouTube");
assert(box.atOpen() === true, 'atOpen becomes true when @ options exist');
box.queryUpdate('look for @yt');
assert(box.atOptions().length === 1, '@yt still offers YouTube');
assertEqual(box.atOptions()[0].id, 'yt', '@yt matches YouTube');
box.queryUpdate('look for @yt ');
assert(box.atOpen() === false, 'space after @term closes autocomplete');
box.queryUpdate('look for @zz');
assert(box.atOpen() === false && box.atOptions().length === 0, '@zz yields no options');
}
function testApplyAtOptionStripsTermAndTogglesChip(): void {
const box = newBox();
box.queryUpdate('linux @dm');
box.applyAtOption('dm');
assertEqual(box.query(), 'linux', 'applying an @ option strips the @term from the query');
assertEqual(box.selected(), ['dm'], 'applying an @ option toggles the provider chip on');
assert(box.atOpen() === false, 'popover closes after applying');
assertEqual(box.providerMode(), 'custom', 'provider mode switches to custom');
box.applyAtOption('dm');
assertEqual(box.selected(), [], 'applying again toggles the chip off');
box.toggleChip('pt');
assertEqual(box.providersForRequest(), ['pt'], 'providersForRequest returns the selected list');
box.toggleAll();
assertEqual(box.providersForRequest(), 'all', 'toggleAll switches back to all');
}
async function testDefaultProvidersPreferenceApplied(): Promise<void> {
const box = newBox({ prefs: { defaultProviders: ['yt', 'dm'] } });
// Allow queued microtasks/timeouts a tick (effects are driven by the fake scheduler)
await sleep(50);
assertEqual(box.providerMode(), 'custom', 'preference switches mode to custom');
assertEqual(box.selected(), ['yt', 'dm'], 'preference providers become selected');
}
function testAltShortcutsToggleChips(): 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;
box.handleKeydown(ev);
assertEqual(box.selected(), [yt], 'Alt+1 toggles the first chip (yt) on');
box.handleKeydown(ev);
assertEqual(box.selected(), [], 'Alt+1 again toggles the chip off');
}
// --- ProviderPicker toggles ---
function testPickerDraftSelection(): void {
const picker = new ProviderPickerComponent();
picker.selected = 'all';
assert(picker.isChecked('yt'), 'All mode checks every provider');
picker.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');
picker.toggle(yt);
assert(picker.isChecked(yt), 're-checking adds the provider back');
let emitted: any = null;
picker.apply.subscribe((v: any) => { emitted = v; });
picker.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');
picker.toggleAllMode();
picker.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);
let emitted: any = null;
picker.apply.subscribe((v: any) => { emitted = v; });
picker.toggle('dm');
picker.onApply();
assertEqual(emitted.selection, ['yt', 'dm'], 'draft selection is emitted on apply');
assert(emitted.rememberDefault === true, 'rememberDefault flag is emitted');
}
const tests: Array<[string, () => void | Promise<void>]> = [
['SearchBox @ parsing detects and filters providers', testAtParsingDetectsAtQuery],
['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],
];
let failures = 0;
for (const [name, fn] of tests) {
try {
await fn();
console.log(`✓ ${name}`);
} catch (error) {
failures += 1;
console.error(`✗ ${name}`);
console.error(error instanceof Error ? error.stack : fmtError(error));
}
}
console.log(failures === 0 ? '\nAll search component tests passed.' : `\n${failures} test(s) failed.`);
process.exit(failures === 0 ? 0 : 1);
})().catch((e) => {
console.error('Unhandled test runner error:', fmtError(e));
process.exit(1);
});
+158
View File
@@ -0,0 +1,158 @@
import 'zone.js/node';
import '@angular/compiler'; // JIT needed: decorators are evaluated at import time under ts-node
import { of } from 'rxjs';
import { Injector, runInInjectionContext } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SearchService } from './search.service';
import type { ProviderId } from '../core/providers/provider-registry';
/**
* Step 11 — Unit tests for SearchService.
*
* ts-node compiles in isolation (no decorator output => no ɵfac), so TestBed
* cannot instantiate Angular classes here. We build the service manually with
* a minimal HttpClient fake returning real Observables.
*/
function assertEqual(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual);
const b = JSON.stringify(expected);
if (a !== b) {
throw new Error(`${message} (expected ${b}, got ${a})`);
}
}
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(message);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface RecordedRequest {
url: string;
providerParam: string | null;
}
function makeHttpFake(payloads: Record<string, any>) {
const requests: RecordedRequest[] = [];
const fake = {
// Adapters call firstValueFrom(http.get(...)) — return a real Observable
get: (url: string, opts: any) => {
let providerParam: string | null = null;
try { providerParam = opts?.params?.get('providers') ?? null; } catch {}
requests.push({ url, providerParam });
// provider id embedded in the request is the key into payloads
const key = providerParam || 'all';
return of(payloads[key] ?? { groups: {} });
},
requests,
};
return fake;
}
function makeService(payloads: Record<string, any> = {}) {
const httpFake = makeHttpFake(payloads);
// SearchService uses inject(HttpClient): create it inside an injection context
const injector = Injector.create({
providers: [{ provide: HttpClient, useValue: httpFake as unknown as HttpClient }],
});
const service = runInInjectionContext(injector, () => new SearchService());
return { service, httpFake };
}
function testSetProvidersNormalizesValues(): void {
const { service } = makeService();
service.setProviders(['yt', 'dm'] as ProviderId[]);
assertEqual(service.providers$.value, ['yt', 'dm'], 'setProviders keeps a non-empty list');
service.setProviders([] as any);
assertEqual(service.providers$.value, 'all', 'empty list falls back to all');
service.setProviders('all');
assertEqual(service.providers$.value, 'all', 'all stays all');
}
function testSetPageAndPageSizeNormalize(): void {
const { service } = makeService();
service.setPage(3);
assert(service.page$.value === 3, 'page 3 is kept');
service.setPage(-5);
assert(service.page$.value === 1, 'negative page is floored to ' + 1);
service.setPageSize(999);
assert(service.pageSize$.value === 50, 'pageSize is capped at 50');
service.setPageSize(0);
assert(service.pageSize$.value === 24, 'pageSize 0 falls back to default 24');
service.setSort('views');
assertEqual(service.sort$.value, 'views', 'sort is applied');
service.setSort('bogus' as any);
assertEqual(service.sort$.value, 'relevance', 'invalid sort falls back to relevance');
}
async function testRequestFanOutPerProvider(): Promise<void> {
const payload = {
yt: { groups: { yt: [{ id: 'a', title: 'A' }] } },
dm: { groups: { dm: [{ id: 'b', title: 'B' }] } },
};
const { service, httpFake } = makeService(payload);
// request$ is cold: subscribing activates the params$ pipeline
const responses: any[] = [];
service.request$.subscribe((r: any) => responses.push(r));
service.setQuery('linux');
service.setProviders(['yt', 'dm'] as ProviderId[]);
// params$ debounces 120ms before fanning out to adapters
await sleep(400);
assert(httpFake.requests.length === 2, `two adapter HTTP calls expected (got ${httpFake.requests.length})`);
const providerParams = httpFake.requests.map((r) => r.providerParam).sort();
assertEqual(providerParams, ['dm', 'yt'], 'each adapter requests its own provider id');
// The aggregated response exposes groups keyed by provider
await sleep(50);
assert(responses.length >= 1, 'request$ emitted an aggregated response');
const last = responses[responses.length - 1];
assertEqual(last.groups?.yt?.length, 1, 'yt group has 1 item');
assertEqual(last.groups?.dm?.length, 1, 'dm group has 1 item');
}
async function testShortQueryTriggersNothing(): Promise<void> {
const { service, httpFake } = makeService({});
service.setQuery('a');
await sleep(350);
assert(httpFake.requests.length === 0, 'query shorter than 2 chars triggers no request');
}
const tests: Array<[string, () => void | Promise<void>]> = [
['SearchService.setProviders normalizes values', testSetProvidersNormalizesValues],
['SearchService.setPage/setPageSize/setSort normalize', testSetPageAndPageSizeNormalize],
['SearchService.request$ fans out one call per provider', testRequestFanOutPerProvider],
['SearchService ignores short queries', testShortQueryTriggersNothing],
];
(async () => {
let failures = 0;
for (const [name, fn] of tests) {
try {
await fn();
console.log(`✓ ${name}`);
} catch (error) {
failures += 1;
console.error(`✗ ${name}`);
console.error(error instanceof Error ? error.stack : error);
}
}
console.log(failures === 0 ? '\nAll SearchService tests passed.' : `\n${failures} test(s) failed.`);
if (failures > 0) {
process.exitCode = 1;
}
// Explicit exit: keep-alive handles (BehaviorSubjects, timers) can hang the process
process.exit(failures === 0 ? 0 : 1);
})().catch((e) => {
console.error('Unhandled test runner error:', e instanceof Error ? e.stack : e);
process.exit(1);
});
+8 -2
View File
@@ -111,8 +111,14 @@ export class SearchService {
// Convenience setter helpers
setQuery(q: string) { this.q$.next(q || ''); }
setProviders(list: ProviderId[] | 'all') { this.providers$.next(list && (Array.isArray(list) ? list : 'all')); }
setProviders(list: ProviderId[] | 'all') {
// Normalize: 'all' or a non-empty list; anything else falls back to 'all'
this.providers$.next(list === 'all' || (Array.isArray(list) && list.length > 0) ? list : 'all');
}
setPage(page: number) { this.page$.next(Math.max(1, Math.floor(page || 1))); }
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') { this.sort$.next(sort || 'relevance'); }
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');
}
}