- helper PUR nextDoseLineText dans format.js (miroir du Kotlin nextDoseLine) : « Xj Yh · sam. 26 10:00 (nom) » > 24 h, « XhMM » sinon, « N min » dans l'heure - home.js : une ligne par traitement, triées par prochaine prise, 1ʳᵉ en avant, titre pluriel « Prochaines doses » dès 2 lignes (i18n EN/FR) - 3 tests miroirs dans misc.test.js (178 verts + E2E) - WEB_VERSION 1.9.6 + CHANGELOG/README
242 lines
11 KiB
JavaScript
242 lines
11 KiB
JavaScript
/**
|
||
* 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)`);
|
||
});
|
||
});
|