// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Épreuves des seuils du banc (§ 19.10, § 19.4, § 6.2) : la dichotomie du // plus grand tenant sur des prédicats écrits, dont le seuil se lit à la main ; // le seuil d'une courbe de temps par image sur des courbes écrites ; la // tolérance de la chaîne témoin sur des longueurs écrites. Les prédicats // comptent leurs appels : une dichotomie qui balaierait tout l'intervalle se // verrait. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { CRITERE_TEMPS_IMAGE_MS, plusGrandTenant, seuilDepuisCourbe, toleranceTemoin } from './seuils.js'; // Un prédicat qui note chaque n reçu, dans l'ordre des appels. function compte(predicat) { const appels = []; const enveloppe = (n) => { appels.push(n); return predicat(n); }; return { enveloppe, appels }; } describe('le critère du temps par image', () => { test('33 ms, le choix de l’utilisateur, écrit ici et non au registre', () => { assert.equal(CRITERE_TEMPS_IMAGE_MS, 33); }); }); describe('plusGrandTenant : la dichotomie, la monotonie vérifiée', () => { test('un seuil connu, 17 sur [2, 200] : rendu tel quel, en treize appels au plus', async () => { const { enveloppe, appels } = compte((n) => n <= 17); assert.equal(await plusGrandTenant(enveloppe, 2, 200), 17); assert.ok(appels.length <= 13, `${appels.length} appels : ${appels.join(' ')}`); assert.deepEqual(appels.slice(-3), [16, 17, 18], 'le résultat et ses deux voisins sont relus'); }); test('un prédicat asynchrone vaut un prédicat synchrone', async () => { assert.equal(await plusGrandTenant(async (n) => n <= 41, 2, 200), 41); }); test('chaque seuil de [2, 40] se retrouve, bornes comprises', async () => { for (let seuil = 2; seuil <= 40; seuil += 1) { assert.equal(await plusGrandTenant((n) => n <= seuil, 2, 40), seuil, String(seuil)); } }); test('tenu partout : le maximum', async () => { assert.equal(await plusGrandTenant(() => true, 2, 200), 200); }); test('jamais vrai : null', async () => { assert.equal(await plusGrandTenant(() => false, 2, 200), null); }); test('un intervalle d’un seul entier : lui, ou null', async () => { assert.equal(await plusGrandTenant(() => true, 5, 5), 5); assert.equal(await plusGrandTenant(() => false, 5, 5), null); }); test('non monotone sous le résultat : un trou à 19 sous un seuil de 20 lève en nommant le voisin', async () => { const trou = (n) => n <= 20 && n !== 19; await assert.rejects(plusGrandTenant(trou, 1, 40), { name: 'RangeError', message: /non monotone.*19/ }); }); test('non monotone au-dessus du résultat : un prédicat qui change d’avis à la relecture lève', async () => { // Le premier appel en 21 répond « tient », le second « ne tient pas » : // la dichotomie le croit tenu, la relecture du voisin le dément. const vus = new Map(); const instable = (n) => { const fois = (vus.get(n) ?? 0) + 1; vus.set(n, fois); return n <= 20 || (n === 21 && fois === 1); }; await assert.rejects(plusGrandTenant(instable, 1, 40), { name: 'RangeError', message: /non monotone/ }); }); test('croissant plutôt que décroissant : faux au minimum, vrai au maximum, lève', async () => { await assert.rejects(plusGrandTenant((n) => n >= 10, 2, 200), { name: 'RangeError', message: /non monotone/ }); }); test('un prédicat qui rend autre chose qu’un booléen est refusé', async () => { await assert.rejects(plusGrandTenant(() => 1, 2, 10), TypeError); await assert.rejects(plusGrandTenant(async () => undefined, 2, 10), TypeError); }); test('des bornes qui ne sont pas des entiers, ou inversées, sont refusées avant tout appel', async () => { const { enveloppe, appels } = compte(() => true); for (const [min, max] of [[2.5, 10], [2, Number.POSITIVE_INFINITY], [10, 2], ['2', 10]]) { await assert.rejects(plusGrandTenant(enveloppe, min, max), RangeError, `${min}, ${max}`); } await assert.rejects(plusGrandTenant(null, 2, 10), TypeError); assert.deepEqual(appels, []); }); }); // Une courbe de temps par image : k en px/cm, p95 en ms. const courbe = (points) => points.map(([k, p95Ms]) => ({ k, p95Ms })); describe('seuilDepuisCourbe : le plus petit k au-delà duquel tout k de la courbe tient', () => { test('monotone : le premier k qui tient', () => { const c = courbe([[0.2, 48], [0.3, 40], [0.4, 33], [0.5, 25], [0.6, 20]]); assert.deepEqual(seuilDepuisCourbe(c, 33), { k: 0.4, tenuPartout: false, jamaisTenu: false }); }); test('le critère tient à égalité : un p95 de 33 ms tient 33 ms', () => { assert.deepEqual(seuilDepuisCourbe(courbe([[0.2, 33.0001], [0.3, 33]]), 33), { k: 0.3, tenuPartout: false, jamaisTenu: false }); }); test('non monotone : le seuil est au-delà du dernier échec, non au premier tenant', () => { const c = courbe([[0.2, 40], [0.3, 30], [0.4, 36], [0.5, 31], [0.6, 28]]); assert.deepEqual(seuilDepuisCourbe(c, 33), { k: 0.5, tenuPartout: false, jamaisTenu: false }); }); test('l’ordre de la courbe reçue est sans effet, et elle n’est pas modifiée', () => { const c = courbe([[0.6, 28], [0.3, 30], [0.5, 31], [0.2, 40], [0.4, 36]]); const copie = structuredClone(c); assert.deepEqual(seuilDepuisCourbe(c, 33), { k: 0.5, tenuPartout: false, jamaisTenu: false }); assert.deepEqual(c, copie); }); test('tout tenu : le plus petit k de la courbe, tenuPartout', () => { const c = courbe([[0.4, 20], [0.18, 30], [1, 12]]); assert.deepEqual(seuilDepuisCourbe(c, 33), { k: 0.18, tenuPartout: true, jamaisTenu: false }); }); test('rien tenu : k null, jamaisTenu', () => { assert.deepEqual(seuilDepuisCourbe(courbe([[0.2, 40], [0.5, 34]]), 33), { k: null, tenuPartout: false, jamaisTenu: true }); }); test('un échec au plus grand k : k null, sans être jamais tenu', () => { assert.deepEqual(seuilDepuisCourbe(courbe([[0.2, 20], [0.5, 34]]), 33), { k: null, tenuPartout: false, jamaisTenu: false }); }); test('une courbe vide est refusée : elle ne prouve rien', () => { assert.throws(() => seuilDepuisCourbe([], 33), RangeError); }); test('une courbe malformée, un k en double, un critère invalide sont refusés', () => { assert.throws(() => seuilDepuisCourbe(null, 33), TypeError); assert.throws(() => seuilDepuisCourbe(courbe([[0.2, Number.NaN]]), 33), RangeError); assert.throws(() => seuilDepuisCourbe(courbe([[-1, 20]]), 33), RangeError); assert.throws(() => seuilDepuisCourbe(courbe([[0.2, 20], [0.2, 40]]), 33), { name: 'RangeError', message: /0\.2/ }); assert.throws(() => seuilDepuisCourbe(courbe([[0.2, 20]]), 0), RangeError); assert.throws(() => seuilDepuisCourbe([{ k: 0.2 }], 33), RangeError); }); }); // Dix mesures identiques d'une longueur. const dix = (valeur) => Array.from({ length: 10 }, () => valeur); describe('toleranceTemoin : le quart de l’écart du repli, au moins trois fois la gigue', () => { test('sans gigue : le quart de l’écart relatif du repli, arrondi par excès à deux chiffres', () => { // Écart du repli : 40 / 1000 = 0,04 ; son quart, 0,01. const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: dix(1040) }); assert.deepEqual(t, { valeur: 0.01, ecartRepli: 0.04, gigue: 0, biais: 0, refus: null }); }); test('un repli plus court compte comme un plus long : l’écart est en valeur absolue', () => { const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: dix(950) }); assert.equal(t.ecartRepli, 0.05); assert.equal(t.valeur, 0.013); }); test('une gigue qui dépasse le quart de l’écart l’emporte : trois fois elle', () => { // Gigue : (1004 − 1000) / 1000 = 0,004 ; trois fois, 0,012 > 0,01. const embarqueePx = [1000, 1004, 1002, 1001, 1003, 1000, 1002, 1001, 1004, 1003]; const t = toleranceTemoin({ attenduePx: 1000, embarqueePx, repliPx: dix(1040) }); assert.equal(t.gigue, 0.004); assert.equal(t.valeur, 0.012); assert.equal(t.refus, null); }); test('la valeur arrondie ne passe jamais sous le plus grand des deux termes', () => { const t = toleranceTemoin({ attenduePx: 977, embarqueePx: dix(977), repliPx: dix(1021.3) }); assert.ok(t.valeur >= t.ecartRepli / 4, `${t.valeur} sous ${t.ecartRepli / 4}`); assert.equal(String(t.valeur).replace(/^0\.0*/, '').length <= 2, true, String(t.valeur)); }); test('un repli que la gigue couvre est refusé : la tolérance laisserait passer le repli', () => { const embarqueePx = [1000, 1010, 1000, 1010, 1000, 1010, 1000, 1010, 1000, 1010]; const t = toleranceTemoin({ attenduePx: 1000, embarqueePx, repliPx: dix(1020) }); assert.equal(t.valeur, null); assert.match(t.refus, /repli/); }); test('un repli rendu à la longueur de la police embarquée est refusé', () => { const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: dix(1000) }); assert.equal(t.valeur, null); assert.match(t.refus, /repli/); }); test('une police embarquée rendue loin de ses avances est refusée : ce n’est pas elle que l’on mesure', () => { const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1060), repliPx: dix(1200) }); assert.equal(t.biais, 0.06); assert.equal(t.valeur, null); assert.match(t.refus, /embarquée/); }); test('neuf mesures sont refusées, dix acceptées, pour chacune des deux polices', () => { const neuf = (valeur) => dix(valeur).slice(1); assert.throws(() => toleranceTemoin({ attenduePx: 1000, embarqueePx: neuf(1000), repliPx: dix(1040) }), { name: 'RangeError', message: /embarquée.*10/ }); assert.throws(() => toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: neuf(1040) }), { name: 'RangeError', message: /repli.*10/ }); assert.equal(toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: dix(1040) }).valeur, 0.01); }); test('le biais se lit sur la médiane de la police embarquée : ni son premier élément, ni son min, ni son max', () => { // Triées : 1000 1000 1001 1001 1001 | 1003 1003 1003 1003 1009 ; la // médiane paire vaut (1001 + 1003) / 2 = 1002, soit un biais de 0,002. // Le premier élément donnerait 0,001, le min 0, le max 0,009, le seul // élément de droite du milieu 0,003. const embarqueePx = [1001, 1009, 1000, 1003, 1001, 1003, 1000, 1003, 1001, 1003]; const t = toleranceTemoin({ attenduePx: 1000, embarqueePx, repliPx: dix(1040) }); assert.deepEqual(t, { valeur: 0.027, ecartRepli: 0.04, gigue: 0.009, biais: 0.002, refus: null }); }); test('l’écart du repli se lit sur sa médiane : une valeur aberrante ne le déplace pas', () => { // Triées : 1020 1030 1030 1030 1030 | 1050 1050 1050 1060 2000 ; médiane // (1030 + 1050) / 2 = 1040, soit 0,04. Le premier élément donnerait 0,03, // le min 0,02, le max 1, le seul élément de droite du milieu 0,05. const repliPx = [1030, 1050, 1020, 1050, 1030, 1060, 1030, 1050, 2000, 1030]; const t = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx }); assert.deepEqual(t, { valeur: 0.01, ecartRepli: 0.04, gigue: 0, biais: 0, refus: null }); }); test('une série de longueur impaire prend l’élément du milieu', () => { // Triées : 1020 1030 1030 1030 1030 1040 1050 1050 1050 1060 2000 ; la // médiane est 1040, et non (1030 + 1040) / 2. const repliPx = [1030, 1050, 1020, 1050, 1030, 1040, 1060, 1030, 1050, 2000, 1030]; assert.equal(toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx }).ecartRepli, 0.04); }); test('un biais égal à la tolérance est admis, un biais au-dessus refusé', () => { const egal = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1010), repliPx: dix(1040) }); assert.deepEqual(egal, { valeur: 0.01, ecartRepli: 0.04, gigue: 0, biais: 0.01, refus: null }); const dessus = toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1011), repliPx: dix(1040) }); assert.equal(dessus.valeur, null); assert.match(dessus.refus, /embarquée/); }); test('une tolérance égale à l’écart du repli est refusée, une tolérance juste en dessous admise', () => { // Gigue : 0,02 ; trois fois, 0,06, l'écart même du repli rendu à 1060. const alterne = Array.from({ length: 10 }, (_, i) => (i % 2 === 0 ? 1000 : 1020)); const egal = toleranceTemoin({ attenduePx: 1000, embarqueePx: alterne, repliPx: dix(1060) }); assert.equal(egal.ecartRepli, 0.06); assert.equal(egal.valeur, null); assert.match(egal.refus, /repli/); const dessous = toleranceTemoin({ attenduePx: 1000, embarqueePx: alterne, repliPx: dix(1061) }); assert.deepEqual(dessous, { valeur: 0.06, ecartRepli: 0.061, gigue: 0.02, biais: 0.01, refus: null }); }); test('moins de dix mesures, une longueur non finie ou nulle sont refusées', () => { assert.throws(() => toleranceTemoin({ attenduePx: 1000, embarqueePx: [1000, 1000], repliPx: dix(1040) }), RangeError); assert.throws(() => toleranceTemoin({ attenduePx: 0, embarqueePx: dix(1000), repliPx: dix(1040) }), RangeError); assert.throws(() => toleranceTemoin({ attenduePx: 1000, embarqueePx: dix(1000), repliPx: [...dix(1040).slice(1), Number.NaN] }), RangeError); }); });