gestion_table_tournante_libre/src/application/format.test.js
Mathieu Benoit 71076afe4d [ADD] application: clock, translation, formatting, displayed name
The screens need one clock, one translation mechanism and one way to
format and name, or each component grows its own. horloge.js is the only
code that reads time and arms timers, injected so tests drive it.
traduction.js keys texts by domain, in two families of tables, raises on a
missing key and never shows a raw key; libelles/ splits the label table by
domain. format.js formats numbers and dates in French, and personnes.js
composes the displayed name the frozen labels use. Nothing persists
between sessions.
Checked: 1773 node and 43 browser tests.

--- FR ---

[ADD] application : horloge, traduction, mise en forme, nom affiché

Les écrans exigent une horloge, un mécanisme de traduction et une façon de
mettre en forme et de nommer, sans quoi chaque composant ferait les siens.
horloge.js est le seul code qui lit l'heure et arme des minuteries, injecté
pour que les épreuves le pilotent. traduction.js range les textes par
domaine, en deux familles de tables, lève sur une clé absente et ne montre
jamais une clé brute ; libelles/ découpe la table des libellés par domaine.
format.js met en forme nombres et dates en français, et personnes.js
compose le nom affiché qu'emploient les libellés figés. Rien ne persiste
d'une séance à l'autre.
Vérifié : 1773 node et 43 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 02:33:39 -04:00

177 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la mise en forme (§ 5.4, § 5.10, § 11.7, § 18.4) : les valeurs
// du contrat, exactement ; « — » pour une valeur absente, jamais « 0 » ; un
// nombre non fini lève ; l'échelle des ordres de grandeur et ses frontières ;
// en langue témoin, un texte qui porte une lettre est encadré de la marque.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { creerFormat } from './format.js';
import { TABLES_APPLICATION } from './libelles.js';
import { MARQUE_TEMOIN, creerTraduction } from './traduction.js';
const { ouverture: O, fermeture: F } = MARQUE_TEMOIN;
const FINE = '\u{202F}';
const francais = creerTraduction(TABLES_APPLICATION);
const format = creerFormat(francais.t);
const temoin = creerFormat(creerTraduction(TABLES_APPLICATION, { langue: 'temoin' }).t);
describe('format : les nombres (§ 5.4, § 11.7)', () => {
test('les valeurs du contrat : « — » pour null, espace fine insécable entre les milliers, virgule décimale', () => {
assert.equal(format.nombre(null), '\u{2014}');
assert.equal(format.nombre(null), francais.t('format.vide'));
assert.equal(format.nombre(1234.5, { decimales: 1 }), `1${FINE}234,5`);
assert.equal(format.nombre(0), '0');
assert.notEqual(format.nombre(null), '0');
});
test("l'arrondi suit les décimales demandées ; les grands nombres se groupent par trois", () => {
assert.equal(format.nombre(2.25, { decimales: 0 }), '2');
assert.equal(format.nombre(2.75), '3');
assert.equal(format.nombre(999), '999');
assert.equal(format.nombre(1000), `1${FINE}000`);
assert.equal(format.nombre(1234567.891, { decimales: 2 }), `1${FINE}234${FINE}567,89`);
assert.equal(format.nombre(0.5, { decimales: 2 }), '0,50');
});
test('un négatif porte le signe moins typographique ; zéro négatif s’écrit 0', () => {
assert.equal(format.nombre(-1234), `\u{2212}1${FINE}234`);
assert.equal(format.nombre(-0), '0');
assert.equal(format.nombre(-0.04, { decimales: 1 }), '0,0');
});
test('Infinity et NaN lèvent ; une valeur qui n’est pas un nombre lève ; des décimales hors de 0 à 20 lèvent', () => {
for (const valeur of [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NaN]) {
assert.throws(() => format.nombre(valeur), RangeError, String(valeur));
}
for (const valeur of [undefined, '12', 12n]) assert.throws(() => format.nombre(valeur), TypeError, String(valeur));
for (const decimales of [-1, 1.5, 21]) assert.throws(() => format.nombre(1, { decimales }), RangeError, String(decimales));
});
test("en langue témoin, la valeur absente est encadrée ; un nombre, qui ne porte aucune lettre, ne l'est pas", () => {
assert.equal(temoin.nombre(null), `${O}\u{2014}${F}`);
assert.equal(temoin.nombre(1234.5, { decimales: 1 }), `1${FINE}234,5`);
});
});
describe('format : les durées (§ 5.10)', () => {
test('les valeurs du contrat', () => {
assert.equal(format.duree(1800), '1,8 s');
assert.equal(format.duree(150000), '2 min 30 s');
});
test("sous la minute au dixième de seconde, puis minutes et secondes, puis heures et minutes ; une part nulle se tait", () => {
assert.equal(format.duree(0), '0 s');
assert.equal(format.duree(12000), '12 s');
assert.equal(format.duree(59940), '59,9 s');
assert.equal(format.duree(59960), '1 min');
assert.equal(format.duree(120000), '2 min');
assert.equal(format.duree(3599400), '59 min 59 s');
assert.equal(format.duree(3599600), '1 h');
assert.equal(format.duree(5400000), '1 h 30 min');
assert.equal(format.duree(36000000), '10 h');
});
test('une durée négative ou non finie lève', () => {
for (const ms of [-1, Number.NaN, Number.POSITIVE_INFINITY]) assert.throws(() => format.duree(ms), RangeError, String(ms));
assert.throws(() => format.duree(null), TypeError);
});
test('en langue témoin, une durée est encadrée de la marque, une fois', () => {
assert.equal(temoin.duree(1800), `${O}1,8 s${F}`);
assert.equal(temoin.duree(150000), `${O}2 min 30 s${F}`);
});
});
describe("format : l'ordre de grandeur d'une durée (§ 5.10)", () => {
const ATTENDUS = [
[30, "moins d'une minute"],
[59, "moins d'une minute"],
[60, 'environ deux minutes'],
[848, 'environ dix minutes'],
[849, 'environ vingt minutes'],
[1200, 'environ vingt minutes'],
[1900, 'environ une demi-heure'],
[5399, 'environ une heure'],
[5400, 'plusieurs heures'],
[7200, 'plusieurs heures'],
];
test("les valeurs du contrat, frontières comprises", () => {
assert.equal(ATTENDUS.length, 10);
for (const [secondes, texte] of ATTENDUS) assert.equal(format.ordreDeGrandeur(secondes), texte, String(secondes));
assert.equal(format.ordreDeGrandeur(1200), francais.t('format.duree.vingtMinutes'));
assert.equal(francais.t('format.duree.vingtMinutes'), 'environ vingt minutes');
});
test('chaque pas a sa frontière en √(a·b) : le dernier entier de chaque pas, puis le premier du suivant', () => {
// Les pas en secondes, et le premier entier au-delà de chaque frontière.
const pas = [120, 300, 600, 1200, 1800, 3600];
const textes = pas.map((s) => format.ordreDeGrandeur(s));
assert.equal(new Set(textes).size, 6);
for (let i = 0; i + 1 < pas.length; i += 1) {
const produit = pas[i] * pas[i + 1];
let premier = Math.ceil(Math.sqrt(produit));
while (premier * premier < produit) premier += 1;
while ((premier - 1) * (premier - 1) >= produit) premier -= 1;
assert.equal(format.ordreDeGrandeur(premier - 1), textes[i], `${premier - 1} s`);
assert.equal(format.ordreDeGrandeur(premier), textes[i + 1], `${premier} s`);
}
});
test('-1, NaN et Infinity lèvent', () => {
for (const secondes of [-1, Number.NaN, Number.POSITIVE_INFINITY]) {
assert.throws(() => format.ordreDeGrandeur(secondes), RangeError, String(secondes));
}
});
test('en langue témoin, un ordre de grandeur est encadré de la marque', () => {
assert.equal(temoin.ordreDeGrandeur(1200), `${O}environ vingt minutes${F}`);
});
});
describe('format : les dates et les horodatages (§ 18.4)', () => {
test('les valeurs du contrat : la date en lettres, « 1er » pour le premier du mois', () => {
assert.equal(format.date('2026-10-06'), '6 octobre 2026');
assert.equal(format.date('2026-10-01'), '1er octobre 2026');
assert.equal(format.horodatage('2026-10-06T19:02:11-04:00'), '6 octobre 2026, 19 h 02');
});
test("chaque mois a son nom ; l'heure du matin s'écrit sans zéro de tête, à l'heure que l'horodatage porte", () => {
const mois = Array.from({ length: 12 }, (_, i) => format.date(`2031-${String(i + 1).padStart(2, '0')}-17`));
assert.equal(mois.length, 12);
assert.deepEqual(mois.map((texte) => texte.split(' ')[1]), [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
]);
assert.equal(format.horodatage('2031-05-01T08:05:00+02:00'), '1er mai 2031, 8 h 05');
});
test("une date ou un horodatage hors de leur forme, ou hors du calendrier, lèvent", () => {
for (const date of ['2026-10-6', '06/10/2026', '2026-13-01', '2026-02-29', '2026-04-31', '2026-10-00', null]) {
assert.throws(() => format.date(date), RangeError, String(date));
}
assert.equal(format.date('2028-02-29'), '29 février 2028');
for (const horodatage of ['2026-10-06T19:02:11', '2026-10-06T24:00:00-04:00', '2026-10-06T19:60:00-04:00', '2026-10-06']) {
assert.throws(() => format.horodatage(horodatage), RangeError, horodatage);
}
});
test('en langue témoin, une date et un horodatage sont encadrés de la marque, une fois', () => {
assert.equal(temoin.date('2026-10-01'), `${O}1er octobre 2026${F}`);
assert.equal(temoin.horodatage('2026-10-06T19:02:11-04:00'), `${O}6 octobre 2026, 19 h 02${F}`);
});
});
describe('format : rien ne vient de la locale du système', () => {
test("les textes qui portent une lettre viennent des clés format.… de la table", () => {
const cles = francais.cles().filter((cle) => cle.startsWith('format.'));
assert.ok(cles.length > 0, 'aucune clé format.');
for (const cle of ['format.vide', 'format.duree.vingtMinutes']) assert.ok(cles.includes(cle), cle);
// Une table substituée change le texte rendu : il ne vient que d'elle.
const autre = creerFormat((cle, details) => (cle === 'format.vide' ? 'néant' : francais.t(cle, details)));
assert.equal(autre.nombre(null), 'néant');
});
});