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