HormoneTrack-web/tests/misc.test.js
Siphonight 048a76e540 v1.9.6 (web) : la carte Accueil liste TOUTES les prochaines doses (triées) — miroir Android
- 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
2026-09-21 21:57:04 +02:00

242 lines
11 KiB
JavaScript
Raw Permalink 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 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)`);
});
});