// 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})`);