/** * Tests rappels (grille Posologie, miroir ReminderScheduleTest.kt — fix #52) * + extrema (miroir ExtremaTest.kt) + changelog (miroir ChangelogHelperTest.kt) * + HrtDuration (miroir HrtDurationTest.kt). * * ⚠️ Les tests de rappel sont indépendants du fuseau : ils construisent les * heures attendues avec LES MÊMES primitives (Date locale) que le module * testé, sur des dates hors changement d'heure. */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { nextReminderFireFor, nextReminderFireMs } from '../js/pk/pk-reminders.js'; import { detectExtrema } from '../js/pk/pk-extrema.js'; import { LevelPoint } from '../js/pk/pk-engine.js'; import { isVersionNewer, sectionsSince } from '../js/util/changelog.js'; import { HrtDuration, nextDoseLineText } from '../js/util/format.js'; import { makeTreatment, makeDose, DAY_MS, HOUR_MS, assertClose } from './helpers.js'; /** Met (h, m) sur le jour calendaire de [ms] (locale — miroir du module testé). */ function atTimeSameDay(ms, h, m) { const d = new Date(ms); d.setHours(h, m, 0, 0); return d.getTime(); } describe('rappels — grille Posologie (fix #52)', () => { test('dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l\'heure choisie (PAS demain)', () => { const now = Date.now(); const tr = makeTreatment({ reminderEnabled: true, reminderHour: 18, reminderMinute: 0, forecastIntervalDays: 7 }); const fire = nextReminderFireFor(tr, [makeDose(now - 2 * DAY_MS, 5)], now); // prochaine occurrence de la grille : J+5 à 18:00 const expected = atTimeSameDay(now + 5 * DAY_MS, 18, 0); assert.equal(fire, expected, `attendu ${new Date(expected)}, obtenu ${new Date(fire)}`); }); test('heure de rappel déjà passée le jour du créneau → créneau suivant', () => { const now = Date.now(); const hour = new Date(now).getHours(); const tr = makeTreatment({ reminderEnabled: true, reminderHour: hour, reminderMinute: 0, forecastIntervalDays: 7, }); // créneau théorique (dernière dose + 7 j) = il y a 2 h → son HH:00 est passé const dose = makeDose(now - 7 * DAY_MS - 2 * HOUR_MS, 5); const slot = dose.timestamp + 7 * DAY_MS; const fire = nextReminderFireFor(tr, [dose], now); assert.ok(fire > now, 'jamais dans le passé'); const fired = new Date(fire); assert.equal(fired.getHours(), hour, 'à l\'heure de rappel choisie'); assert.equal(fired.getMinutes(), 0); // fire est un créneau de la grille (k × 7 j après le créneau initial, // ± le recadrage HH:mm — le snap à l'heure fait varier l'écart de < 24 h) const k = Math.round((fire - slot) / (7 * DAY_MS)); assert.ok(k >= 1, `créneau suivant (k=${k})`); assert.ok(Math.abs(fire - slot - k * 7 * DAY_MS) < DAY_MS, 'aligné sur la grille (± recadrage horaire)'); // le créneau SAUTÉ (k-1) était bien passé à l'heure de rappel assert.ok(atTimeSameDay(slot + (k - 1) * 7 * DAY_MS, hour, 0) <= now); }); test('fallback QUOTIDIEN sans Posologie (gel/oral) : prochaine occurrence HH:mm', () => { const now = Date.now(); const tr = makeTreatment({ reminderEnabled: true, reminderHour: 9, reminderMinute: 30, forecastIntervalDays: null }); const fire = nextReminderFireFor(tr, [], now); const todayAt = atTimeSameDay(now, 9, 30); const expected = todayAt > now ? todayAt : todayAt + DAY_MS; assert.equal(fire, expected); }); test('créneau manqué sauté : jamais dans le passé (oubli d\'injection)', () => { const now = Date.now(); const tr = makeTreatment({ reminderEnabled: true, reminderHour: 18, reminderMinute: 0, forecastIntervalDays: 7 }); // dernière dose il y a 10 jours : créneaux J-3 et J+4 → J+4 const fire = nextReminderFireFor(tr, [makeDose(now - 10 * DAY_MS, 5)], now); assert.ok(fire > now, 'jamais dans le passé'); assert.equal(fire, atTimeSameDay(now + 4 * DAY_MS, 18, 0)); }); test('agrégat multi-traitements : le plus tôt gagne', () => { const now = Date.now(); const a = makeTreatment({ id: 1, reminderEnabled: true, reminderHour: 23, reminderMinute: 0, forecastIntervalDays: 7, }); const b = makeTreatment({ id: 2, reminderEnabled: true, reminderHour: 8, reminderMinute: 0, forecastIntervalDays: 3, }); const next = nextReminderFireMs([a, b], [makeDose(now - 2 * DAY_MS, 1, { treatmentId: 1 }), makeDose(now - 2 * DAY_MS, 1, { treatmentId: 2 })], now); assert.equal(next, Math.min( nextReminderFireFor(a, [makeDose(now - 2 * DAY_MS, 1, { treatmentId: 1 })], now), nextReminderFireFor(b, [makeDose(now - 2 * DAY_MS, 1, { treatmentId: 2 })], now), )); }); test('traitements inactifs / rappel désactivé / heure absente → null', () => { const now = Date.now(); assert.equal(nextReminderFireFor(makeTreatment({ reminderEnabled: false }), [], now), null); assert.equal(nextReminderFireFor(makeTreatment({ reminderEnabled: true, isActive: false, reminderHour: 8, reminderMinute: 0 }), [], now), null); assert.equal(nextReminderFireFor(makeTreatment({ reminderEnabled: true, reminderHour: null, reminderMinute: null }), [], now), null); }); }); describe('detectExtrema (pics/creux du graphique)', () => { test('alternance stricte pic/creux en régime d\'équilibre (4 doses hebdo)', () => { // Superposition de 4 doses hebdo EV TFS : oscillation ≥ 4 extrema const points = []; for (let h = 0; h <= 4 * 7 * 24; h += 6) { let e2 = 0; for (let k = 0; k < 4; k++) { const dtH = h - k * 7 * 24; if (dtH > 0) { // profil EV TFS approx (pic 59 @ 51 h, t½ 3 j) — suffisant pour l'oscillation const t = dtH / 24; e2 += 5 * 59 * Math.exp(-(Math.abs(Math.log((t + 0.3) / 2.4)) ** 2) * 0.5); } } points.push(new LevelPoint(h * HOUR_MS, e2, 0.5)); } const extrema = detectExtrema(points, (p) => p.e2, 2.0); assert.ok(extrema.length >= 4, `≥ 4 extrema (obtenu ${extrema.length})`); for (let i = 1; i < extrema.length; i++) { assert.notEqual(extrema[i].isPeak, extrema[i - 1].isPeak, 'alternance stricte'); } // un pic est au-dessus de ses voisins (dans la liste alternée) const peak = extrema.find((e) => e.isPeak); const trough = extrema.find((e) => !e.isPeak); assert.ok(peak.value > trough.value); }); test('courbe monotone → vide ; série plate → vide ; trop courte → vide', () => { const mono = [new LevelPoint(0, 1, 0), new LevelPoint(1, 2, 0), new LevelPoint(2, 3, 0), new LevelPoint(3, 4, 0)]; assert.deepEqual(detectExtrema(mono, (p) => p.e2), []); const flat = [new LevelPoint(0, 5, 0), new LevelPoint(1, 5, 0), new LevelPoint(2, 5, 0), new LevelPoint(3, 5, 0)]; assert.deepEqual(detectExtrema(flat, (p) => p.e2), []); assert.deepEqual(detectExtrema([new LevelPoint(0, 1, 0)], (p) => p.e2), []); }); test('filtre d\'amplitude : une oscillation sous le seuil produit UN pivot', () => { const tiny = [ new LevelPoint(0, 100, 0), new LevelPoint(1, 100.5, 0), // pic local < minSwing 2 new LevelPoint(2, 100, 0), new LevelPoint(3, 100.5, 0), new LevelPoint(4, 100, 0), ]; const filtered = detectExtrema(tiny, (p) => p.e2, 2.0); assert.ok(filtered.length <= 1, 'l\'alternance complète est filtrée'); const kept = detectExtrema(tiny, (p) => p.e2, 0.1); assert.ok(kept.length >= 2, 'avec un seuil bas, l\'alternance apparaît'); }); }); describe('ChangelogHelper (comparaison numérique — piège lexicographique #38)', () => { test('1.2.9 < 1.2.10 (la comparaison de chaînes serait fausse)', () => { assert.equal(isVersionNewer('1.2.10', '1.2.9'), true); assert.equal(isVersionNewer('1.2.9', '1.2.10'), false); assert.equal(isVersionNewer('1.4.2', '1.4.2'), false); assert.equal(isVersionNewer('0.2.0', '0.1.9'), true); assert.equal(isVersionNewer('1.0.0', null), true, 'première installation'); assert.equal(isVersionNewer('1.0.0', ''), true); assert.equal(isVersionNewer('1.1.0', 'bogus'), true, 'référence non parsable → true'); assert.equal(isVersionNewer('v1.1.0', '1.0.9'), true, 'préfixe v toléré'); }); test('sectionsSince : uniquement les versions plus récentes que la dernière vue', () => { const md = [ '## [0.3.0] — la plus récente', 'Corrige le pan.', '', '## [0.2.0] — milieu', 'Ajoute le zoom.', '', '## [0.1.0] — la plus ancienne', 'Première version.', ].join('\n'); // vue 0.1.0 → 0.2.0 + 0.3.0 const out = sectionsSince(md, '0.1.0', '0.3.0'); assert.ok(out.includes('[0.3.0]') === false && out.includes('Version 0.3.0'), 'titre réécrit'); assert.ok(out.includes('Corrige le pan.')); assert.ok(out.includes('Ajoute le zoom.')); assert.ok(!out.includes('Première version.'), 'version déjà vue exclue'); // première installation → section courante seule const first = sectionsSince(md, null, '0.3.0'); assert.ok(first.includes('Corrige le pan.')); assert.ok(!first.includes('Ajoute le zoom.')); // rien de nouveau assert.equal(sectionsSince(md, '0.3.0', '0.3.0'), ''); }); }); describe('HrtDuration (temps sous THS)', () => { test('daysSince : 0 si futur/invalide, sinon jours entiers', () => { assert.equal(HrtDuration.daysSince(10 * DAY_MS, 9 * DAY_MS), 0); assert.equal(HrtDuration.daysSince(10 * DAY_MS, 10 * DAY_MS + 3 * DAY_MS + 12 * HOUR_MS), 3); }); test('monthsAndDays : mois de 30 jours (estimation assumée)', () => { assert.deepEqual(HrtDuration.monthsAndDays(95), [3, 5]); assert.deepEqual(HrtDuration.monthsAndDays(0), [0, 0]); }); test('daysAndHours : carte « prochaine dose » au-delà de 24 h (v1.4.1)', () => { assert.deepEqual(HrtDuration.daysAndHours(122), [5, 2]); assert.deepEqual(HrtDuration.daysAndHours(24), [1, 0]); assert.deepEqual(HrtDuration.daysAndHours(5), [0, 5]); }); test('assertClose helper (sanity)', () => { assertClose(300, 295, 0.02); assert.throws(() => assertClose(300, 100, 0.02)); }); }); describe('nextDoseLineText (carte « Prochaines doses » — v1.9.6, miroir NextDoseLineTest.kt)', () => { // Hors DST : les heures attendues sont construites avec les MÊMES // primitives (Intl fuseau système) que le module testé. const now = new Date(2026, 8, 21, 12, 0, 0).getTime(); // lundi 21/09/2026 12:00 const timeOf = (h, m) => new Date(2026, 8, 21, h, m, 0).getTime(); test('au-delà de 24 h — jours + jour de semaine', () => { // fire = samedi 26/09 10:00 (118 h plus tard) const fire = now + 118 * HOUR_MS; const line = nextDoseLineText(now, fire, 'Injection EEn 5mg 7d'); assert.match(line, /^4j 22h · .+ \(Injection EEn 5mg 7d\)$/); // le jour de semaine est présent (le format Intl diffère selon la locale // système : on épingle la STRUCTURE, pas le libellé) assert.ok(!line.startsWith('118'), 'delta affiché en jours, pas en heures'); }); test('entre 1 h et 24 h — heures + minutes', () => { const fire = timeOf(14, 5); assert.equal(nextDoseLineText(now, fire, 'CPA'), `2h05 · 14:05 (CPA)`); }); test('moins d\'une heure — minutes', () => { const fire = timeOf(12, 7); assert.equal(nextDoseLineText(now, fire, 'CPA'), `7 min · 12:07 (CPA)`); }); });