/** * 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 }); }); });