CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
166 lines
9.8 KiB
JavaScript
166 lines
9.8 KiB
JavaScript
// Phase 0 §2.6 — Contrat `Suggestion` v2.
|
|
//
|
|
// Run with: npm run test:contract
|
|
//
|
|
// Pourquoi ce test existe : avant, les champs `views` / `publishedAt` / `channelId`
|
|
// / `uploaderAvatar` étaient bien récupérés par certains providers, puis perdus
|
|
// silencieusement en route (anomalies #3, #7). Rien ne le détectait : le code
|
|
// « fonctionnait », la grille restait vide. Ce test fige les invariants.
|
|
//
|
|
// 100% offline : aucun appel réseau. On teste (a) le contrat déclaré, (b) les
|
|
// parseurs HTML/JSON en local, (c) la parité serveur <-> front.
|
|
|
|
import {
|
|
SUGGESTION_CONTRACT_VERSION,
|
|
SUGGESTION_V2_FIELDS,
|
|
providerRegistry,
|
|
validateProviders,
|
|
} from '../providers/registry.mjs';
|
|
import { parseRelativeDate } from '../providers/youtube-innertube.mjs';
|
|
import { isShortItem, isLiveItem, itemDurationSec, itemPublishedTs } from '../search-filters.mjs';
|
|
import { suggestionShapeErrors } from './lib/suggestion-shape.mjs';
|
|
|
|
let passed = 0;
|
|
function expect(cond, msg) { if (!cond) throw new Error(`Assertion failed: ${msg}`); }
|
|
function eq(actual, expected, msg) {
|
|
const a = JSON.stringify(actual); const b = JSON.stringify(expected);
|
|
if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`);
|
|
}
|
|
function ok(msg) { passed++; console.log(` V ${msg}`); }
|
|
|
|
// ---------- 0.1 Le contrat est versionné ----------
|
|
eq(SUGGESTION_CONTRACT_VERSION, 2, 'contract version is 2');
|
|
ok('contract is versioned (v=2)');
|
|
|
|
expect(Array.isArray(SUGGESTION_V2_FIELDS) && SUGGESTION_V2_FIELDS.length > 0, 'SUGGESTION_V2_FIELDS is a non-empty list');
|
|
for (const f of SUGGESTION_V2_FIELDS) expect(typeof f === 'string' && f.length > 0, `field "${f}" is a non-empty string`);
|
|
ok(`v2 declares ${SUGGESTION_V2_FIELDS.length} optional fields`);
|
|
|
|
// ---------- 0.2 Les 6 providers sont bien enregistrés ----------
|
|
for (const id of ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) {
|
|
expect(providerRegistry[id], `provider "${id}" is registered`);
|
|
expect(typeof providerRegistry[id].search === 'function', `provider "${id}" exposes search()`);
|
|
expect(typeof providerRegistry[id].label === 'string' && providerRegistry[id].label.length > 0, `provider "${id}" has a label`);
|
|
}
|
|
ok('all 6 providers registered with search() + label');
|
|
|
|
eq(validateProviders('').sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt'], 'empty providers -> all 6');
|
|
eq(validateProviders('yt,bogus,YT').sort(), ['yt'], 'unknown + duplicates + case are normalized');
|
|
ok('validateProviders normalizes unknown/duplicate/case');
|
|
|
|
// ---------- 0.3-0.6 Invariants sur des Suggestions synthétiques ----------
|
|
// Ce sont les règles que TOUT provider doit respecter. On les applique à un objet
|
|
// « bien formé » puis à ses variantes dégradées, comme le ferait la grille.
|
|
|
|
// Validateur de contrat — source unique dans lib/suggestion-shape.mjs, partagee
|
|
// avec provider_shapes.test.mjs (phase 8.4). Deux copies du contrat finitissent par
|
|
// diverger, et c'est precisement la divergence qu'on cherche a detecter.
|
|
const assertSuggestion = (s, label) => suggestionShapeErrors(s, label, expect) && true;
|
|
|
|
const good = {
|
|
title: 'Test', id: 'abc', url: 'https://example.com/v/abc', thumbnail: 'https://example.com/t.jpg',
|
|
uploaderName: 'Chaîne', uploaderAvatar: 'https://example.com/a.jpg', duration: 212,
|
|
views: 1234, likes: 56, publishedAt: '2026-01-17T10:30:00.000Z', type: 'video',
|
|
width: 1080, height: 1920, channelId: 'UC123', channelExternalId: 'UC123', language: 'fr',
|
|
tags: ['a', 'b'], isLive: false, hasSubtitles: true, embeddable: true,
|
|
};
|
|
assertSuggestion(good, 'well-formed');
|
|
ok('well-formed Suggestion passes the contract validator');
|
|
|
|
// Champs absents => strictement absents (pas de 0, pas de '')
|
|
const minimal = { title: 'Test', id: 'abc' };
|
|
assertSuggestion(minimal, 'minimal');
|
|
eq(Object.keys(minimal), ['title', 'id'], 'a minimal Suggestion carries no nullish junk keys');
|
|
ok('minimal Suggestion stays minimal (no invented zeroes)');
|
|
|
|
// `duration: 0` est le bug historique : il rendait la règle
|
|
// « verticale sans durée connue => pas un short » inopérante.
|
|
const zeroDuration = { title: 'T', id: 'i', duration: 0 };
|
|
try { assertSuggestion(zeroDuration, 'zero-duration'); throw new Error('Assertion failed: duration:0 should have been rejected'); }
|
|
catch (e) { expect(String(e.message).includes('duration is strictly > 0'), 'duration:0 is rejected with a clear message'); }
|
|
ok('duration:0 is rejected (the phase 1/2 bug class)');
|
|
|
|
// Distinction affinée en phase 8.4, en gelant la sortie réelle des providers :
|
|
// un COMPTEUR à 0 est une donnée vraie (« 0 like »), pas un mensonge. PeerTube
|
|
// renvoie `likes: 0` legitimately ; le rejeter en `undefined` ferait perdre
|
|
// l'information. Ce qui reste interdit, c'est d'inventer un 0 : l'ABSENT doit
|
|
// rester absent.
|
|
assertSuggestion({ title: 'T', id: 'i', views: 0, likes: 0, dislikes: 0, viewers: 0 }, 'zero-counts');
|
|
ok('an explicit 0 on a counter is accepted (real data, not an invented zero)');
|
|
const zeroDims = { title: 'T', id: 'i', width: 0, height: 0 };
|
|
try { assertSuggestion(zeroDims, 'zero-dims'); throw new Error('Assertion failed: width:0 should have been rejected'); }
|
|
catch (e) { expect(String(e.message).includes('is strictly > 0'), 'width:0 is rejected'); }
|
|
ok('0 on width/height stays rejected (a 0-sized video does not exist)');
|
|
|
|
// ---------- 0.7-0.8 Classification : parité des règles ----------
|
|
// Une verticale sans durée connue ne doit PAS être classée short.
|
|
eq(isShortItem({ type: 'video', width: 1080, height: 1920, duration: undefined }), false,
|
|
'portrait WITHOUT duration is not a short (absent duration is not evidence)');
|
|
eq(isShortItem({ type: 'video', width: 1080, height: 1920, duration: 30 }), true,
|
|
'portrait + 30 s is a short');
|
|
eq(isShortItem({ type: 'video', width: 1920, height: 1080, duration: 30 }), false,
|
|
'landscape + 30 s is NOT a short (duration alone is not enough)');
|
|
eq(isShortItem({ type: 'video', isShort: true }), true, 'explicit provider isShort wins');
|
|
ok('isShortItem: no duration => no short, and portrait is required');
|
|
|
|
// Règles (a)/(b)/(c) de la tâche 2.3 — ces trois cas doivent être identiques
|
|
// côté serveur ET côté front (`isShortVideo`), sinon la pastille SHORT apparaît
|
|
// dans la grille puis disparaît au changement d'onglet.
|
|
eq(isShortItem({ isShort: true }), true, '(a) native flag + unknown duration => short');
|
|
eq(isShortItem({ isShort: true, width: 1080, height: 1080, duration: 60 }), true,
|
|
'(b) square 60 s + native flag => short (flag primes over orientation)');
|
|
eq(isShortItem({ width: 1080, height: 1080, duration: 60 }), false,
|
|
'(b) square 60 s WITHOUT a flag => not a short (duration alone never qualifies)');
|
|
eq(isShortItem({ kind: 'clip', type: 'video', duration: 42 }), true,
|
|
'(c) kind:clip beats type:video');
|
|
eq(isShortItem({ url: 'https://www.youtube.com/shorts/abcdefghijk', duration: 30 }), true,
|
|
'/shorts/ URL marker is honoured (parity with the front)');
|
|
ok('rules (a)/(b)/(c) hold, including the /shorts/ URL marker');
|
|
|
|
// itemDurationSec doit distinguer 0 de undefined.
|
|
eq(itemDurationSec({ duration: 0 }), undefined, 'itemDurationSec maps 0 -> undefined');
|
|
eq(itemDurationSec({ duration: 30 }), 30, 'itemDurationSec keeps a real duration');
|
|
eq(itemDurationSec({ durationSec: 42 }), 42, 'itemDurationSec reads durationSec too');
|
|
ok('itemDurationSec never leaks a 0 duration');
|
|
|
|
// Une date relative ne doit jamais arriver jusqu'ici (bug InnerTube 1.9).
|
|
eq(itemPublishedTs({ publishedAt: '2 months ago' }), 0, 'relative label yields no timestamp');
|
|
eq(itemPublishedTs({ publishedAt: '2026-01-17T10:30:00.000Z' }), Date.parse('2026-01-17T10:30:00.000Z'),
|
|
'ISO date yields a timestamp');
|
|
ok('itemPublishedTs rejects relative labels (InnerTube regression guard)');
|
|
|
|
eq(isLiveItem({ type: 'live' }), true, 'type=live is live');
|
|
eq(isLiveItem({ type: 'video', isLive: true }), true, 'isLive flag is honoured');
|
|
eq(isLiveItem({ type: 'vod', kind: 'vod' }), false, 'a Twitch VOD is not live');
|
|
ok('isLiveItem honours both type and the isLive flag');
|
|
|
|
// ---------- 0.9 parseRelativeDate (tâche 1.9) ----------
|
|
const NOW = Date.parse('2026-03-01T12:00:00.000Z');
|
|
const day = 864e5;
|
|
eq(parseRelativeDate('il y a 2 heures', NOW), new Date(NOW - 2 * 36e5).toISOString(), 'FR relative hours');
|
|
eq(parseRelativeDate('il y a 3 jours', NOW), new Date(NOW - 3 * day).toISOString(), 'FR relative days');
|
|
eq(parseRelativeDate('il y a 2 semaines', NOW), new Date(NOW - 2 * 6048e5).toISOString(), 'FR relative weeks');
|
|
eq(parseRelativeDate('2 months ago', NOW), new Date(NOW - 2 * 26298e6).toISOString(), 'EN relative months');
|
|
eq(parseRelativeDate('Streamed 3 weeks ago', NOW), new Date(NOW - 3 * 6048e5).toISOString(), 'EN streamed weeks');
|
|
eq(parseRelativeDate('1 an', NOW), new Date(NOW - 315576e5).toISOString(), 'FR relative years');
|
|
eq(parseRelativeDate('hier', NOW), new Date(NOW - day).toISOString(), 'FR yesterday');
|
|
eq(parseRelativeDate('yesterday', NOW), new Date(NOW - day).toISOString(), 'EN yesterday');
|
|
eq(parseRelativeDate("aujourd'hui", NOW), new Date(NOW).toISOString(), 'FR today');
|
|
eq(parseRelativeDate('today', NOW), new Date(NOW).toISOString(), 'EN today');
|
|
eq(parseRelativeDate('2026-01-17T10:30:00Z', NOW), '2026-01-17T10:30:00.000Z', 'absolute ISO passthrough');
|
|
eq(parseRelativeDate('', NOW), undefined, 'empty string -> undefined');
|
|
eq(parseRelativeDate(null, NOW), undefined, 'null -> undefined');
|
|
eq(parseRelativeDate('Streaming', NOW), undefined, 'unparseable label -> undefined (no invented date)');
|
|
ok(`parseRelativeDate handles ${13} FR/EN cases and never invents a date`);
|
|
|
|
// ---------- 0.10 Cohérence provider <-> contrat ----------
|
|
// `id` court attendu par groupe, et un `search` qui dégrade proprement.
|
|
for (const id of ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) {
|
|
const mod = providerRegistry[id];
|
|
expect(mod.id === id, `provider "${id}" declares id === "${id}"`);
|
|
}
|
|
ok('each adapter self-declares its own id');
|
|
|
|
// Résumé
|
|
console.log(`\n provider-contract: ${passed} assertions OK (contract v${SUGGESTION_CONTRACT_VERSION})`);
|