/** * Validateur de forme d'une `Suggestion` — source unique. * * Partagé par `provider-contract.test.mjs` (invariants sur objets synthétiques) * et `provider_shapes.test.mjs` (fixtures gelées issues des 6 adaptateurs * réels). Deux définitions du contrat finiraient par diverger, et c'est * précisément la divergence qu'on veut détecter. * * Règle centrale (phase 2) : une métadonnée absente est `undefined`, jamais `0`. * Un `0` inventé s'affiche dans l'UI comme une donnée réelle. */ /** Champs textuels : chaîne non vide, ou absents. */ const STRING_FIELDS = [ 'url', 'thumbnail', 'uploaderName', 'uploaderAvatar', 'channelId', 'slug', 'channelUrl', 'language', 'channelExternalId', 'channelHandle', 'channel', 'kind', 'game', 'durationRaw', ]; /** * Champs numériques où 0 est impossible : un 0 y est un bug ou un mensonge. * `duration: 0` rendait notamment la détection de Short inopérante * (« verticale sans durée connue » ne doit pas être classée short). */ const STRICTLY_POSITIVE_FIELDS = ['duration', 'width', 'height']; /** * Champs de comptage où 0 est une VRAIE information : l'API peut legitimately * répondre « 0 like » (PeerTube le fait). Traduire ce 0 en `undefined` * confondrait « personne n'a aimé » avec « l'API n'a rien dit » — on perdrait * l'information au lieu de la protéger. * * L'invariant protégé reste : ABSENT reste absent. Ce qui est interdit, c'est * d'inventer un 0, pas de rencontrer un 0. */ const COUNT_FIELDS = ['views', 'likes', 'dislikes', 'viewers', 'viewCountRaw']; const BOOLEAN_FIELDS = ['isShort', 'isLive', 'hasSubtitles', 'embeddable']; /** * Schemes d'identité de chaîne connus (phase 6). * * `yt-uc` et non `yt-channel` : c'est le scheme réellement émis par * l'adaptateur YouTube (canal = identifiant `UC…`). Le nommer autrement * donnerait l'illusion que le front sait le lire. */ const CHANNEL_REF_SCHEMES = new Set(['yt-uc', 'pt-composite', 'od-claim', 'tw-login', 'ru-slug', 'dm-user']); /** * Valide un objet `Suggestion`. * @param {any} s * @param {string} label contexte pour le message d'erreur * @param {(msg: string) => void} expect * @returns {string[]} messages d'erreur ([] si conforme) */ export function suggestionShapeErrors(s, label, expect) { expect(typeof s?.title === 'string', `${label}: title is a string`); expect(typeof s?.id === 'string' && s.id.length > 0, `${label}: id is a non-empty string`); for (const f of STRICTLY_POSITIVE_FIELDS) { if (s?.[f] !== undefined) { expect(typeof s[f] === 'number' && Number.isFinite(s[f]), `${label}: ${f} is a finite number`); expect(s[f] > 0, `${label}: ${f} is strictly > 0 (never 0)`); } } for (const f of COUNT_FIELDS) { if (s?.[f] !== undefined) { expect(typeof s[f] === 'number' && Number.isFinite(s[f]), `${label}: ${f} is a finite number`); expect(s[f] >= 0, `${label}: ${f} is >= 0 (an explicit 0 from the upstream is real data)`); } } if (s?.publishedAt !== undefined) { expect(typeof s.publishedAt === 'string', `${label}: publishedAt is a string`); const t = Date.parse(s.publishedAt); expect(Number.isFinite(t), `${label}: publishedAt is parseable as a date`); // Piège historique : « 2 months ago » n'est pas une date. expect(!/ago|il y a/i.test(s.publishedAt), `${label}: publishedAt is not a relative label`); expect(t > 0 && t < 8.64e15, `${label}: publishedAt is in a sane range`); } for (const f of STRING_FIELDS) { if (s?.[f] !== undefined) { expect(typeof s[f] === 'string' && s[f].length > 0, `${label}: ${f} is a non-empty string when present`); } } if (s?.tags !== undefined) { expect(Array.isArray(s.tags), `${label}: tags is an array when present`); } for (const f of BOOLEAN_FIELDS) { if (s?.[f] !== undefined) expect(typeof s[f] === 'boolean', `${label}: ${f} is a boolean when present`); } // Phase 6 : `channelRef` est additif, mais s'il est présent il doit être // exploitable tel quel par le front. Forme réelle = objet `{ provider, // scheme, value }` (et non une chaîne) : c'est ce que les 6 adaptateurs // émettent, vérifié sur fixtures gelées. if (s?.channelRef !== undefined) { const ref = s.channelRef; expect(ref && typeof ref === 'object' && !Array.isArray(ref), `${label}: channelRef est un objet`); expect(typeof ref.provider === 'string' && ref.provider.length > 0, `${label}: channelRef.provider est une chaîne non vide`); expect(typeof ref.scheme === 'string' && ref.scheme.length > 0, `${label}: channelRef.scheme est une chaîne non vide`); expect(typeof ref.value === 'string' && ref.value.length > 0, `${label}: channelRef.value est une chaîne non vide`); expect(CHANNEL_REF_SCHEMES.has(ref.scheme), `${label}: channelRef.scheme inconnu « ${ref.scheme} »`); expect(!/\s/.test(ref.value), `${label}: channelRef.value sans espace`); } return []; } /** Schémas connus, exportés pour la génération de doc (phase 8.5). */ export { CHANNEL_REF_SCHEMES, STRING_FIELDS, STRICTLY_POSITIVE_FIELDS, COUNT_FIELDS, BOOLEAN_FIELDS };