v1.9.4 (web) : presets ESE étendus à 6 esters + éditeur dynamique + portage v1.9.0 complété

- presets.js : EC huile (5 mg IM), EB (1 mg IM), EUCS suspension
  cristalline (10 mg SC) — 25 entrées, champs Bateman informatifs
- pk-engine.js : Esters.EUCS manquant (portage v1.9.0 incomplet — le
  preset référençait undefined)
- models.js : choicesForModel ESE = 6 esters (parité Android v1.9.0 ;
  était resté sur la couverture des tables ODS à 3) + KDoc à jour
- treatment-editor.js : liste des esters = choicesForModel(model) —
  rebascule sur EV si l'ester sort de la couverture au changement de
  modèle (garde anti 0 pg/mL) + re-render du formulaire
- tests/presets.test.js (nouveau, 6) : couverture 6 esters ESE, unicité,
  i18n EN/FR, TFS 7 / WHSAH 6 inchangés, choicesForModel aligné dispatchs
- versions 1.9.4 (settings + e2e) + CHANGELOG/§4/§8/README
This commit is contained in:
Siphonight 2026-09-19 23:29:40 +02:00
parent 624e047f39
commit 9af2587110
9 changed files with 142 additions and 19 deletions

View File

@ -17,11 +17,12 @@
> Fie-toi toujours à tes analyses de sang et aux consignes de ton > Fie-toi toujours à tes analyses de sang et aux consignes de ton
> endocrinologue. > endocrinologue.
- **Statut** : web v1.9.2 — parité fonctionnelle avec l'Android v1.9.2 - **Statut** : web v1.9.4 — parité fonctionnelle avec l'Android v1.9.4
(modèle Estrannaise analytique + nuage d'incertitude MCMC exclusif ESE ; (modèle Estrannaise analytique + nuage d'incertitude MCMC exclusif ESE ;
l'auto-backup journalier Android reste structurellement non porté, cf presets des 6 esters injectables ESE ; l'auto-backup journalier Android
§12 — l'export manuel couvre la donnée) · reste structurellement non porté, cf §12 — l'export manuel couvre la
**169 tests verts** · E2E navigateur ✅ · conteneur testé en donnée) ·
**175 tests verts** · E2E navigateur ✅ · conteneur testé en
configuration durcie ✅ · lint/i18n ✅ configuration durcie ✅ · lint/i18n ✅
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases) - **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)

View File

@ -5,6 +5,30 @@
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog > section la plus récente EN PREMIER ; ce fichier est lu par le dialog
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). > « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
## [1.9.4] — 2026-09-19
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables
- **Remontée** : depuis l'ajout du modèle source Estrannaise (v1.9.0), les
presets de création n'offraient toujours que EV/EU/EEn — « les presets
n'ont pas été mis à jour lors de l'ajout du modèle source pour ESE ».
- **Presets** : 3 presets ESE ajoutés (25 entrées, miroir de
`PKPresets.kt`) — EC huile (5 mg IM), EB (1 mg IM), EUCS undécylate
suspension cristalline (10 mg SC, ester exclusif à ce fit). Champs
Bateman informatifs = t½ terminales des fits (pas utilisés au calcul).
- **Éditeur** : la liste des esters suit la couverture RÉELLE du modèle
choisi (`choicesForModel`, parité Android v1.9.0) — ESE liste ses 6
esters injectables ; au changement de modèle, si l'ester courant sort de
la couverture (ex. EUCS + TFS), il rebascule sur EV (garde anti
« 0 pg/mL ») et le formulaire se re-rend.
- **Corrigé (portage v1.9.0 incomplet)** : `Esters.EUCS` manquait dans
`pk-engine.js` (le preset référençait `undefined`) et `choicesForModel`
(`js/data/models.js`) était resté sur la couverture des tables ODS
(3 esters) avec sa KDoc obsolète « produirait 0 pg/mL ».
- **Tests** : `tests/presets.test.js` (nouveau, 6 tests) — couverture des
6 esters ESE, unicité, clés i18n EN/FR, TFS (7) / WHSAH (6) inchangés,
`choicesForModel` aligné sur les dispatchs. Suite web : 175 tests.
## [1.9.3] — 2026-09-19 ## [1.9.3] — 2026-09-19
### Corrigé — le numéro de version annoncé ### Corrigé — le numéro de version annoncé

View File

@ -170,7 +170,8 @@ HormoneTrack-web/ (dépôt séparé)
│ │ ρ log-linéaire, fenêtre labs) │ │ ρ log-linéaire, fenêtre labs)
│ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état, │ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état,
│ │ │ shouldNotify (anti-spam) │ │ │ shouldNotify (anti-spam)
│ │ ├── presets.js PKPresets.kt (22 presets, clés i18n) │ │ ├── presets.js PKPresets.kt (25 presets — v1.9.4 :
│ │ │ les 6 esters injectables ESE)
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt : │ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
│ │ │ panDeltaHours (#62), xLabelTicks (#55), │ │ │ panDeltaHours (#62), xLabelTicks (#55),
│ │ │ pointHoursBefore (#53), clampPanHours (#54), │ │ │ pointHoursBefore (#53), clampPanHours (#54),
@ -251,6 +252,9 @@ HormoneTrack-web/ (dépôt séparé)
│ ├── rounding.test.js ← round2/round3 arrondis (v1.9.2 : │ ├── rounding.test.js ← round2/round3 arrondis (v1.9.2 :
│ │ harmonisation Android, fin de la │ │ harmonisation Android, fin de la
│ │ troncature historique) │ │ troncature historique)
│ ├── presets.test.js ← PKPresetsTest.kt (v1.9.4 : presets des
│ │ 6 esters ESE, unicité, i18n EN/FR,
│ │ choicesForModel aligné dispatchs)
│ ├── lab-timing.test.js ← LabTimingTest.kt (v1.8.0 : prochaine │ ├── lab-timing.test.js ← LabTimingTest.kt (v1.8.0 : prochaine
│ │ prise de sang recommandée — creux avant │ │ prise de sang recommandée — creux avant
│ │ créneau, saut de stabilisation, gardes) │ │ créneau, saut de stabilisation, gardes)
@ -456,6 +460,12 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
exclusivité ESE — TFS/oral hors nuage, gardes vides) + **round2/round3 exclusivité ESE — TFS/oral hors nuage, gardes vides) + **round2/round3
ARRONDIS** (v1.9.2 : fin de la troncature historique — harmonisation des ARRONDIS** (v1.9.2 : fin de la troncature historique — harmonisation des
niveaux web/Android, cf CHANGELOG Android 1.8.2) ; niveaux web/Android, cf CHANGELOG Android 1.8.2) ;
- **presets** : v1.9.4 (miroir `PKPresetsTest.kt`) — les presets ESE
couvrent les 6 esters du fit analytique (unicité, clés i18n EN/FR),
TFS (7) / WHSAH (6) inchangés, et **`choicesForModel` aligné sur les
dispatchs** (ESE 6 / TFS 7 explicite sans EUCS / WHS 6) — l'éditeur et
le dialog de dose ne peuvent proposer un ester que le moteur sait
tracer ;
- **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés, - **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés,
**imports des exports Android réels** (data-driven, hors dépôt). **imports des exports Android réels** (data-driven, hors dépôt).
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55), - **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55),

View File

@ -40,10 +40,10 @@ export const AdministrationRoute = {
/** /**
* Esters proposables dans l'override par dose pour un MODÈLE PK donné * Esters proposables dans l'override par dose pour un MODÈLE PK donné
* (miroir de `Esters.choicesForModel`, v1.4.6 — la liste suit la couverture * (miroir de `Esters.choicesForModel` — la liste suit la couverture RÉELLE
* du modèle : TFS = 7 esters ; WHSAH = 6 (PEP non couvert) ; Estrannaise * du modèle : TFS = 7 esters ; WHSAH = 6 (PEP non couvert) ; Estrannaise
* (tables ODS) = EV/EU/EEn seulement — un traitement ESE + EB produirait * ANALYTIQUE (v1.9.0) = 6 esters injectables, dont EUCS exclusif à ce fit —
* 0 pg/mL). * un ester hors liste produirait 0 pg/mL : aucun fallback silencieux).
* *
* @param {string} pkModel * @param {string} pkModel
* @returns {string[]} * @returns {string[]}
@ -51,6 +51,7 @@ export const AdministrationRoute = {
export function choicesForModel(pkModel) { export function choicesForModel(pkModel) {
if (pkModel === 'TFS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS', 'PEP']; if (pkModel === 'TFS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS', 'PEP'];
if (pkModel === 'WHS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS']; if (pkModel === 'WHS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS'];
if (pkModel === 'ESE') return ['EV', 'EU', 'EEN', 'EC', 'EB', 'EUCS']; // v1.9.4 : analytique (parité Android v1.9.0)
return ['EV', 'EU', 'EEN']; return ['EV', 'EU', 'EEN'];
} }

View File

@ -55,6 +55,7 @@ export class TConfig {
export const Esters = { export const Esters = {
NONE: 'NONE', EV: 'EV', EU: 'EU', EEN: 'EEN', NONE: 'NONE', EV: 'EV', EU: 'EU', EEN: 'EEN',
EB: 'EB', EC: 'EC', ECS: 'ECS', PEP: 'PEP', EB: 'EB', EC: 'EC', ECS: 'ECS', PEP: 'PEP',
EUCS: 'EUCS', // v1.9.4 : undécylate suspension cristalline (fit Estrannaise seulement)
}; };
/** Constantes de modèles PK (miroir de `PKModels.kt`). */ /** Constantes de modèles PK (miroir de `PKModels.kt`). */

View File

@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`). * (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
* v1.4.10 = portage initial de l'Android v1.4.10. * v1.4.10 = portage initial de l'Android v1.4.10.
*/ */
export const WEB_VERSION = '1.9.3'; export const WEB_VERSION = '1.9.4';
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
function parseNumOrNull(text) { function parseNumOrNull(text) {

View File

@ -2,8 +2,13 @@
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
* Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`). * Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`).
* *
* - presets (PKPresets, 22 entrées) pré-remplissent tout (création) ; * - presets (PKPresets, 25 entrées) pré-remplissent tout (création) —
* - champs conditionnels : ester + modèle PK si injection, Bateman sinon ; * v1.9.4 : le modèle ESE offre ses 6 esters injectables (EV/EU/EEn/EC/
* EB/EUCS), au même niveau que les presets TFS (7) et WHSAH (6) ;
* - champs conditionnels : ester + modèle PK si injection, Bateman sinon —
* v1.9.4 : la liste des esters suit la couverture RÉELLE du modèle
* choisi (choicesForModel) et rebascule sur EV si l'ester courant
* n'y est plus (garde anti « 0 pg/mL ») ;
* - carte Calibration : facteur manuel + bouton « Calibrer avec les * - carte Calibration : facteur manuel + bouton « Calibrer avec les
* analyses » (computeScaleFactor — médiane lab ÷ prédiction, garde #61) ; * analyses » (computeScaleFactor — médiane lab ÷ prédiction, garde #61) ;
* hint quand l'auto-calibration écrase le facteur à l'affichage (v1.4.9) ; * hint quand l'auto-calibration écrase le facteur à l'affichage (v1.4.9) ;
@ -22,7 +27,7 @@
*/ */
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey } from '../data/models.js'; import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey, choicesForModel } from '../data/models.js';
import { all as allPresets } from '../pk/presets.js'; import { all as allPresets } from '../pk/presets.js';
import { computeScaleFactor } from '../pk/pk-calibration.js'; import { computeScaleFactor } from '../pk/pk-calibration.js';
import { t } from '../util/i18n.js'; import { t } from '../util/i18n.js';
@ -124,14 +129,25 @@ export function renderTreatmentEditor(container, ctx) {
})); }));
if (isInjectionRoute(st.route)) { if (isInjectionRoute(st.route)) {
// Ester : liste simple (miroir Android — EV/EU/EEn dans l'éditeur ; // Ester : liste = couverture RÉELLE du modèle choisi (v1.9.4 —
// les 7 esters sont proposables dans l'OVERRIDE par dose du DoseDialog) // miroir Android Esters.choicesForModel) : ESE offre ses 6 esters
const esterOptions = ['EV', 'EU', 'EEN'].map((x) => ({ value: x, label: x })); // injectables depuis l'éditeur, au lieu des seuls EV/EU/EEn de
// l'époque des tables ODS. L'override par dose du dialog garde le
// même helper (pas de divergence des deux listes).
const esterOptions = choicesForModel(st.model).map((x) => ({ value: x, label: x }));
form.appendChild(dropdown(t('ester'), st.ester, esterOptions, (v) => { st.ester = v; })); form.appendChild(dropdown(t('ester'), st.ester, esterOptions, (v) => { st.ester = v; }));
// Modèle PK : 3 choix (v1.4.6 — WHSAH ajouté) // Modèle PK : 3 choix (v1.4.6 — WHSAH ajouté). Au changement de
// modèle, l'ester courant peut sortir de la couverture (ex. EUCS +
// TFS : le V3C n'a pas de paramètres EUCS → 0 pg/mL) : on rebascule
// sur le premier ester couvert (EV partout) puis on re-rend le
// formulaire pour reconstruire la liste des esters (v1.9.4).
const modelOptions = ['ESE', 'TFS', 'WHS'].map((x) => ({ value: x, label: t(modelLabelKey(x)) })); const modelOptions = ['ESE', 'TFS', 'WHS'].map((x) => ({ value: x, label: t(modelLabelKey(x)) }));
form.appendChild(dropdown(t('pk_model'), st.model, modelOptions, (v) => { st.model = v; })); form.appendChild(dropdown(t('pk_model'), st.model, modelOptions, (v) => {
st.model = v;
if (!choicesForModel(v).includes(st.ester)) st.ester = choicesForModel(v)[0];
rerenderForm();
}));
} else { } else {
const row1 = el('div', { class: 'row' }); const row1 = el('div', { class: 'row' });
row1.appendChild(field({ label: t('pk_absorption'), type: 'number', value: st.tmaxText, onInput: (v) => { st.tmaxText = v; }, attrs: { step: 'any' } })); row1.appendChild(field({ label: t('pk_absorption'), type: 'number', value: st.tmaxText, onInput: (v) => { st.tmaxText = v; }, attrs: { step: 'any' } }));

View File

@ -131,7 +131,7 @@ async function main() {
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
if (dialog) { if (dialog) {
const dlgText = await dialog.textContent(); const dlgText = await dialog.textContent();
ok(dlgText.includes('Version 1.9.3'), 'dialog : section de la version courante'); ok(dlgText.includes('Version 1.9.4'), 'dialog : section de la version courante');
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong'); const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
await page.click('.dialog .actions button'); await page.click('.dialog .actions button');
@ -334,7 +334,7 @@ async function main() {
await page.waitForTimeout(300); await page.waitForTimeout(300);
text = await page.textContent('main.content'); text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue'); ok(text.includes('Langue'), 'Paramètres : carte langue');
ok(text.includes('Version 1.9.3 (web)'), 'Paramètres : version affichée'); ok(text.includes('Version 1.9.4 (web)'), 'Paramètres : version affichée');
ok(text.includes('100 % local'), 'Paramètres : note vie privée'); ok(text.includes('100 % local'), 'Paramètres : note vie privée');
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });

70
tests/presets.test.js Normal file
View File

@ -0,0 +1,70 @@
/**
* Tests des PRESETS de traitements (v1.9.4 — miroir de `PKPresets.kt`) :
* le modèle ESE ANALYTIQUE couvre 6 esters injectables — les presets de
* création doivent offrir les 6 (comme TFS offre les 7 du V3C et WHSAH
* les 6 du fit Mona). Avant v1.9.4, les presets ESE n'offraient que
* EV/EU/EEn (l'héritage des tables ODS) — remontée : « les presets n'ont
* pas été mis à jour lors de l'ajout du modèle source ESE ».
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { all } from '../js/pk/presets.js';
describe('PKPresets web (presets ESE étendus — v1.9.4)', () => {
test('les presets ESE couvrent les 6 esters du fit analytique', () => {
const eseInjectable = all.filter((p) => p.pkModel === 'ESE' && p.esterType !== 'NONE');
const esters = eseInjectable.map((p) => p.esterType).sort();
assert.deepEqual(esters, ['EB', 'EC', 'EEN', 'EU', 'EUCS', 'EV']);
});
test('chaque ester couvert a EXACTEMENT un preset ESE (pas de doublon)', () => {
const eseInjectable = all.filter((p) => p.pkModel === 'ESE' && p.esterType !== 'NONE');
const names = eseInjectable.map((p) => p.nameKey);
assert.equal(new Set(names).size, names.length);
});
test('chaque preset a une clé i18n présente dans EN et FR', async () => {
const { store } = await import('../js/data/store.js');
store.setBackend(new Map()); // Node : pas de localStorage
const i18n = await import('../js/util/i18n.js');
// t() lit la langue du store (pas de setLanguage exporté) : on force
// successivement 'en' puis 'fr' via les settings (miroir de l'app).
for (const lang of ['en', 'fr']) {
store.setSettings({ language: lang });
for (const p of all) {
// t() retombe sur la clé brute si absente : un libellé égal à la
// clé = clé manquante (leçon §12 web : une string manquante
// s'affiche brute dans l'UI)
assert.notEqual(i18n.t(p.nameKey), p.nameKey, `clé i18n manquante (${lang}) : ${p.nameKey}`);
}
}
});
test('les presets TFS (7) et WHSAH (6) restent inchangés', () => {
const injectables = (model) => all.filter((p) => p.pkModel === model && p.esterType !== 'NONE');
assert.equal(injectables('TFS').length, 7);
assert.equal(injectables('WHS').length, 6);
});
test('choicesForModel (éditeur + dialog de dose) suit la couverture du fit ESE', async () => {
// v1.9.4 : l'éditeur liste choicesForModel(model) — si ce helper restait
// sur la couverture des tables ODS (3 esters), l'éditeur n'offrirait pas
// EC/EB/EUCS même avec les presets à jour (parité Android v1.9.0).
const { choicesForModel } = await import('../js/data/models.js');
assert.deepEqual(choicesForModel('ESE').sort(), ['EB', 'EC', 'EEN', 'EU', 'EUCS', 'EV']);
// TFS reste EXPLICITE à 7 (pas d'EUCS dans le V3C — sinon 0 pg/mL) et
// WHSAH à 6 (PEP non couvert).
assert.deepEqual(choicesForModel('TFS').sort(), ['EB', 'EC', 'ECS', 'EEN', 'EU', 'EV', 'PEP']);
assert.deepEqual(choicesForModel('WHS').sort(), ['EB', 'EC', 'ECS', 'EEN', 'EU', 'EV']);
assert.equal(choicesForModel('TFS').includes('EUCS'), false);
});
test('les presets ESE non-injectables (gel/patch/oral) restent en Bateman', () => {
const eseNonInjectable = all.filter(
(p) => p.pkModel === 'ESE' && (p.esterType === 'NONE' || p.esterType == null),
);
assert.ok(eseNonInjectable.length >= 3, 'gel/patch/oral toujours présents');
assert.ok(eseNonInjectable.every((p) => p.esterType === 'NONE'));
});
});