Portage navigateur complet de l'app Android HormoneTrack v1.4.10 : - moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C, WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés - calibration par période d'ester et par modèle affiché (#60/#61) - UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire, prévision, pics/creux, fuseau configurable), rappels web (Notification API), seuils d'alerte, i18n FR/EN - sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1) - 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie - processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur, smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG) - dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions alignées sur l'Android porté, changelogs indépendants Non porté (impossible dans un navigateur, documenté §12) : agenda récurrent, notifications onglet fermé, montre.
178 lines
7.9 KiB
JavaScript
178 lines
7.9 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
|
|
assert.deepEqual(parsed.settings, {
|
|
language: 'fr', autoCalibrate: true,
|
|
alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: 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');
|
|
});
|
|
});
|