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.
106 lines
5.0 KiB
JavaScript
106 lines
5.0 KiB
JavaScript
/**
|
|
* 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 };
|