Nouveau formulaire "Recherche YouTube" (mots-cles + limite optionnelle), branche sur la zone de resultats existante (miniatures, bouton "Ajouter" par ligne ; pas de "Tout ajouter", comme demande). - Avec une limite : tous les resultats d'un coup (une seule page). - Sans limite : defilement infini. Un element sentinelle en fin de liste, observe par un IntersectionObserver (racine = la liste, marge 300 px), charge la page suivante ; il est rearme apres chaque page, affiche chargement / erreur avec "Reessayer" / "Fin des resultats", et une page en echec ne fait pas perdre les precedentes. Serveur (GET/POST/DELETE /api/search) : une "session" = un seul yt-dlp (--flat-playlist --lazy-playlist -j -- ytsearch1000:<requete>) servi page par page depuis un tampon. Pourquoi pas un appel yt-dlp par page - mesure avec le vrai yt-dlp : l'ordre des resultats de YouTube n'est pas deterministe meme pour deux appels identiques (5/20 aux memes positions, 69 % de recouvrement sur 100), donc paginer en relancant la recherche donnait 15 a 30 % de doublons des la 3e-4e page et des trous ; YouTube s'arrete vers ~590 resultats ; ~7 % de doublons internes (dedoublonnes a l'ingestion) ; sans --lazy-playlist yt-dlp n'imprime rien avant la fin (~23 s), avec il emet au fil des pages (20 en ~2,3 s). Le parcours n'avance qu'a la demande : quand le tampon a 20 resultats d'avance, la lecture de stdout est suspendue (nouvelle option onSpawn de runYtDlp) ; le tube se remplit et yt-dlp bloque (mesure : plus aucune ligne pendant 10 s de pause). On n'aspire donc pas ~590 resultats, et ~30 requetes vers YouTube, quand l'utilisateur ne defile pas. Cycle de vie : 4 sessions simultanees max (la moins recemment utilisee est fermee), expiration a 10 min d'inactivite / 30 min de vie, fermeture des que la derniere page est servie, sur DELETE (liste remplacee cote client), au pagehide du client, et a l'arret du serveur. Requete validee (1 a 200 caracteres, sans caractere de controle) et transmise en un seul argument ytsearchN:<requete>, jamais interpretable comme une option. Mise en page : ce troisieme formulaire laissait a la liste ~104 px (une ligne) a 1366x650. Une liste non vide garde min-height 13rem et la colonne du formulaire defile au lieu de rogner (la page ne defile toujours pas). Verifie : contre le vrai yt-dlp (page tamponnee en 4 ms, page 200 apres 12 s d'inactivite en ~4 s : la suspension tient ; 29 pages -> 576 videos, 576 uniques ; processus fermes a la fin / sur DELETE / au plafond) et dans le navigateur (580 resultats sans doublon jusqu'a "Fin des resultats", requetes de page uniques malgre deux signaux simultanes, echec + reprise, session expiree, remplacement de la recherche, mode limite, ajout d'un resultat a la file, pagehide, playlist et chaine en non-regression). Le volet IntersectionObserver a ete pilote par un observateur factice : le panneau de navigation n'affichait plus de frames (rAF/IO natifs muets). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
import type { FastifyBaseLogger } from 'fastify';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
|
||
return { ...actual, runYtDlp: vi.fn() };
|
||
});
|
||
|
||
import { SearchService, type SearchServiceOptions } from '../src/services/SearchService.js';
|
||
import { InvalidSearchQueryError } from '../src/services/searchQuery.js';
|
||
import { runYtDlp } from '../src/services/ytDlpRunner.js';
|
||
import { type FakeYtDlpRun, installFakeYtDlp } from './fakeYtDlp.js';
|
||
|
||
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
|
||
|
||
let runs: FakeYtDlpRun[];
|
||
|
||
/**
|
||
* @param overrides - Réglages à surcharger.
|
||
* @returns Un service de test.
|
||
*/
|
||
function createService(overrides: Partial<SearchServiceOptions> = {}): SearchService {
|
||
return new SearchService(noopLogger, overrides);
|
||
}
|
||
|
||
/**
|
||
* Ouvre une recherche dont le yt-dlp simulé produit `entries` résultats.
|
||
*
|
||
* @param service - Service concerné.
|
||
* @param entries - Nombre de résultats à faire produire au yt-dlp simulé.
|
||
* @param limit - Taille de la première page.
|
||
* @returns L'identifiant de la recherche, sa première page, et son yt-dlp simulé.
|
||
*/
|
||
async function openSearch(
|
||
service: SearchService,
|
||
entries: number,
|
||
limit = 5,
|
||
): Promise<{ id: string; run: FakeYtDlpRun; nextOffset: number | null }> {
|
||
const started = service.start('speed climbing', 1000, limit);
|
||
const run = runs.at(-1);
|
||
if (!run) {
|
||
throw new Error('yt-dlp simulé non lancé');
|
||
}
|
||
run.emitEntries(entries);
|
||
const { id, page } = await started;
|
||
return { id, run, nextOffset: page.nextOffset };
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(runYtDlp).mockReset();
|
||
runs = installFakeYtDlp(vi.mocked(runYtDlp));
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
describe('SearchService', () => {
|
||
it('ouvre une recherche et retourne son identifiant et sa première page', async () => {
|
||
const service = createService();
|
||
|
||
const { id, run, nextOffset } = await openSearch(service, 20, 5);
|
||
|
||
expect(id).toEqual(expect.any(String));
|
||
expect(nextOffset).toBe(5);
|
||
expect(run.args.at(-1)).toBe('ytsearch1000:speed climbing');
|
||
service.closeAll();
|
||
});
|
||
|
||
it('sert les pages suivantes d’une recherche ouverte', async () => {
|
||
const service = createService();
|
||
const { id } = await openSearch(service, 20, 5);
|
||
|
||
const page = await service.page(id, 5, 5);
|
||
|
||
expect(page?.videos.map((video) => video.id)).toEqual(['id6', 'id7', 'id8', 'id9', 'id10']);
|
||
expect(page?.nextOffset).toBe(10);
|
||
service.closeAll();
|
||
});
|
||
|
||
it('retourne null pour une recherche inconnue', async () => {
|
||
const service = createService();
|
||
|
||
await expect(service.page('inconnue', 0, 5)).resolves.toBeNull();
|
||
});
|
||
|
||
it('rejette une requête invalide sans jamais lancer yt-dlp', async () => {
|
||
const service = createService();
|
||
|
||
await expect(service.start(' ', 100, 5)).rejects.toBeInstanceOf(InvalidSearchQueryError);
|
||
expect(runs).toHaveLength(0);
|
||
});
|
||
|
||
it('ferme la recherche dès que sa dernière page a été servie', async () => {
|
||
const service = createService();
|
||
const started = service.start('speed climbing', 1000, 5);
|
||
runs[0]?.emitEntries(3);
|
||
runs[0]?.finish(); // fin des résultats : la première page est aussi la dernière
|
||
const { id, page } = await started;
|
||
|
||
expect(page.nextOffset).toBeNull();
|
||
await expect(service.page(id, 0, 5)).resolves.toBeNull();
|
||
});
|
||
|
||
it('dispose ferme la recherche (yt-dlp annulé) et n’existe plus ensuite', async () => {
|
||
const service = createService();
|
||
const { id, run } = await openSearch(service, 20, 5);
|
||
|
||
expect(service.dispose(id)).toBe(true);
|
||
|
||
expect(run.aborted).toBe(true);
|
||
expect(service.dispose(id)).toBe(false);
|
||
await expect(service.page(id, 5, 5)).resolves.toBeNull();
|
||
});
|
||
|
||
it('ferme la recherche et relance l’erreur quand la première page échoue', async () => {
|
||
const service = createService();
|
||
const started = service.start('speed climbing', 1000, 5);
|
||
runs[0]?.fail(new Error('boom'));
|
||
|
||
await expect(started).rejects.toThrow('boom');
|
||
expect(runs[0]?.aborted).toBe(true);
|
||
});
|
||
|
||
it('ferme la moins récemment utilisée quand la limite de recherches simultanées est atteinte', async () => {
|
||
const service = createService({ maxSessions: 2 });
|
||
const first = await openSearch(service, 20);
|
||
const second = await openSearch(service, 20);
|
||
|
||
// La première est la moins récemment utilisée : consulter la seconde le confirme.
|
||
await service.page(second.id, 5, 5);
|
||
const third = await openSearch(service, 20);
|
||
|
||
expect(first.run.aborted).toBe(true);
|
||
expect(second.run.aborted).toBe(false);
|
||
expect(third.run.aborted).toBe(false);
|
||
await expect(service.page(first.id, 5, 5)).resolves.toBeNull();
|
||
service.closeAll();
|
||
});
|
||
|
||
it('ferme les recherches inactives, et arrête son balayage quand il n’en reste plus', async () => {
|
||
vi.useFakeTimers();
|
||
const service = createService({ idleTtlMs: 1000, sweepIntervalMs: 500 });
|
||
const { run } = await openSearch(service, 20);
|
||
expect(vi.getTimerCount()).toBe(1); // le balayage tourne
|
||
|
||
await vi.advanceTimersByTimeAsync(2000);
|
||
|
||
expect(run.aborted).toBe(true);
|
||
expect(vi.getTimerCount()).toBe(0); // plus rien à balayer
|
||
});
|
||
|
||
it('ne ferme pas une recherche encore active', async () => {
|
||
vi.useFakeTimers();
|
||
const service = createService({ idleTtlMs: 1000, sweepIntervalMs: 500 });
|
||
const { id, run } = await openSearch(service, 40, 5);
|
||
|
||
for (let offset = 5; offset <= 25; offset += 5) {
|
||
await vi.advanceTimersByTimeAsync(800); // toujours sous le délai d'inactivité
|
||
await service.page(id, offset, 5);
|
||
}
|
||
|
||
expect(run.aborted).toBe(false);
|
||
service.closeAll();
|
||
});
|
||
|
||
it('ferme une recherche qui dépasse sa durée de vie maximale, même active', async () => {
|
||
vi.useFakeTimers();
|
||
const service = createService({ idleTtlMs: 10_000, maxLifetimeMs: 2000, sweepIntervalMs: 500 });
|
||
const { id, run } = await openSearch(service, 40, 5);
|
||
|
||
await vi.advanceTimersByTimeAsync(1000);
|
||
await service.page(id, 5, 5); // activité récente...
|
||
await vi.advanceTimersByTimeAsync(1500); // ...mais durée de vie dépassée
|
||
|
||
expect(run.aborted).toBe(true);
|
||
});
|
||
|
||
it('closeAll ferme toutes les recherches', async () => {
|
||
const service = createService();
|
||
const first = await openSearch(service, 20);
|
||
const second = await openSearch(service, 20);
|
||
|
||
service.closeAll();
|
||
|
||
expect(first.run.aborted).toBe(true);
|
||
expect(second.run.aborted).toBe(true);
|
||
});
|
||
});
|