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.
181 lines
7.7 KiB
JavaScript
181 lines
7.7 KiB
JavaScript
/**
|
||
* 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 });
|
||
});
|
||
});
|