feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
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:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user