diff --git a/src/geometrie/constantes.js b/src/geometrie/constantes.js new file mode 100644 index 0000000..7875d16 --- /dev/null +++ b/src/geometrie/constantes.js @@ -0,0 +1,158 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le registre des constantes mesurées (§ 14.1, § 19.10). Toute dimension qui +// dépend du rendu ou du geste se relève, jamais ne se déduit : chaque entrée +// dit ce qu'elle mesure, pourquoi elle existe, sur quelle configuration elle +// se relève, qui la relève et comment. Tant que son propriétaire ne l'a pas +// relevée, une constante porte sa valeur de départ et se déclare défaut de +// travail ; il écrit ici la valeur relevée, passe le statut à « mesuree » et +// complète la configuration. Le banc de mesure lit ce tableau pour en +// engendrer la région de la documentation (§ 19.10). + +/** + * @typedef {Object} Constante + * @property {string} nom + * @property {number|null} valeur null pour une constante relevée à l'exécution + * @property {string} unite + * @property {string} mesure ce qu'elle mesure + * @property {string} raison pourquoi elle existe + * @property {string} configuration sur quoi elle se relève : machine, cadre, configuration + * @property {'banc'|'node'|'manuel'|'execution'} proprietaire + * @property {'mesuree'|'defautDeTravail'} statut + * @property {string} protocole comment le propriétaire la relève, et où il l'écrit + */ + +const PROPRIETAIRES = new Set(['banc', 'node', 'manuel', 'execution']); +const STATUTS = new Set(['mesuree', 'defautDeTravail']); + +/** @type {ReadonlyArray>} */ +export const CONSTANTES = Object.freeze( + [ + { + nom: 'SEUIL_GLISSER_PX', + valeur: 4, + unite: 'px', + mesure: "déplacement du pointeur, entre pointerdown et pointerup, au-delà duquel un appui devient un glissement", + raison: "un clic délibéré bouge de quelques pixels : sous ce seuil, il resterait un clic et ne déplacerait rien (§ 8.4)", + configuration: 'pavé tactile de la machine de référence', + proprietaire: 'manuel', + statut: 'defautDeTravail', + protocole: + 'vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre ' + + 'pointerdown et pointerup, × 1,5, écrit dans ce registre', + }, + { + nom: 'DUREE_PRESSION_LONGUE_MS', + valeur: 500, + unite: 'ms', + mesure: "durée d'un appui immobile au-delà de laquelle il ouvre le menu d'une personne", + raison: "un appui bref ne doit jamais ouvrir le menu, ni une pression voulue se faire attendre (§ 7.3)", + configuration: 'écran tactile de la machine de référence', + proprietaire: 'manuel', + statut: 'defautDeTravail', + protocole: + "à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, " + + 'sur dix essais, écrite dans ce registre', + }, + { + nom: 'SEUIL_SIEGES_LISIBLES', + valeur: 16, + unite: 'places', + mesure: "plus grand nombre de places d'une table dont la liste de noms reste lisible", + raison: "au-delà, le dessin d'une table cesse d'être lisible, et l'application le signale (§ 6.2)", + configuration: 'banc de mesure, cadre de 1 366 × 700 px', + proprietaire: 'banc', + statut: 'defautDeTravail', + protocole: + 'banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur ' + + "de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre", + }, + { + nom: 'K_LISTES', + valeur: 0.3, + unite: 'px/cm', + mesure: 'échelle sous laquelle le plan retire les listes de noms', + raison: "sous elle, dessiner les listes coûte plus d'une image par déplacement de vue (§ 17, point 6)", + configuration: 'banc de mesure, grande démonstration, 33 tables avec listes', + proprietaire: 'banc', + statut: 'defautDeTravail', + protocole: + 'banc de mesure : temps par image pendant un déplacement de vue sur la grande démonstration, 33 tables ' + + 'avec listes, à plusieurs k ; le plus petit k qui tient le critère de temps par image, écrit dans ce registre', + }, + { + nom: 'LARGEUR_BLOC_NOMS_CM', + valeur: 180, + unite: 'cm', + mesure: "largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège", + raison: 'un nom tient sur une ligne du plan sans recouvrir la table voisine (§ 7.4)', + configuration: 'grande démonstration au zoom par défaut', + proprietaire: 'manuel', + statut: 'defautDeTravail', + protocole: + "relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le " + + 'logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre', + }, + { + nom: 'HAUTEUR_LIGNE', + valeur: null, + unite: 'cm', + mesure: "hauteur d'une ligne d'une liste de noms, dans le dessin", + raison: 'deux lignes ne se chevauchent pas, quels que soient les accents et les jambages (§ 7.4)', + configuration: 'police calculée des listes, relevée à chaque montage du plan', + proprietaire: 'execution', + statut: 'mesuree', + protocole: + 'mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des ' + + "listes ; aucune valeur n'est écrite dans ce registre", + }, + ].map((entree) => Object.freeze(entree)), +); + +// Champs textuels d'une entrée, qui ne peuvent être vides. +const CHAMPS_TEXTE = ['unite', 'mesure', 'raison', 'configuration']; + +const estTexte = (valeur) => typeof valeur === 'string' && valeur.length > 0; + +/** + * La valeur de la constante nommée ; null pour une constante relevée à + * l'exécution. Lève RangeError sur un nom absent du registre. + * @param {string} nom + * @returns {number|null} + */ +export function constante(nom) { + const entree = CONSTANTES.find((c) => c.nom === nom); + if (entree === undefined) throw new RangeError(`constante inconnue : ${String(nom)}`); + return entree.valeur; +} + +/** + * Les fautes d'un registre, [] quand chaque entrée est complète : une par + * champ fautif, dans l'ordre des entrées, puis dans l'ordre des champs du + * type Constante. Un nom vide, ou déjà porté par une entrée précédente, est + * une faute ; la valeur est un nombre fini, et null pour une constante + * relevée à l'exécution seulement ; les champs textuels ne sont pas vides ; + * propriétaire et statut prennent une valeur de leur liste. + * @param {ReadonlyArray} liste + * @returns {Array<{nom: string, champ: string}>} + */ +export function verifierRegistre(liste) { + const vus = new Set(); + const fautes = []; + for (const entree of liste) { + const nom = entree.nom; + const faute = (champ) => fautes.push({ nom, champ }); + if (!estTexte(nom) || vus.has(nom)) faute('nom'); + vus.add(nom); + const execution = entree.proprietaire === 'execution'; + if (execution ? entree.valeur !== null : !Number.isFinite(entree.valeur)) faute('valeur'); + for (const champ of CHAMPS_TEXTE) { + if (!estTexte(entree[champ])) faute(champ); + } + if (!PROPRIETAIRES.has(entree.proprietaire)) faute('proprietaire'); + if (!STATUTS.has(entree.statut)) faute('statut'); + if (!estTexte(entree.protocole)) faute('protocole'); + } + return fautes; +} diff --git a/src/geometrie/constantes.test.js b/src/geometrie/constantes.test.js new file mode 100644 index 0000000..add9c51 --- /dev/null +++ b/src/geometrie/constantes.test.js @@ -0,0 +1,96 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du registre des constantes mesurées (§ 14.1, § 19.10) : les six +// entrées du contrat de données, chacune complète, et la vérification qui +// nomme l'entrée et le champ d'une faute. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { CONSTANTES, constante, verifierRegistre } from './constantes.js'; + +// Le contrat de données : nom, valeur de départ, unité, propriétaire. +const CONTRAT = [ + ['SEUIL_GLISSER_PX', 4, 'px', 'manuel'], + ['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel'], + ['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc'], + ['K_LISTES', 0.3, 'px/cm', 'banc'], + ['LARGEUR_BLOC_NOMS_CM', 180, 'cm', 'manuel'], + ['HAUTEUR_LIGNE', null, 'cm', 'execution'], +]; + +// Une copie du registre, entrée par entrée, que l'épreuve peut altérer. +const copie = () => CONSTANTES.map((entree) => ({ ...entree })); + +describe('constantes : le registre (§ 14.1, § 19.10)', () => { + test('le registre porte les six entrées du contrat, dans son ordre, chacune complète', () => { + assert.equal(CONSTANTES.length, 6); + assert.deepEqual( + CONSTANTES.map(({ nom, valeur, unite, proprietaire }) => [nom, valeur, unite, proprietaire]), + CONTRAT, + ); + assert.deepEqual(verifierRegistre(CONSTANTES), []); + for (const entree of CONSTANTES) { + assert.ok(entree.protocole.length > 0 && entree.mesure.length > 0 && entree.raison.length > 0, entree.nom); + } + }); + + test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail', () => { + const statuts = CONSTANTES.map(({ nom, statut }) => [nom, statut]); + assert.deepEqual(statuts.slice(0, 5), CONTRAT.slice(0, 5).map(([nom]) => [nom, 'defautDeTravail'])); + }); + + test('constante rend la valeur par le nom ; un nom inconnu lève', () => { + assert.equal(constante('SEUIL_GLISSER_PX'), 4); + assert.equal(constante('K_LISTES'), 0.3); + assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 180); + assert.equal(constante('HAUTEUR_LIGNE'), null); + assert.throws(() => constante('INCONNUE'), RangeError); + assert.throws(() => constante('nom'), RangeError); + }); + + test('le registre et ses entrées sont figés', () => { + assert.ok(Object.isFrozen(CONSTANTES)); + assert.ok(CONSTANTES.every((entree) => Object.isFrozen(entree))); + }); + + test("verifierRegistre nomme l'entrée et le champ d'une faute : propriétaire, configuration ou statut perdu", () => { + for (const champ of ['proprietaire', 'configuration', 'statut']) { + const liste = copie(); + delete liste[2][champ]; + assert.deepEqual(verifierRegistre(liste), [{ nom: 'SEUIL_SIEGES_LISIBLES', champ }], champ); + } + const vide = copie(); + vide[0].configuration = ''; + vide[4].proprietaire = 'personne'; + vide[1].statut = 'mesure'; + assert.deepEqual(verifierRegistre(vide), [ + { nom: 'SEUIL_GLISSER_PX', champ: 'configuration' }, + { nom: 'DUREE_PRESSION_LONGUE_MS', champ: 'statut' }, + { nom: 'LARGEUR_BLOC_NOMS_CM', champ: 'proprietaire' }, + ]); + }); + + test("verifierRegistre relève aussi une valeur absente hors de l'exécution, un texte vide, un nom répété", () => { + const liste = copie(); + liste[0].valeur = null; + liste[1].valeur = Number.NaN; + liste[2].protocole = ''; + liste[3].unite = undefined; + liste[4].mesure = 3; + liste[5].raison = ''; + liste.push({ ...liste[3], unite: 'px/cm' }); + assert.deepEqual(verifierRegistre(liste), [ + { nom: 'SEUIL_GLISSER_PX', champ: 'valeur' }, + { nom: 'DUREE_PRESSION_LONGUE_MS', champ: 'valeur' }, + { nom: 'SEUIL_SIEGES_LISIBLES', champ: 'protocole' }, + { nom: 'K_LISTES', champ: 'unite' }, + { nom: 'LARGEUR_BLOC_NOMS_CM', champ: 'mesure' }, + { nom: 'HAUTEUR_LIGNE', champ: 'raison' }, + { nom: 'K_LISTES', champ: 'nom' }, + ]); + assert.deepEqual(verifierRegistre([{ ...liste[0], nom: '', valeur: 4 }]), [{ nom: '', champ: 'nom' }]); + // Une constante relevée à l'exécution n'a pas de valeur dans le registre : + // un nombre y serait le relevé d'une autre machine. + assert.deepEqual(verifierRegistre([{ ...CONSTANTES[5], valeur: 21.5 }]), [{ nom: 'HAUTEUR_LIGNE', champ: 'valeur' }]); + }); +}); diff --git a/src/geometrie/echelle.js b/src/geometrie/echelle.js new file mode 100644 index 0000000..b7a93c7 --- /dev/null +++ b/src/geometrie/echelle.js @@ -0,0 +1,40 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'échelle graphique du plan (§ 7.7) : une barre d'une longueur ronde, que le +// lecteur reporte sur le dessin. Une barre reste juste quand une impression +// change le facteur d'échelle, là où un rapport écrit en chiffres deviendrait +// faux. + +// Mantisses des longueurs rondes, de la plus grande à la plus petite. +const MANTISSES = [5, 2, 1]; + +// m × 10ⁿ, au plus près : une puissance négative divise par 10⁻ⁿ, que +// l'arithmétique flottante représente exactement, plutôt que de multiplier +// par 10ⁿ, qu'elle arrondit. +const ronde = (m, n) => (n >= 0 ? m * 10 ** n : m / 10 ** -n); + +/** + * La plus grande longueur ronde — 1, 2 ou 5 × 10ⁿ cm, n entier de signe + * quelconque — dont la barre tient dans largeurMaxPx à l'échelle k, et la + * largeur de cette barre, longueurCm × k. La recherche part de la décade + * au-dessus de largeurMaxPx / k et descend, mantisse par mantisse, jusqu'à la + * première longueur qui tient : l'arrondi du logarithme ne fait rien manquer. + * Lève RangeError quand k ou largeurMaxPx n'est pas un nombre fini > 0. + * @param {number} k px/cm + * @param {number} largeurMaxPx + * @returns {{longueurCm: number, largeurPx: number}} + */ +export function echelleGraphique(k, largeurMaxPx) { + for (const [nom, valeur] of [['k', k], ['largeur', largeurMaxPx]]) { + if (typeof valeur !== 'number' || !Number.isFinite(valeur) || valeur <= 0) { + throw new RangeError(`${nom} : nombre fini > 0 attendu, reçu ${String(valeur)}`); + } + } + for (let n = Math.floor(Math.log10(largeurMaxPx / k)) + 1; ; n -= 1) { + for (const m of MANTISSES) { + const longueurCm = ronde(m, n); + if (longueurCm * k <= largeurMaxPx) return { longueurCm, largeurPx: longueurCm * k }; + } + } +} diff --git a/src/geometrie/echelle.test.js b/src/geometrie/echelle.test.js new file mode 100644 index 0000000..f0f269d --- /dev/null +++ b/src/geometrie/echelle.test.js @@ -0,0 +1,55 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de l'échelle graphique (§ 7.7) : la plus grande longueur ronde, 1, +// 2 ou 5 × 10ⁿ cm, dont la barre tient dans la largeur donnée à l'échelle k. +// Les cas chiffrés sont écrits en clair ; le balayage de k vérifie chaque +// résultat par la définition, non par une seconde implémentation. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { echelleGraphique } from './echelle.js'; + +// Vrai quand longueur vaut 1, 2 ou 5 × 10ⁿ, n entier de signe quelconque, +// à l'arrondi des nombres flottants près. +function estRonde(longueur) { + const puissance = 10 ** Math.floor(Math.log10(longueur) + 1e-12); + const mantisse = longueur / puissance; + return [1, 2, 5].some((m) => Math.abs(mantisse - m) < 1e-9); +} + +// La valeur ronde qui suit longueur : 1 → 2, 2 → 5, 5 → 10. +function rondeSuivante(longueur) { + const puissance = 10 ** Math.floor(Math.log10(longueur) + 1e-12); + const mantisse = Math.round(longueur / puissance); + return (mantisse === 1 ? 2 : mantisse === 2 ? 5 : 10) * puissance; +} + +describe("échelle : l'échelle graphique (§ 7.7)", () => { + test('k = 1, 0,19 et 10 dans 200 px', () => { + assert.deepEqual(echelleGraphique(1, 200), { longueurCm: 200, largeurPx: 200 }); + const etroite = echelleGraphique(0.19, 200); + assert.equal(etroite.longueurCm, 1000); + assert.ok(Math.abs(etroite.largeurPx - 190) < 1e-9, `${etroite.largeurPx}`); + assert.deepEqual(echelleGraphique(10, 200), { longueurCm: 20, largeurPx: 200 }); + assert.deepEqual(echelleGraphique(10, 5), { longueurCm: 0.5, largeurPx: 5 }); + }); + + test('pour chaque k de 0,05 à 10, raison 1,07 : longueur ronde, barre dans 200 px, la ronde suivante dépasserait', () => { + let comptes = 0; + for (let k = 0.05; k <= 10; k *= 1.07) { + const { longueurCm, largeurPx } = echelleGraphique(k, 200); + assert.ok(estRonde(longueurCm), `k = ${k} : ${longueurCm}`); + assert.ok(Math.abs(largeurPx - longueurCm * k) < 1e-9, `k = ${k}`); + assert.ok(largeurPx <= 200, `k = ${k} : ${largeurPx}`); + assert.ok(rondeSuivante(longueurCm) * k > 200, `k = ${k} : ${longueurCm}`); + comptes += 1; + } + assert.equal(comptes, 79); + }); + + test("k ou largeur qui n'est pas un nombre fini positif est refusé", () => { + for (const [k, largeur] of [[0, 200], [-1, 200], [Number.NaN, 200], [Number.POSITIVE_INFINITY, 200], [1, 0], [1, -5], [1, Number.NaN], [null, 200]]) { + assert.throws(() => echelleGraphique(k, largeur), RangeError, `${k}, ${largeur}`); + } + }); +}); diff --git a/src/geometrie/texte.js b/src/geometrie/texte.js new file mode 100644 index 0000000..55ea83d --- /dev/null +++ b/src/geometrie/texte.js @@ -0,0 +1,75 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le texte du plan (§ 7.4) : la troncature d'un nom dans la largeur d'un bloc +// de noms, et les ordonnées des lignes d'une liste, chaque ligne étant un +// à décalage explicite. Aucune fonction ne lit le DOM : la largeur +// d'une chaîne vient d'une mesure injectée, getComputedTextLength au +// navigateur, sur la police rendue (§ 14.1). + +/** + * Gabarit dont se mesure la hauteur de ligne d'une liste : capitales + * accentuées et jambages, jamais une chaîne moyenne (§ 7.4). + */ +export const GABARIT_HAUTEUR_LIGNE = 'Ôjgq Æ W'; + +/** + * Corps du texte des listes de noms, en cm dans le dessin : les étiquettes ne + * sont pas à l'échelle du mobilier (§ 7.7), et ce corps fixe leur taille dans + * le dessin, que le zoom agrandit avec le reste. + */ +export const TAILLE_TEXTE_LISTE_CM = 18; + +// Vrai pour la première, ou la seconde, moitié d'une paire de substitution. +const estPremiereMoitie = (code) => code >= 0xd800 && code <= 0xdbff; +const estSecondeMoitie = (code) => code >= 0xdc00 && code <= 0xdfff; + +/** + * Le texte entier s'il tient dans largeurMax ; sinon le plus long préfixe + * qui, suivi de la marque, y tient ; '' quand la marque seule n'y tient pas ; + * '' reste ''. Le résultat ne dépasse jamais largeurMax. + * + * La recherche avance par dichotomie sur le nombre d'unités de code du + * préfixe, la largeur d'un préfixe croissant avec sa longueur. Une coupe qui + * tomberait entre les deux moitiés d'une paire de substitution recule d'une + * unité : le préfixe ne finit jamais sur une moitié de lettre. + * + * @param {string} texte + * @param {number} largeurMax + * @param {function(string): number} mesurer largeur d'une chaîne, dans l'unité de largeurMax + * @param {string} marque ce qui suit un préfixe tronqué + * @returns {string} + */ +export function tronquer(texte, largeurMax, mesurer, marque) { + if (texte === '' || mesurer(texte) <= largeurMax) return texte; + if (!(mesurer(marque) <= largeurMax)) return ''; + // Fin d'un préfixe de n unités, ramenée hors d'une paire de substitution. + const coupe = (n) => + n > 0 && estPremiereMoitie(texte.charCodeAt(n - 1)) && estSecondeMoitie(texte.charCodeAt(n)) ? n - 1 : n; + const avecMarque = (n) => texte.slice(0, coupe(n)) + marque; + // Invariant : le préfixe de bas unités tient, suivi de la marque ; celui de + // haut unités ne tient pas, le texte entier, suivi de la marque, compris. + let bas = 0; + let haut = texte.length; + while (haut - bas > 1) { + const milieu = bas + Math.floor((haut - bas) / 2); + if (mesurer(avecMarque(milieu)) <= largeurMax) bas = milieu; + else haut = milieu; + } + return avecMarque(bas); +} + +/** + * Ordonnées des n lignes d'une liste, la première à 0, chacune d'une hauteur + * de ligne sous la précédente. Lève RangeError sur un n qui n'est pas un + * entier ≥ 0. + * @param {number} n + * @param {number} hauteurLigne + * @returns {number[]} + */ +export function decalagesLignes(n, hauteurLigne) { + if (!Number.isInteger(n) || n < 0) { + throw new RangeError(`nombre de lignes : entier ≥ 0 attendu, reçu ${String(n)}`); + } + return Array.from({ length: n }, (_, i) => i * hauteurLigne); +} diff --git a/src/geometrie/texte.test.js b/src/geometrie/texte.test.js new file mode 100644 index 0000000..714eb5c --- /dev/null +++ b/src/geometrie/texte.test.js @@ -0,0 +1,92 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du texte du plan (§ 7.4, § 14.1) : la troncature avec marque et +// les décalages des lignes d'une liste. La mesure est injectée : ici, une +// somme de largeurs par glyphe écrites dans l'épreuve, où le glyphe large +// coûte plus que la moyenne ; au navigateur, getComputedTextLength. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { GABARIT_HAUTEUR_LIGNE, TAILLE_TEXTE_LISTE_CM, decalagesLignes, tronquer } from './texte.js'; + +const MARQUE = '…'; + +// Largeur d'un glyphe : W 10, Æ 11, i 3, la marque 6, tout autre 6. Une +// moitié de paire de substitution isolée ne dessine rien de mesurable et +// compte 0 : une coupe au milieu d'une paire paraîtrait tenir. +const LARGEURS = new Map([['W', 10], ['Æ', 11], ['i', 3], [MARQUE, 6]]); +const MOITIE = /^[\u{D800}-\u{DFFF}]$/u; +const mesurer = (chaine) => + [...chaine].reduce((somme, glyphe) => somme + (MOITIE.test(glyphe) ? 0 : (LARGEURS.get(glyphe) ?? 6)), 0); + +// Une moitié de paire de substitution isolée, n'importe où dans la chaîne. +const MOITIE_ISOLEE = /[\u{D800}-\u{DFFF}]/u; + +describe('texte : la troncature (§ 7.4, § 14.1)', () => { + test('un texte qui tient reste entier ; sinon le plus long préfixe qui, suivi de la marque, tient', () => { + assert.equal(mesurer('Ombrelle'), 48); + assert.equal(tronquer('Ombrelle', 60, mesurer, MARQUE), 'Ombrelle'); + assert.equal(tronquer('Ombrelle', 48, mesurer, MARQUE), 'Ombrelle'); + assert.equal(tronquer('WWWWWWWW', 60, mesurer, MARQUE), 'WWWWW…'); + assert.equal(mesurer('WWWWW…'), 56); + assert.equal(tronquer('ÆÆÆÆÆÆ', 40, mesurer, MARQUE), 'ÆÆÆ…'); + assert.equal(tronquer('Ombrelle', 47, mesurer, MARQUE), 'Ombrel…'); + }); + + test("la marque seule qui ne tient pas rend '' ; un texte entier qui tient sans marque reste ; '' reste ''", () => { + assert.equal(tronquer('Ombrelle', 4, mesurer, MARQUE), ''); + assert.equal(tronquer('i', 4, mesurer, MARQUE), 'i'); + assert.equal(tronquer('', 0, mesurer, MARQUE), ''); + assert.equal(tronquer('', 100, mesurer, MARQUE), ''); + assert.equal(tronquer('Ombrelle', 6, mesurer, MARQUE), '…'); + }); + + test("une lettre hors du plan de base n'est jamais coupée entre ses deux unités UTF-16", () => { + const texte = '\u{1D49C}\u{1D49C}\u{1D49C}\u{1D49C}'; + assert.equal(texte.length, 8); + assert.equal(tronquer(texte, 20, mesurer, MARQUE), '\u{1D49C}\u{1D49C}…'); + let comptes = 0; + for (let largeur = 0; largeur <= 30; largeur += 1) { + const rendu = tronquer(texte, largeur, mesurer, MARQUE); + assert.ok(!MOITIE_ISOLEE.test(rendu), `largeur ${largeur} : ${JSON.stringify(rendu)}`); + comptes += 1; + } + assert.equal(comptes, 31); + assert.equal(tronquer('Iris \u{1D49C}mbrelle', 44, mesurer, MARQUE), 'Iris \u{1D49C}…'); + }); + + test('le résultat ne dépasse jamais la largeur, et garde le plus long préfixe admis', () => { + const textes = ['Théo Pervenche', 'WWWWWWWWWWWW', 'ÆÆÆÆ iii WWW', 'Ondine Lacasse \u{1D49C}\u{1D49C} Grisaille', 'Ôjgq', 'x'.repeat(200)]; + let comptes = 0; + for (const texte of textes) { + for (let largeur = 0; largeur <= 130; largeur += 1) { + const rendu = tronquer(texte, largeur, mesurer, MARQUE); + assert.ok(mesurer(rendu) <= largeur, `${texte} dans ${largeur} : ${rendu}`); + if (rendu !== texte && rendu !== '') { + // Le préfixe d'un glyphe de plus, suivi de la marque, ne tiendrait pas. + const prefixe = [...rendu.slice(0, -MARQUE.length)]; + const suivant = [...texte][prefixe.length]; + assert.ok(texte.startsWith(prefixe.join('')), `${texte} dans ${largeur}`); + assert.ok(mesurer(prefixe.join('') + suivant + MARQUE) > largeur, `${texte} dans ${largeur} : ${rendu}`); + } + comptes += 1; + } + } + assert.equal(comptes, 6 * 131); + }); +}); + +describe("texte : les lignes d'une liste (§ 7.4)", () => { + test('les ordonnées des n lignes partent de 0 et avancent de la hauteur de ligne', () => { + assert.deepEqual(decalagesLignes(3, 21.5), [0, 21.5, 43]); + assert.deepEqual(decalagesLignes(1, 21.5), [0]); + assert.deepEqual(decalagesLignes(0, 21.5), []); + assert.throws(() => decalagesLignes(-1, 21.5), RangeError); + assert.throws(() => decalagesLignes(1.5, 21.5), RangeError); + }); + + test('le gabarit de hauteur porte capitales accentuées et jambages ; le corps des listes vaut 18 cm', () => { + assert.equal(GABARIT_HAUTEUR_LIGNE, 'Ôjgq Æ W'); + assert.equal(TAILLE_TEXTE_LISTE_CM, 18); + }); +}); diff --git a/src/geometrie/vue.js b/src/geometrie/vue.js new file mode 100644 index 0000000..c9ab959 --- /dev/null +++ b/src/geometrie/vue.js @@ -0,0 +1,218 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La vue du plan (§ 7.2) : trois nombres, k en px/cm, tx et ty en px CSS, que +// le dessin écrit dans un seul attribut, transform="translate(tx,ty) scale(k)", +// sur le groupe [data-vue] d'un sans viewBox. Toute +// conversion entre l'écran et le dessin passe par ce module : le dessin, la +// désignation d'un siège et le dépôt d'un glissement lisent la même formule. +// Aucune fonction ne lit le DOM : le cadre, rectangle du à l'écran, est +// un paramètre que l'appelant relit au pointerdown. + +/** @typedef {{k: number, tx: number, ty: number}} Vue */ +/** @typedef {{gauche: number, haut: number, largeur: number, hauteur: number}} Cadre */ +/** @typedef {{x: number, y: number}} Point */ +/** @typedef {{minX: number, minY: number, maxX: number, maxY: number}} Englobant */ + +/** Borne haute du zoom, en px/cm (§ 7.2). */ +export const K_MAX = 10; +/** k_min = FACTEUR_K_MIN × k_ajusté ; aucune borne basse absolue (§ 7.2). */ +export const FACTEUR_K_MIN = 0.5; +/** k ajusté d'un plan sans table, en px/cm : sans englobant, rien ne fixe d'échelle. */ +export const K_PLAN_VIDE = 1; +/** Marge laissée de chaque côté du cadre par l'ajustement, en px. */ +export const MARGE_AJUSTEMENT_PX = 16; +/** Facteur de zoom d'un cran de molette. */ +export const FACTEUR_CRAN = 1.1; + +// Longueur d'un cran de molette, par valeur de WheelEvent.deltaMode : en +// pixels, en lignes, en pages. +const CRAN_PAR_MODE = [100, 3, 1]; + +/** + * Point de dessin, en cm, sous le point écran (xEcran, yEcran), en px CSS de + * la fenêtre : la formule du § 7.2, cadre soustrait avant la translation, la + * division par k en dernier. + * @param {Vue} vue @param {Cadre} cadre + * @returns {Point} + */ +export function dessinDepuisEcran(vue, cadre, xEcran, yEcran) { + return { x: (xEcran - cadre.gauche - vue.tx) / vue.k, y: (yEcran - cadre.haut - vue.ty) / vue.k }; +} + +/** + * Point écran, en px CSS de la fenêtre, du point de dessin (x, y) en cm : la + * réciproque de dessinDepuisEcran. + * @param {Vue} vue @param {Cadre} cadre + * @returns {Point} + */ +export function ecranDepuisDessin(vue, cadre, x, y) { + return { x: cadre.gauche + vue.tx + vue.k * x, y: cadre.haut + vue.ty + vue.k * y }; +} + +/** + * Le k qui fait tenir l'englobant dans le cadre moins ses marges, plafonné à + * K_MAX ; K_PLAN_VIDE sans englobant. Null quand la largeur ou la hauteur du + * cadre ne dépasse pas 2 × MARGE_AJUSTEMENT_PX, plan vide compris : aucun k + * positif n'y fait rien tenir, et un cadre de taille nulle est celui d'un plan + * monté caché. Un englobant plat dans une direction n'y contraint rien, et un + * englobant réduit à un point prend K_MAX. Tout k rendu est fini et > 0. + * @param {Englobant|null} englobant @param {Cadre} cadre + * @returns {number|null} + */ +export function kAjuste(englobant, cadre) { + const utileX = cadre.largeur - 2 * MARGE_AJUSTEMENT_PX; + const utileY = cadre.hauteur - 2 * MARGE_AJUSTEMENT_PX; + if (!(utileX > 0 && utileY > 0)) return null; + if (englobant === null) return K_PLAN_VIDE; + const etendueX = englobant.maxX - englobant.minX; + const etendueY = englobant.maxY - englobant.minY; + // Une étendue nulle donne Infinity, que le plafond ramène à K_MAX. + const kX = etendueX > 0 ? utileX / etendueX : Number.POSITIVE_INFINITY; + const kY = etendueY > 0 ? utileY / etendueY : Number.POSITIVE_INFINITY; + return Math.min(K_MAX, kX, kY); +} + +/** + * La vue ajustée : k = kAjuste, centre de l'englobant au centre du cadre ; un + * plan vide met l'origine du dessin au centre. Null quand kAjuste l'est : + * l'appelant garde la vue en place. + * @param {Englobant|null} englobant @param {Cadre} cadre + * @returns {Vue|null} + */ +export function ajuster(englobant, cadre) { + const k = kAjuste(englobant, cadre); + if (k === null) return null; + const cx = englobant === null ? 0 : (englobant.minX + englobant.maxX) / 2; + const cy = englobant === null ? 0 : (englobant.minY + englobant.maxY) / 2; + return { k, tx: cadre.largeur / 2 - k * cx, ty: cadre.hauteur / 2 - k * cy }; +} + +/** + * Bornes du zoom : kMin = FACTEUR_K_MIN × kAjuste, kMax = K_MAX. Lève + * RangeError sur un kAjuste qui n'est pas un nombre fini > 0, null compris : + * 0,5 × null vaudrait 0, et la borne basse disparaîtrait sans bruit. + * @param {number} kAjuste + * @returns {{kMin: number, kMax: number}} + */ +export function bornesZoom(kAjuste) { + if (typeof kAjuste !== 'number' || !Number.isFinite(kAjuste) || kAjuste <= 0) { + throw new RangeError(`k ajusté : nombre fini > 0 attendu, reçu ${String(kAjuste)}`); + } + return { kMin: FACTEUR_K_MIN * kAjuste, kMax: K_MAX }; +} + +// k ramené dans les bornes. +const dansBornes = (k, bornes) => Math.min(bornes.kMax, Math.max(bornes.kMin, k)); + +// La vue d'échelle k dont le point écran (xCadre, yCadre), relatif au coin du +// cadre, montre le même point de dessin que dans vue. +function versK(vue, xCadre, yCadre, k) { + const x = (xCadre - vue.tx) / vue.k; + const y = (yCadre - vue.ty) / vue.k; + return { k, tx: xCadre - k * x, ty: yCadre - k * y }; +} + +/** + * La vue, son k ramené dans les bornes, le point de dessin au centre du cadre + * restant au centre ; inchangée quand k y est déjà. Un cadrage mémorisé, + * restitué dans un autre cadre, y passe. + * @param {Vue} vue @param {Cadre} cadre @param {{kMin: number, kMax: number}} bornes + * @returns {Vue} + */ +export function borner(vue, cadre, bornes) { + const k = dansBornes(vue.k, bornes); + if (k === vue.k) return { k: vue.k, tx: vue.tx, ty: vue.ty }; + return versK(vue, cadre.largeur / 2, cadre.hauteur / 2, k); +} + +/** + * Zoom d'un facteur sous le point écran (xEcran, yEcran) : k devient + * k × facteur ramené dans les bornes, puis la translation se calcule sur ce k + * borné, de sorte que le point de dessin sous le pointeur y reste, à une borne + * comprise. Lève RangeError sur un facteur qui n'est pas un nombre fini > 0. + * @param {Vue} vue @param {Cadre} cadre @param {number} facteur + * @param {{kMin: number, kMax: number}} bornes + * @returns {Vue} + */ +export function zoomerSous(vue, cadre, xEcran, yEcran, facteur, bornes) { + if (!Number.isFinite(facteur) || facteur <= 0) { + throw new RangeError(`facteur de zoom : nombre fini > 0 attendu, reçu ${String(facteur)}`); + } + return versK(vue, xEcran - cadre.gauche, yEcran - cadre.haut, dansBornes(vue.k * facteur, bornes)); +} + +/** + * Position d'une table glissée, en cm : positionInitiale + (pointeur − + * pointeurInitial) / k. Le calcul se fait en écart : la table garde le + * décalage où le pointeur l'a saisie, et un mouvement nul la laisse en place. + * @param {Point} positionInitiale cm @param {Point} pointeurInitial px + * @param {Point} pointeur px @param {number} k + * @returns {Point} + */ +export function deplacer(positionInitiale, pointeurInitial, pointeur, k) { + return { + x: positionInitiale.x + (pointeur.x - pointeurInitial.x) / k, + y: positionInitiale.y + (pointeur.y - pointeurInitial.y) / k, + }; +} + +/** + * Vue glissée par le sol : tx, ty + (pointeur − pointeurInitial), en px ; k + * inchangé. + * @param {Vue} vueInitiale @param {Point} pointeurInitial @param {Point} pointeur + * @returns {Vue} + */ +export function deplacerVue(vueInitiale, pointeurInitial, pointeur) { + return { + k: vueInitiale.k, + tx: vueInitiale.tx + (pointeur.x - pointeurInitial.x), + ty: vueInitiale.ty + (pointeur.y - pointeurInitial.y), + }; +} + +/** + * Facteur de zoom d'un événement de molette : FACTEUR_CRAN puissance le + * nombre de crans, un cran valant 100 px, 3 lignes ou une page selon deltaMode + * (0, 1, 2) ; > 1 vers le haut (deltaY négatif), 1 sans mouvement. Lève + * RangeError sur un autre deltaMode. + * @param {number} deltaY @param {number} deltaMode + * @returns {number} + */ +export function facteurMolette(deltaY, deltaMode) { + const cran = CRAN_PAR_MODE[deltaMode]; + if (!Number.isInteger(deltaMode) || cran === undefined) { + throw new RangeError(`deltaMode : 0, 1 ou 2 attendu, reçu ${String(deltaMode)}`); + } + return FACTEUR_CRAN ** (-deltaY / cran); +} + +/** + * Valeur de l'attribut transform du groupe [data-vue] : chaque nombre écrit + * par String, que Number relit à l'identique. + * @param {Vue} vue + * @returns {string} + */ +export function transformDe(vue) { + return `translate(${String(vue.tx)},${String(vue.ty)}) scale(${String(vue.k)})`; +} + +// Un nombre tel que String l'écrit : signe, chiffres, partie décimale, +// exposant. +const NOMBRE = String.raw`-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?`; +const FORME_TRANSFORM = new RegExp(`^translate\\((${NOMBRE}),(${NOMBRE})\\) scale\\((${NOMBRE})\\)$`); + +/** + * La vue que décrit un attribut transform de la forme qu'écrit transformDe. + * Lève SyntaxError sur toute autre forme, et sur un k qui n'est pas > 0. + * @param {string} texte + * @returns {Vue} + */ +export function vueDepuisTransform(texte) { + const lu = FORME_TRANSFORM.exec(texte); + const k = lu === null ? Number.NaN : Number(lu[3]); + if (lu === null || !(k > 0) || !Number.isFinite(k)) { + throw new SyntaxError(`transform de vue attendu, reçu ${JSON.stringify(texte)}`); + } + return { k, tx: Number(lu[1]), ty: Number(lu[2]) }; +} diff --git a/src/geometrie/vue.test.js b/src/geometrie/vue.test.js new file mode 100644 index 0000000..0f2363a --- /dev/null +++ b/src/geometrie/vue.test.js @@ -0,0 +1,348 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la vue du plan (§ 7.2, § 14.10) : la conversion écran ↔ dessin, +// l'ajustement, les bornes relatives du zoom, le zoom sous le curseur, le +// glissement en écart, la molette et l'attribut transform. Chaque valeur +// attendue est écrite en clair ou se déduit, à la main, de la formule du +// § 7.2 ; les formules fausses qu'une épreuve doit refuser sont écrites ici, +// et montrées rouges sur les mêmes cas. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { FLUX, creerPcg32 } from '../demo/prng.js'; +import { + FACTEUR_CRAN, + FACTEUR_K_MIN, + K_MAX, + K_PLAN_VIDE, + MARGE_AJUSTEMENT_PX, + ajuster, + borner, + bornesZoom, + deplacer, + deplacerVue, + dessinDepuisEcran, + ecranDepuisDessin, + facteurMolette, + kAjuste, + transformDe, + vueDepuisTransform, + zoomerSous, +} from './vue.js'; + +const PRECISION = 1e-9; + +// Vrai quand a et b diffèrent de moins de PRECISION, relativement à leur +// grandeur quand elle dépasse 1. +const proches = (a, b) => Math.abs(a - b) <= PRECISION * Math.max(1, Math.abs(a), Math.abs(b)); + +function egauxPoints(recu, attendu, message) { + assert.ok( + proches(recu.x, attendu.x) && proches(recu.y, attendu.y), + `${message} : (${recu.x}, ${recu.y}) au lieu de (${attendu.x}, ${attendu.y})`, + ); +} + +function egalesVues(recue, attendue, message) { + assert.ok( + proches(recue.k, attendue.k) && proches(recue.tx, attendue.tx) && proches(recue.ty, attendue.ty), + `${message} : ${JSON.stringify(recue)} au lieu de ${JSON.stringify(attendue)}`, + ); +} + +// Un englobant carré de côté c cm, coin haut gauche à l'origine. +const carre = (c) => ({ minX: 0, minY: 0, maxX: c, maxY: c }); + +describe('vue : conversion écran ↔ dessin (§ 7.2)', () => { + const VUE = { k: 2, tx: 10, ty: 20 }; + const CADRE = { gauche: 100, haut: 50, largeur: 800, hauteur: 600 }; + + // Cas écrits à la main : [vue, cadre, point écran, point de dessin]. + const CAS = [ + [VUE, CADRE, { x: 150, y: 90 }, { x: 20, y: 10 }], + [{ k: 0.19, tx: -50, ty: 30 }, { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }, { x: 67, y: 279 }, { x: 80 / 0.19, y: 38 / 0.19 }], + [{ k: 10, tx: 0, ty: 0 }, { gauche: 37, haut: 211, largeur: 300, hauteur: 900 }, { x: 37, y: 211 }, { x: 0, y: 0 }], + ]; + + // Écarts d'une conversion aux cas écrits à la main, un par cas faux. + const ecarts = (conversion) => + CAS.filter(([vue, cadre, ecran, dessin]) => { + const recu = conversion(vue, cadre, ecran.x, ecran.y); + return !(proches(recu.x, dessin.x) && proches(recu.y, dessin.y)); + }); + + test('le point écran (150, 90) est le point de dessin (20, 10), et réciproquement', () => { + egauxPoints(dessinDepuisEcran(VUE, CADRE, 150, 90), { x: 20, y: 10 }, 'dessin'); + egauxPoints(ecranDepuisDessin(VUE, CADRE, 20, 10), { x: 150, y: 90 }, 'écran'); + assert.deepEqual(ecarts(dessinDepuisEcran), []); + }); + + test('trois formules fausses tombent sur les mêmes cas : cadre oublié, division avant la translation, répartition dans un viewBox', () => { + const cadreOublie = (vue, cadre, x, y) => ({ x: (x - vue.tx) / vue.k, y: (y - vue.ty) / vue.k }); + const diviseAvant = (vue, cadre, x, y) => ({ + x: (x - cadre.gauche) / vue.k - vue.tx, + y: (y - cadre.haut) / vue.k - vue.ty, + }); + // Un viewBox carré, de la largeur visible du dessin : le pointeur s'y + // répartit en proportion du cadre, juste en x, faux en y dès que le cadre + // n'est pas carré. + const viewBox = (vue, cadre, x, y) => { + const cote = cadre.largeur / vue.k; + return { + x: -vue.tx / vue.k + ((x - cadre.gauche) / cadre.largeur) * cote, + y: -vue.ty / vue.k + ((y - cadre.haut) / cadre.hauteur) * cote, + }; + }; + for (const [nom, faux] of [['cadre oublié', cadreOublie], ['division avant', diviseAvant], ['viewBox', viewBox]]) { + assert.ok(ecarts(faux).length > 0, nom); + } + }); + + test("l'aller-retour écran → dessin → écran rend le point, sur 25 points, quatre k et un cadre décalé", () => { + const cadre = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }; + let comptes = 0; + for (const k of [0.1, 0.19, 1, 10]) { + const vue = { k, tx: -123.25, ty: 47.5 }; + for (let i = 0; i < 5; i += 1) { + for (let j = 0; j < 5; j += 1) { + const ecran = { x: cadre.gauche + (i * cadre.largeur) / 4, y: cadre.haut + (j * cadre.hauteur) / 4 }; + const dessin = dessinDepuisEcran(vue, cadre, ecran.x, ecran.y); + egauxPoints(ecranDepuisDessin(vue, cadre, dessin.x, dessin.y), ecran, `k = ${k}, (${i}, ${j})`); + comptes += 1; + } + } + } + assert.equal(comptes, 100); + }); +}); + +describe("vue : l'ajustement et les bornes relatives du zoom (§ 7.2, § 14.10)", () => { + test('un plan de 36 m dans 1 366 × 700 s\'ajuste sous 0,2 px/cm, et k_min vaut 0,5 × k_ajusté sur deux plans', () => { + const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 }; + const grand = kAjuste(carre(3600), cadre); + assert.ok(proches(grand, (700 - 32) / 3600), `k ajusté ${grand}`); + assert.ok(grand < 0.2); + assert.equal(bornesZoom(grand).kMin, 0.5 * grand); + assert.ok(bornesZoom(grand).kMin < 0.2); + const petit = kAjuste(carre(400), cadre); + assert.ok(proches(petit, 668 / 400), `k ajusté ${petit}`); + assert.equal(bornesZoom(petit).kMin, 0.5 * petit); + assert.equal(bornesZoom(petit).kMax, 10); + assert.equal(FACTEUR_K_MIN, 0.5); + assert.equal(K_MAX, 10); + assert.equal(MARGE_AJUSTEMENT_PX, 16); + }); + + test('k_max vaut 10 : un zoom avant de facteur 2 depuis k = 9 rend 10', () => { + const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 }; + const vue = zoomerSous({ k: 9, tx: 0, ty: 0 }, cadre, 400, 300, 2, bornesZoom(1)); + assert.equal(vue.k, 10); + }); + + test("ajuster centre l'englobant au k ajusté ; le plan vide prend 1 px/cm, l'origine au centre", () => { + const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 }; + const vue = ajuster(carre(3600), cadre); + assert.ok(proches(vue.k, 668 / 3600)); + egauxPoints(ecranDepuisDessin(vue, cadre, 1800, 1800), { x: 683, y: 350 }, 'centre'); + const decale = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }; + egauxPoints(ecranDepuisDessin(ajuster(carre(3600), decale), decale, 1800, 1800), { x: 37 + 683, y: 211 + 350 }, 'centre, cadre décalé'); + + const vide = { gauche: 5, haut: 7, largeur: 800, hauteur: 600 }; + assert.equal(K_PLAN_VIDE, 1); + assert.equal(kAjuste(null, vide), 1); + assert.deepEqual(ajuster(null, vide), { k: 1, tx: 400, ty: 300 }); + assert.deepEqual(bornesZoom(kAjuste(null, vide)), { kMin: 0.5, kMax: 10 }); + }); + + test("un englobant de 10 × 10 cm s'ajuste à k = 10, plafonné ; un englobant réduit à un point aussi", () => { + const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 }; + assert.equal(kAjuste(carre(10), cadre), 10); + assert.equal(ajuster(carre(10), cadre).k, 10); + assert.equal(kAjuste({ minX: 300, minY: 300, maxX: 300, maxY: 300 }, cadre), 10); + const ligne = ajuster({ minX: 0, minY: 300, maxX: 3000, maxY: 300 }, cadre); + assert.ok(proches(ligne.k, 768 / 3000)); + egauxPoints(ecranDepuisDessin(ligne, cadre, 1500, 300), { x: 400, y: 300 }, 'centre'); + }); + + test("un cadre nul ou plus petit que ses marges ne s'ajuste pas : null, plan vide compris ; 33 px de large suffisent", () => { + for (const [largeur, hauteur] of [[0, 600], [800, 0], [0, 0], [20, 20], [32, 600], [800, 32]]) { + const cadre = { gauche: 0, haut: 0, largeur, hauteur }; + assert.equal(kAjuste(carre(3600), cadre), null, `${largeur} × ${hauteur}`); + assert.equal(kAjuste(null, cadre), null, `${largeur} × ${hauteur}, plan vide`); + assert.equal(ajuster(carre(3600), cadre), null, `${largeur} × ${hauteur}`); + assert.equal(ajuster(null, cadre), null, `${largeur} × ${hauteur}, plan vide`); + } + const etroit = { gauche: 0, haut: 0, largeur: 33, hauteur: 600 }; + assert.ok(proches(kAjuste(carre(3600), etroit), 1 / 3600)); + }); + + test('bornesZoom refuse un k ajusté qui n\'est pas un nombre fini positif', () => { + for (const k of [null, undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, '1']) { + assert.throws(() => bornesZoom(k), RangeError, String(k)); + } + }); + + test('200 englobants et cadres tirés, nuls et vides compris : chaque nombre rendu est fini, chaque k > 0, ou le résultat vaut null', () => { + const GRAINE = 20260; + const alea = creerPcg32(GRAINE, FLUX.DEMO); + let nuls = 0; + let rendus = 0; + for (let i = 0; i < 200; i += 1) { + const nature = i % 5; + const cadre = { + gauche: alea.borne(2000) - 1000, + haut: alea.borne(2000) - 1000, + largeur: nature === 0 ? alea.borne(33) : alea.borne(3000), + hauteur: nature === 1 ? 0 : alea.borne(3000), + }; + let englobant = null; + if (nature >= 2) { + const minX = alea.borne(20000) - 10000; + const minY = alea.borne(20000) - 10000; + const plat = nature === 2; + englobant = { + minX, + minY, + maxX: minX + (plat ? 0 : alea.borne(10000) / 7), + maxY: minY + (plat ? 0 : alea.borne(10000) / 3), + }; + } + const k = kAjuste(englobant, cadre); + const vue = ajuster(englobant, cadre); + if (k === null) { + assert.equal(vue, null, `tirage ${i}`); + nuls += 1; + continue; + } + rendus += 1; + assert.ok(Number.isFinite(k) && k > 0 && k <= K_MAX, `tirage ${i} : k ${k}`); + assert.ok([vue.k, vue.tx, vue.ty].every(Number.isFinite) && vue.k > 0, `tirage ${i} : ${JSON.stringify(vue)}`); + const bornes = bornesZoom(k); + assert.ok(Number.isFinite(bornes.kMin) && bornes.kMin > 0, `tirage ${i}`); + } + assert.ok(nuls > 0 && rendus > 0, `${nuls} nuls, ${rendus} rendus`); + }); + + test('un cadrage hors des bornes est ramené dans elles, le centre du cadre fixe', () => { + const cadre = { gauche: 30, haut: 40, largeur: 800, hauteur: 600 }; + const k = kAjuste({ minX: 0, minY: 0, maxX: 1000, maxY: 1420 }, cadre); + assert.ok(proches(k, 0.4)); + const bornes = bornesZoom(k); + const memorise = { k: 0.1, tx: -25, ty: 60 }; + const centre = dessinDepuisEcran(memorise, cadre, 30 + 400, 40 + 300); + const ramene = borner(memorise, cadre, bornes); + assert.ok(proches(ramene.k, 0.2), `k ${ramene.k}`); + egauxPoints(dessinDepuisEcran(ramene, cadre, 30 + 400, 40 + 300), centre, 'centre'); + const trop = borner({ k: 25, tx: 3, ty: 4 }, cadre, bornes); + assert.equal(trop.k, 10); + assert.deepEqual(borner({ k: 1, tx: 3, ty: 4 }, cadre, bornes), { k: 1, tx: 3, ty: 4 }); + }); +}); + +describe('vue : le zoom sous le curseur (§ 7.2)', () => { + const CADRE = { gauche: 37, haut: 211, largeur: 800, hauteur: 600 }; + const DEPART = { k: 1, tx: 50, ty: 40 }; + const BORNES = { kMin: 0.2, kMax: 10 }; + const POINTS = [ + { x: 37 + 400, y: 211 + 300 }, + { x: 37 + 799, y: 211 + 599 }, + { x: 37, y: 211 }, + ]; + + // Les cas où le point de dessin sous le pointeur bouge, pour une fonction + // de zoom donnée. + const fuites = (zoomer) => + [1.1, 1 / 1.1, 100, 0.001].flatMap((facteur) => + POINTS.filter((p) => { + const avant = dessinDepuisEcran(DEPART, CADRE, p.x, p.y); + const apres = dessinDepuisEcran(zoomer(DEPART, CADRE, p.x, p.y, facteur, BORNES), CADRE, p.x, p.y); + return !(proches(avant.x, apres.x) && proches(avant.y, apres.y)); + }), + ); + + test('le point de dessin sous le pointeur y reste, au centre comme au coin, aux bornes comprises', () => { + assert.deepEqual(fuites(zoomerSous), []); + assert.equal(zoomerSous(DEPART, CADRE, 437, 511, 100, BORNES).k, 10); + assert.equal(zoomerSous(DEPART, CADRE, 437, 511, 0.001, BORNES).k, 0.2); + assert.ok(proches(zoomerSous(DEPART, CADRE, 437, 511, 1.1, BORNES).k, 1.1)); + }); + + test("deux zooms faux tombent : autour de l'origine du cadre, et le bornage appliqué après coup", () => { + const borne = (k) => Math.min(BORNES.kMax, Math.max(BORNES.kMin, k)); + const autourDeLOrigine = (vue, cadre, x, y, facteur, bornes) => ({ k: borne(vue.k * facteur), tx: vue.tx, ty: vue.ty }); + const borneApres = (vue, cadre, x, y, facteur) => { + const k = vue.k * facteur; + const p = dessinDepuisEcran(vue, cadre, x, y); + return { k: borne(k), tx: x - cadre.gauche - k * p.x, ty: y - cadre.haut - k * p.y }; + }; + assert.ok(fuites(autourDeLOrigine).length > 0); + assert.ok(fuites(borneApres).length > 0); + }); + + test('un facteur qui n\'est pas un nombre fini positif est refusé', () => { + for (const facteur of [0, -1.1, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => zoomerSous(DEPART, CADRE, 437, 511, facteur, BORNES), RangeError, String(facteur)); + } + }); +}); + +describe('vue : le glissement se calcule en écart (§ 7.2)', () => { + test("une table saisie à 37 px de son centre suit l'écart du pointeur, divisé par k", () => { + const initiale = { x: 300, y: 300 }; + const pointeurInitial = { x: 437, y: 260 }; + assert.deepEqual(deplacer(initiale, pointeurInitial, { x: 477, y: 240 }, 2), { x: 320, y: 290 }); + assert.deepEqual(deplacer(initiale, pointeurInitial, pointeurInitial, 2), initiale); + }); + + test("la mutation qui met l'origine de la table sous le pointeur tombe", () => { + // Vue où le centre de la table, (300, 300) cm, est à l'écran (400, 260) : + // le pointeur saisi en (437, 260) est à 37 px à droite du centre. + const vue = { k: 2, tx: -200, ty: -340 }; + const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 }; + egauxPoints(ecranDepuisDessin(vue, cadre, 300, 300), { x: 400, y: 260 }, 'centre'); + const sousLePointeur = (initiale, pointeurInitial, pointeur) => dessinDepuisEcran(vue, cadre, pointeur.x, pointeur.y); + const initiale = { x: 300, y: 300 }; + const pointeurInitial = { x: 437, y: 260 }; + assert.notDeepEqual(sousLePointeur(initiale, pointeurInitial, pointeurInitial, 2), initiale); + assert.notDeepEqual(sousLePointeur(initiale, pointeurInitial, { x: 477, y: 240 }, 2), { x: 320, y: 290 }); + }); + + test("glisser le sol ajoute l'écart du pointeur, en px, et garde k", () => { + assert.deepEqual(deplacerVue({ k: 0.5, tx: 10, ty: -20 }, { x: 100, y: 100 }, { x: 130, y: 85 }), { k: 0.5, tx: 40, ty: -35 }); + }); +}); + +describe("vue : la molette et l'attribut transform", () => { + test('un cran vaut 100 px, 3 lignes ou une page ; vers le haut, le facteur dépasse 1', () => { + assert.equal(FACTEUR_CRAN, 1.1); + assert.ok(proches(facteurMolette(-100, 0), 1.1)); + assert.ok(proches(facteurMolette(100, 0), 1 / 1.1)); + assert.ok(proches(facteurMolette(-3, 1), 1.1)); + assert.ok(proches(facteurMolette(1, 2), 1 / 1.1)); + assert.ok(proches(facteurMolette(-250, 0), 1.1 ** 2.5)); + assert.equal(facteurMolette(0, 0), 1); + assert.throws(() => facteurMolette(-100, 3), RangeError); + }); + + test('transformDe et vueDepuisTransform font l\'aller-retour ; une autre forme lève', () => { + const vues = [ + { k: 2, tx: 10, ty: 20 }, + { k: 1 / 3600, tx: -683.5, ty: 0.1 + 0.2 }, + { k: 10, tx: 1e-7, ty: -2.5e21 }, + ]; + assert.equal(transformDe(vues[0]), 'translate(10,20) scale(2)'); + for (const vue of vues) assert.deepEqual(vueDepuisTransform(transformDe(vue)), vue); + for (const texte of [ + '', + 'translate(10,20)', + 'scale(2) translate(10,20)', + 'translate(10 20) scale(2)', + 'translate(10,20) scale(0)', + 'translate(10,20) scale(-2)', + 'translate(a,20) scale(2)', + 'translate(10,20) scale(2) rotate(3)', + ]) { + assert.throws(() => vueDepuisTransform(texte), SyntaxError, texte); + } + }); +}); diff --git a/test/arborescence.test.js b/test/arborescence.test.js index 70ff45a..d13ef42 100644 --- a/test/arborescence.test.js +++ b/test/arborescence.test.js @@ -1,9 +1,9 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Gardes de l'arborescence, lues dans le texte des sources : le moteur, le -// générateur de démonstrations, le stockage et l'analyseur CSV n'appellent -// aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un +// Gardes de l'arborescence, lues dans le texte des sources : le moteur, la +// géométrie du plan, le générateur de démonstrations, le stockage et +// l'analyseur CSV n'appellent aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un // objet que par exception nommée (§ 15.5, point 4) ; le graphe d'imports de // chaque couche ne rejoint que ce que le § 13.4 lui permet ; chaque paquet // importé est déclaré dans devDependencies ; une valeur du contrat de données @@ -138,10 +138,10 @@ const PARCOURS_DE_CLES_ADMIS = [ ]; // Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) : -// le moteur et le générateur de démonstrations, puis le stockage et -// l'analyseur CSV, qui reçoivent l'horloge et l'aléa de l'application, en -// paramètre. -const ARBRES_DETERMINISTES = ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']; +// le moteur, la géométrie du plan et le générateur de démonstrations, puis le +// stockage et l'analyseur CSV, qui reçoivent l'horloge et l'aléa de +// l'application, en paramètre. +const ARBRES_DETERMINISTES = ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv']; // Vrai quand le motif se lit dans le texte à partir de l'indice donné, et non // plus loin : une copie collante du motif, ses drapeaux g et y retirés. @@ -196,6 +196,7 @@ function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) { // pour éprouver le refus d'un balayage vide. const MODULES_VIDES = Object.freeze({ 'src/moteur/a.js': '', + 'src/geometrie/a.js': '', 'src/demo/a.js': '', 'src/stockage/a.js': '', 'src/csv/a.js': '', @@ -210,7 +211,7 @@ const sansArbre = (fichiers, arbre) => const releverSansException = (racine) => releverDeterminisme(racine, []); describe('arborescence : déterminisme (§ 14.7)', () => { - test("ni src/moteur, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => { + test("ni src/moteur, ni src/geometrie, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => { assert.deepEqual(releverDeterminisme(RACINE), []); }); @@ -315,7 +316,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => { 'src/demo/tirage.cjs:1 Date.now', 'src/demo/tirage.mjs:1 Math.random', ]); - for (const arbre of ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']) { + for (const arbre of ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv']) { const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' }; assert.throws( () => avecArbre(sansModule, releverSansException), @@ -325,6 +326,24 @@ describe('arborescence : déterminisme (§ 14.7)', () => { } }); + test('src/geometrie se relève comme le moteur, à toute profondeur, hors épreuves', () => { + const fichiers = { + ...MODULES_VIDES, + 'src/geometrie/vue.js': [ + 'export const a = Math.random();', + 'export const b = (o) => Object.keys(o).map((cle) => o[cle]);', + 'export const c = Math.min(1, 2) + Math.floor(2.5);', + ].join('\n'), + 'src/geometrie/sous/texte.js': 'for (const champ in ligne) t.push(champ);\n', + 'src/geometrie/vue.test.js': 'export const d = Math.random() + Object.keys(o).length;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverSansException), [ + 'src/geometrie/sous/texte.js:1 for…in', + 'src/geometrie/vue.js:1 Math.random', + 'src/geometrie/vue.js:2 Object.keys', + ]); + }); + test('src/stockage et src/csv se relèvent comme le moteur et le générateur, à toute profondeur, hors épreuves', () => { const fichiers = { ...MODULES_VIDES, @@ -470,7 +489,9 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; // l'application. // // Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce -// qui les commande. Le stockage lit et écrit par le système de fichiers qu'il +// qui les commande. La géométrie est une couche à part, plus bas que le +// moteur : des fonctions pures du plan, qui n'importent ni le moteur, ni +// l'analyseur CSV, ni le générateur de démonstrations. Le stockage lit et écrit par le système de fichiers qu'il // reçoit ; seules ses deux implémentations de plateforme touchent le // navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron. // L'analyseur CSV lit le modèle du stockage. L'application câble le moteur, @@ -478,12 +499,19 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; // l'interface. const COUCHES = [ { - arbres: ['src/moteur', 'src/geometrie'], + arbres: ['src/moteur'], requis: ['src/moteur'], exterieures: ['interface', 'application', 'stockage'], paquets: PAQUETS_DE_PLATEFORME, navigateur: [], }, + { + arbres: ['src/geometrie'], + requis: ['src/geometrie'], + exterieures: ['interface', 'application', 'stockage', 'moteur', 'csv', 'demo'], + paquets: PAQUETS_DE_PLATEFORME, + navigateur: [], + }, { arbres: ['src/stockage'], requis: ['src/stockage'], @@ -815,10 +843,10 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { 'src/moteur/a.js:26 import ../application/seance.js', 'src/moteur/b.js:4 import ../interface/App.svelte', 'src/moteur/sous/c.js:1 import ../../interface/x.js', + 'src/moteur/a.js → src/commun/aide.js:2 window.', 'src/geometrie/echelle.js:1 window.', 'src/geometrie/echelle.js:3 import ../application/commandes.js', 'src/geometrie/echelle.js:4 import ../application/seance.js', - 'src/moteur/a.js → src/commun/aide.js:2 window.', 'src/geometrie/echelle.js → src/commun/racine.js:1 window.', 'src/stockage/fichiers.js:1 import @capacitor/core', ]); @@ -989,10 +1017,57 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { ]); }); - test('src/geometrie et src/application peuvent manquer ; un src/moteur, un src/stockage ou un src/csv sans module fait échouer la garde', () => { + test("src/geometrie n'importe ni le moteur, ni le CSV, ni les démonstrations : une couche à part, plus bas que le moteur", () => { + const fichiers = { + ...MODULES_VIDES, + 'src/geometrie/vue.js': [ + "import { mesurer } from '../moteur/indicateurs.js';", + "import { decouper } from '../csv/lecture.js';", + "import { creerPcg32 } from '../demo/prng.js';", + "import { tronquer } from './texte.js';", + ].join('\n'), + 'src/geometrie/texte.js': 'export const tronquer = (t) => t;\n', + 'src/moteur/indicateurs.js': 'export const mesurer = () => 0;\n', + 'src/csv/lecture.js': 'export const decouper = () => [];\n', + 'src/demo/prng.js': 'export const creerPcg32 = () => null;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/geometrie/vue.js:1 import ../moteur/indicateurs.js', + 'src/geometrie/vue.js:2 import ../csv/lecture.js', + 'src/geometrie/vue.js:3 import ../demo/prng.js', + ]); + }); + + test("src/geometrie n'importe ni l'interface, ni l'application, ni le stockage, ni un paquet de plateforme", () => { + const fichiers = { + ...MODULES_VIDES, + 'src/geometrie/vue.js': [ + "import { x } from '../interface/x.js';", + "import { y } from '../application/y.js';", + "import { lire } from '../stockage/document.js';", + "import { mount } from 'svelte';", + "import { writable } from 'svelte/store';", + "import { app } from 'electron';", + "import { Capacitor } from '@capacitor/core';", + "import { tronquer } from './texte.js';", + ].join('\n'), + 'src/geometrie/texte.js': 'export const tronquer = (t) => t;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/geometrie/vue.js:1 import ../interface/x.js', + 'src/geometrie/vue.js:2 import ../application/y.js', + 'src/geometrie/vue.js:3 import ../stockage/document.js', + 'src/geometrie/vue.js:4 import svelte', + 'src/geometrie/vue.js:5 import svelte/store', + 'src/geometrie/vue.js:6 import electron', + 'src/geometrie/vue.js:7 import @capacitor/core', + ]); + }); + + test('src/application peut manquer ; un src/moteur, un src/geometrie, un src/stockage ou un src/csv sans module fait échouer la garde', () => { assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []); - for (const arbre of ['src/moteur', 'src/stockage', 'src/csv']) { - const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '', 'src/geometrie/echelle.js': '' }; + for (const arbre of ['src/moteur', 'src/geometrie', 'src/stockage', 'src/csv']) { + const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' }; assert.throws( () => avecArbre(sansModule, releverFrontiere), new RegExp(`${arbre} ne fournit aucun module`),