HormoneTrack-web/tests/chart-helpers.test.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

181 lines
7.7 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.

/**
* Tests des helpers du graphique (miroir de ChartZoomTest.kt) :
* pan fractionnaire (#62), niceCeil, stepForRange, ticks minuit local (#55),
* pointHoursBefore (#53), clamps de pan (#54), horizon/extension de prévision,
* toggles par défaut (#58).
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
panDeltaHours, niceCeil, stepForRange, xLabelTicks, clampPanHours,
pointHoursBefore, forecastHorizonHours, forecastExtensionHours,
defaultModelToggles,
} from '../js/pk/chart-helpers.js';
import { LevelPoint } from '../js/pk/pk-engine.js';
import { HOUR_MS, DAY_MS } from './helpers.js';
describe('panDeltaHours — fix #62 (pan mort sur 24 h)', () => {
test('le résiduel fractionnaire s\'accumule jusqu\'à franchir une heure', () => {
// Vue 24 h, canvas 900 px : 30 px ≈ 0,8 h — chaque delta seul serait 0
let residual = 0;
let applied = 0;
for (let i = 0; i < 3; i++) {
const r = panDeltaHours(30, 900, 24, residual);
applied += r.hours;
residual = r.residual;
}
assert.ok(applied >= 1, `3 deltas de 0,8 h doivent appliquer ≥ 1 h (obtenu ${applied})`);
assert.equal(applied, 2, '0,8 + 0,8 = 1,6 → 1 appliqué, 0,8 → 2 au total');
});
test('deltas négatifs : troncature vers zéro, résiduel conservé', () => {
let r = panDeltaHours(-30, 900, 24, 0);
assert.equal(r.hours, 0); // -0.8 → 0 (vers zéro)
assert.ok(r.residual < 0);
r = panDeltaHours(-30, 900, 24, r.residual);
assert.equal(r.hours, -1, '-0.8 + -0.8 = -1.6 → -1');
});
test('canvas de largeur nulle → pas d\'application, résiduel intact', () => {
assert.deepEqual(panDeltaHours(50, 0, 24, 0.5), { hours: 0, residual: 0.5 });
});
test('grande plage (7 j) : un delta de 30 px ≈ 5,6 h appliqué immédiatement', () => {
assert.equal(panDeltaHours(30, 900, 24 * 7, 0).hours, 5);
});
});
describe('niceCeil / stepForRange', () => {
test('bornes propres 1/2/2.5/5/10 × 10ⁿ', () => {
assert.equal(niceCeil(370), 500);
assert.equal(niceCeil(250), 250);
assert.equal(niceCeil(240), 250);
assert.equal(niceCeil(101), 200);
assert.equal(niceCeil(99), 100);
assert.equal(niceCeil(5), 5);
assert.equal(niceCeil(0), 100, 'valeur nulle → borne par défaut');
assert.equal(niceCeil(-3), 100);
});
test('échantillonnage adaptatif (v1.2.9)', () => {
assert.equal(stepForRange(6), 900000, '≤ 12 h → 15 min');
assert.equal(stepForRange(12), 900000);
assert.equal(stepForRange(13), 1800000, '≤ 24 h → 30 min');
assert.equal(stepForRange(24), 1800000);
assert.equal(stepForRange(24 * 7), 3600000, 'sinon 1 h');
});
});
describe('xLabelTicks — fix #55 (labels à minuit LOCAL)', () => {
test('pas 24 h : ticks alignés sur minuit LOCAL (Europe/Paris)', () => {
// Choisis un t0 au milieu d'une journée : le 1er tick doit être un minuit
// de Paris (01:00/00:00 UTC selon DST)
const t0 = Date.UTC(2026, 8, 10, 14, 30); // 10 sept 2026 14:30 UTC
const t1 = t0 + 10 * DAY_MS;
const ticks = xLabelTicks(t0, t1, 24, 'Europe/Paris');
assert.ok(ticks.length >= 9);
for (const tick of ticks) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(new Date(tick));
const hour = parts.find((p) => p.type === 'hour').value;
const minute = parts.find((p) => p.type === 'minute').value;
assert.equal(hour, '00', `tick ${new Date(tick).toISOString()} doit être minuit Paris`);
assert.equal(minute, '00');
}
});
test('pas horaire : ticks sur des heures rondes LOCALES', () => {
const t0 = Date.UTC(2026, 8, 10, 14, 30);
const t1 = t0 + 8 * HOUR_MS;
const ticks = xLabelTicks(t0, t1, 1, 'Europe/Paris');
for (const tick of ticks) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(new Date(tick));
assert.equal(Number(parts.find((p) => p.type === 'minute').value), 0);
}
});
test('pas 5 jours : ticks espacés de 5 j calendaires (DST-safe)', () => {
const t0 = Date.UTC(2026, 2, 25, 12); // traverse le DST européen fin mars
const t1 = t0 + 30 * DAY_MS;
const ticks = xLabelTicks(t0, t1, 120, 'Europe/Paris');
assert.ok(ticks.length >= 4);
for (let i = 1; i < ticks.length; i++) {
const deltaDays = (ticks[i] - ticks[i - 1]) / DAY_MS;
assert.ok(deltaDays >= 4.9 && deltaDays <= 5.1, `écart ≈ 5 j (obtenu ${deltaDays})`);
}
});
test('fenêtre vide / pas ≤ 0 → vide', () => {
assert.deepEqual(xLabelTicks(100, 100, 24), []);
assert.deepEqual(xLabelTicks(200, 100, 24), []);
assert.deepEqual(xLabelTicks(0, 100, 0), []);
});
test('fuseau par défaut = fuseau du système (pas de crash)', () => {
assert.ok(xLabelTicks(0, 10 * DAY_MS, 24).length > 0);
});
});
describe('clampPanHours / horizon de prévision (#54/#61)', () => {
test('clamp : passé borné par maxPast, futur seulement si prévision active', () => {
assert.equal(clampPanHours(50, 0, 100), 50);
assert.equal(clampPanHours(150, 0, 100), 100, 'passé clampé');
assert.equal(clampPanHours(-5, 0, 100), 0, 'futur interdit sans prévision');
assert.equal(clampPanHours(-5, 48, 100), -5, 'futur autorisé avec prévision');
assert.equal(clampPanHours(-100, 48, 100), -48, 'futur clampé à l\'horizon');
});
test('forecastHorizonHours : 12 × Posologie borné [30 j ; 1 an]', () => {
assert.equal(forecastHorizonHours(7), 12 * 7 * 24 / 24 * 24); // 7 j × 12 = 84 j → non borné
assert.equal(forecastHorizonHours(7), 7 * 24 * 12);
assert.equal(forecastHorizonHours(1), 24 * 30, 'borné bas à 30 j');
assert.equal(forecastHorizonHours(60), 24 * 365, 'borné haut à 1 an');
assert.equal(forecastHorizonHours(null), null);
assert.equal(forecastHorizonHours(0), null);
});
test('forecastExtensionHours : au moins la prochaine dose (+12 h), sinon 1/3 de plage', () => {
assert.equal(forecastExtensionHours(24, 84 * 24, 48), 60, '48 + 12 = 60 h');
assert.equal(forecastExtensionHours(720, 84 * 24, null), 240, '1/3 de la plage');
assert.equal(forecastExtensionHours(24, 0, 48), 0, 'horizon 0 → rien');
assert.equal(forecastExtensionHours(24, null, 48), 0, 'pas de prévision → rien');
});
});
describe('pointHoursBefore — fix #53 (delta « 6 h » qui comparait 24 h)', () => {
const curve = [];
for (let h = 0; h <= 24; h++) {
curve.push(new LevelPoint(h * HOUR_MS, h, 1));
}
test('le point le plus PROCHE de −6 h (par excès)', () => {
const p = pointHoursBefore(curve, 6);
assert.equal((curve[24].timestamp - p.timestamp) / HOUR_MS, 6);
});
test('retourne null si aucun point au-delà de N heures (courbe trop courte)', () => {
const short = [new LevelPoint(0, 1, 1), new LevelPoint(2 * HOUR_MS, 2, 1)];
assert.equal(pointHoursBefore(short, 6), null);
assert.equal(pointHoursBefore([], 6), null);
});
});
describe('defaultModelToggles — fix #58 (toggles alignés sur les traitements)', () => {
test('seuls les modèles utilisés sont ON (EV TFS + EEn WHSAH → ESE off)', () => {
const toggles = defaultModelToggles(new Set(['TFS', 'WHS']));
assert.deepEqual(toggles, { ESE: false, TFS: true, WHS: true });
});
test('garde-fou : aucun profil → TFS forcé ON (Bateman tracé dans chaque série)', () => {
assert.deepEqual(defaultModelToggles(new Set()), { ESE: false, TFS: true, WHS: false });
});
test('un seul modèle utilisé → les autres restent off', () => {
assert.deepEqual(defaultModelToggles(new Set(['ESE'])), { ESE: true, TFS: false, WHS: false });
});
});