HormoneTrack-web/tests/backup.test.js
Siphonight 902d521094 v1.13.0 (web) : reco prédictive — valeur E2 au creux, régime poolé, cible de creux — miroir Android
- predictedE2 : valeur brute au creux × facteur du ester (auto-calibration
  ON requise — sinon rien, une valeur brute serait trompeuse) ; rendu
  coloré face à la cible dans labs.js + home.js.
- pooledRegimeDoses : les doses de tous les traitements partageant (ester
  effectif, mg) forment UNE séquence — le re-parenting d'historique
  (« 6d-old ») ne redémarre plus la stabilisation. Garde-fous : ester ≠
  jamais poolé, dose ≠ exclue.
- Cible de creux E2 (opt-in) : store + carte settings + backup
  (rétrocompatible — un backup ancien n'efface pas la cible locale) +
  i18n FR/EN. ⚠️ DISTINCT des seuils d'alerte : jamais de notification.
- 15 tests (pooling ×3, statut cible, round-trip backup) ; leçon E2E :
  bump WEB_VERSION sans section CHANGELOG = dialog vide.

199 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.13.0.
2026-10-07 09:58:52 +02:00

180 lines
8.0 KiB
JavaScript

/**
* Tests du format de backup (miroir de BackupGsonTest.kt + régressions sur
* les exports Android réels) :
*
* - round-trip build → parse (compatibilité exacte du schéma v2) ;
* - rétrocompat v1 (sans settings) ;
* - import ÉCRASEMENT (IDs conservés, ordre enfants → parents) ;
* - **les exports Android réels s'importent dans le web** (test data-driven
* sur local-test-data/ si présent — mêmes fichiers que les régressions
* Android, hors dépôt) ;
* - noms de fichiers d'export.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import {
WEB_ROOT, localTestDataDir, makeTreatment, makeDose, makeLab, HOUR_MS,
} from './helpers.js';
import { buildBackupJson, parseBackupJson, backupFileName, diagnosticLogFileName } from '../js/data/backup.js';
import { store } from '../js/data/store.js';
/** Backend Map isolé par test (localStorage indisponible en Node). */
function freshStore() {
store.setBackend(new Map());
return store;
}
describe('format BackupData v2 (compatibilité Android)', () => {
test('build → parse round-trip : tous les champs survivent', () => {
const treatments = [makeTreatment({ id: 3, scaleFactor: 0.72, forecastIntervalDays: 7, reminderHour: 18, reminderMinute: 30, reminderEnabled: true })];
const doseLogs = [makeDose(1000, 5, { id: 7, notes: 'cuisse L', esterType: 'EV' })];
const labResults = [makeLab('T', 44, 'ng/dL', 2000, { id: 9, notes: 'jeûne' })];
const tConfig = { base: 6.0, floor: 0.2, k: 0.21 };
const settings = {
language: 'fr', autoCalibrate: true,
alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null,
};
const json = buildBackupJson({ treatments, doseLogs, labResults }, tConfig, settings);
const parsed = parseBackupJson(json);
assert.equal(parsed.version, 2);
assert.equal(parsed.treatments.length, 1);
assert.equal(parsed.doseLogs.length, 1);
assert.equal(parsed.labResults.length, 1);
assert.deepEqual(parsed.tConfig, tConfig);
// champs plats UserSettings exactement comme Gson
// (v1.13.0 : la cible de creux voyage aussi — champs optionnels)
assert.deepEqual(parsed.settings, {
language: 'fr', autoCalibrate: true,
alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null,
troughTargetLow: null, troughTargetHigh: null,
});
// fidélité des entités (champs Gson/Kotlin)
assert.equal(parsed.treatments[0].calendarEventId, null);
assert.equal(parsed.treatments[0].pkModel, 'TFS');
assert.equal(parsed.doseLogs[0].esterType, 'EV');
assert.equal(parsed.labResults[0].unit, 'ng/dL');
});
test('rétrocompat v1 : backup SANS settings → settings null, importable', () => {
// Structure d'un vrai export v1 (avant la v1.4.2 Android)
const v1 = JSON.stringify({
version: 1,
exportedAt: 1700000000000,
treatments: [makeTreatment({ id: 1 })],
doseLogs: [makeDose(0, 5)],
labResults: [],
tConfig: { base: 6.0, floor: 0.2, k: 0.19 },
});
const parsed = parseBackupJson(v1);
assert.equal(parsed.version, 1);
assert.equal(parsed.settings, null);
assert.equal(parsed.treatments.length, 1);
});
test('backup tronqué/tordu : défauts TConfig, listes manquantes → vides', () => {
const weird = parseBackupJson('{"treatments": null}');
assert.deepEqual(weird.treatments, []);
assert.equal(weird.tConfig.base, 6.0, 'défaut TConfig.kt si absent');
});
test('non-JSON et non-backup → erreur explicite (pas de crash silencieux)', () => {
assert.throws(() => parseBackupJson('not json at all'), /JSON illisible/);
assert.throws(() => parseBackupJson('{"foo": 1}'), /backup/);
});
test('les clés web extra (chartTimezone…) ne fuient PAS dans l\'export', () => {
const json = buildBackupJson(
{ treatments: [], doseLogs: [], labResults: [] },
{ base: 6, floor: 0.2, k: 0.19 },
{ language: 'fr', autoCalibrate: false, chartTimezone: 'Europe/Paris', alertNotifiedState: 'E2:HIGH' },
);
assert.ok(!json.includes('chartTimezone'), 'clé web exclue');
assert.ok(!json.includes('alertNotifiedState'), 'clé web exclue');
assert.ok(!json.includes('changelogSeenVersion'), 'volontairement exclu (miroir Android)');
});
});
describe('import ÉCRASEMENT (v1.2.6)', () => {
test('données actuelles effacées, IDs du backup conservés, compteur recalculé', () => {
const s = freshStore();
// État initial : un traitement id 5 + une dose id 2 rattachée
s.upsertTreatment(makeTreatment({ id: 5, name: 'ancien' }));
s.upsertDoseLog(makeDose(0, 1, { id: 2, treatmentId: 5 }));
const backup = parseBackupJson(buildBackupJson({
treatments: [makeTreatment({ id: 11, name: 'nouveau' })],
doseLogs: [makeDose(100, 5, { id: 21, treatmentId: 11 })],
labResults: [makeLab('E2', 300, 'pg/mL', 150, { id: 31 })],
}, { base: 6, floor: 0.2, k: 0.19 }, null));
const result = s.importOverwrite(backup);
assert.deepEqual(result, { treatments: 1, doseLogs: 1, labResults: 1 });
// IDs du backup conservés (FK dose→traitement valides)
assert.deepEqual(s.getTreatments().map((t) => t.id), [11]);
assert.equal(s.getDoseLogs()[0].treatmentId, 11);
assert.equal(s.getLabResults()[0].id, 31);
// le prochain id auto-increment ne rentre PAS en conflit avec les ids importés
const nextId = s.upsertTreatment(makeTreatment({ name: 'post-import' }));
assert.ok(nextId > 11, `prochain id = ${nextId} > max importé`);
});
test('import d\'un backup v1 : les réglages ne sont PAS touchés', () => {
const s = freshStore();
s.setSettings({ autoCalibrate: true, alertE2High: 400 });
const backup = parseBackupJson(buildBackupJson(
{ treatments: [], doseLogs: [], labResults: [] },
{ base: 5, floor: 0.1, k: 0.2 },
null, // v1 : pas de settings
));
s.importOverwrite(backup);
s.setTConfig(backup.tConfig);
assert.equal(s.getSettings().autoCalibrate, true, 'réglages intacts (null = ne rien toucher)');
assert.equal(s.getTConfig().base, 5, 'tConfig du backup restauré');
});
});
describe('exports Android RÉELS → import web (données hors dépôt)', () => {
// Le dossier vit dans le dépôt Android voisin (dépôts séparés depuis
// v1.4.10) — cf helpers.localTestDataDir() : null → les tests skip
// (clone neuf / CI), comme les Assume Kotlin côté Android.
const dir = localTestDataDir();
const backups = ['backup-v1.0.0.json', 'backup-v1.2.0.json', 'backup-v1.3.1.json', 'backup-v1.4.2.json'];
for (const name of backups) {
const path = dir ? join(dir, name) : null;
// Skip si absent (clone neuf / CI) — même comportement Assume côté Android
if (!path || !existsSync(path)) {
test.skip(`${name} : import web (données locales absentes)`, () => {});
continue;
}
test(`${name} : parse + import + calibration non vide`, () => {
const s = freshStore();
const parsed = parseBackupJson(readFileSync(path, 'utf8'));
const result = s.importOverwrite(parsed);
assert.ok(result.treatments > 0, `${result.treatments} traitements`);
assert.ok(result.doseLogs > 0, `${result.doseLogs} doses`);
assert.ok(result.labResults > 0, `${result.labResults} analyses`);
// FK intactes : chaque dose pointe vers un traitement existant
const ids = new Set(s.getTreatments().map((t) => t.id));
for (const d of s.getDoseLogs()) {
assert.ok(ids.has(d.treatmentId), 'FK dose → traitement');
}
});
}
});
describe('noms de fichiers d\'export (miroir ExportFileNames)', () => {
test('backup : hormonetrack-backup-YYYYMMDD.json', () => {
assert.equal(backupFileName(new Date(2026, 8, 8)), 'hormonetrack-backup-20260908.json');
});
test('logs : hormonetrack-logs-YYYYMMDD-HHmm.txt (pattern AVEC heure, bug #48)', () => {
assert.equal(diagnosticLogFileName(new Date(2026, 8, 8, 9, 5)), 'hormonetrack-logs-20260908-0905.txt');
});
});