3 Commits

Author SHA1 Message Date
Adriano af8afebba8 longevity: misureValideAlla, spareggio sulla riga e nota sulla conferma dei fuori range
Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:40:31 +00:00
Adriano 4968b75472 longevity: collega i test fisici a calcolaSessione, misure valide fra sessioni
Un check-up e un questionario sono sessioni separate: prima di questo
commit calcolaSessione leggeva solo le misure della sessione corrente,
quindi l'una non vedeva mai i dati dell'altra. misureValideAlla (nuova,
motore/index.ts) legge invece, per ogni test_id, la misura valida piu'
recente del cliente fra tutte le sue sessioni fino a quella data.
sottoDominiDellaSessione unisce sotto-domini da curva (questionario) e
sotto-domini fisici (sottoDominiFisici) in un'unica mappa, usata sia da
calcolaSessione (livello 1) sia da vista.ts (sottoDominiPresenti, per
"cosa manca" nel referto — non piu' una query separata sulla tabella
misure). Aggiornati i commenti di seed-demo.ts (il registro ora contiene
anche le voci f_*, anche se questa demo non le compila).

Test nuovi in motore-fisici-sessione.test.ts: il caso demo() dell'oracolo
end-to-end, un ingresso mancante o l'eta assente che lascia il
sotto-dominio fuori, la combinazione fra sessioni (check-up dopo il
questionario, sessione precedente che non vede il futuro, misura fuori
range sostituita dalla precedente valida). 202 test esistenti restano
verdi, 9 nuovi: 211 totali. tsc --noEmit: 0 errori (0 anche su 13d8cdd).

Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:38:34 +00:00
Adriano 48910d84ba longevity: registro dei test fisici e sottoDominiFisici (porting dell'oracolo)
Aggiunge le 24 voci f_* al registro (misure grezze del check-up, senza
curva/sotto_dominio: il calcolo non passa da applicaCurva) e
motore/fisici.ts (sottoDominiFisici), che chiama le funzioni gia'
verificate di test-fisici.ts con eta e sesso per produrre i punteggi di
sotto-dominio. Un ingresso mancante lascia il sotto-dominio assente, mai
un ripiego. Non ancora collegato a calcolaSessione (prossimo commit).

Refs #1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:38:01 +00:00
7 changed files with 616 additions and 75 deletions
+8 -7
View File
@@ -26,13 +26,14 @@
* ⚠️ I dati sono INVENTATI e vanno detti tali a chi guarda: è una persona che non esiste.
* Il referto che ne esce non è un esempio clinico, è un esempio di interfaccia.
*
* ⚠️ Cosa mostrerà davvero il radar: il registro contiene oggi SOLO le 20 domande del
* questionario — i test fisici (handgrip, VO2max, plank, composizione corporea...) non
* hanno ancora una riga, arrivano col gestionale trainer. Quindi escono con un valore gli
* assi che il questionario copre almeno al 40%, e gli altri restano dichiarati
* insufficienti con l'elenco di cosa manca. Non è un difetto della demo: è lo stato del
* prodotto, ed è anche la regola di Nicola che si vede all'opera (annullare invece di
* stimare).
* ⚠️ Cosa mostrerà davvero il radar: questo script compila SOLO il questionario (le 20
* domande `q_*`) tramite `salvaDalForm` — non registra nessuna misura fisica (`f_*`), che
* arriva dal check-up col gestionale trainer, non da qui. Il registro le contiene entrambe
* (vedi `registro.ts`), ma senza `registraMisure` per le `f_*` questa sessione non ne vede
* nessuna. Quindi escono con un valore gli assi che il questionario copre almeno al 40%, e
* gli altri restano dichiarati insufficienti con l'elenco di cosa manca. Non è un difetto
* della demo: è lo stato di UNA sessione di solo questionario, ed è anche la regola di
* Nicola che si vede all'opera (annullare invece di stimare).
*
* DIPENDE DA: tsx (devDependency) — il codice del prodotto è TypeScript e questo script
* lo usa davvero invece di riscriverne una copia in SQL.
@@ -0,0 +1,139 @@
/**
* Dalle misure grezze del check-up (registrate coi test_id `f_*` di `registro.ts`) ai
* punteggi 0-100 di sotto-dominio, con lo stesso raggruppamento che l'oracolo fa in
* `demo()` (`tests/longevity/riferimento/isl_scoring_engine.py`): ogni sotto-dominio è
* una chiamata a una funzione di `test-fisici.ts`, con gli argomenti giusti presi dalla
* mappa delle misure.
*
* A differenza del questionario, queste voci NON passano da `applicaCurva`: le sette curve
* generiche (`bell`, `lin_dec`, ecc.) prendono un solo valore, e quasi tutte le funzioni qui
* sotto ne vogliono almeno due (il valore grezzo più età e/o sesso, a volte anche il peso
* corporeo di un'altra misura). Estendere `applicaCurva` per farci passare età e sesso è
* lavoro del piano degli import (vedi l'intestazione di `test-fisici.ts`), non di questa
* revisione: qui il calcolo resta una funzione dedicata, non una curva dichiarata nel dato.
*
* REGOLA UNICA, la stessa di tutto il motore: un sotto-dominio a cui manca anche un solo
* ingresso (compresa l'età, dove la funzione la richiede) NON compare nel risultato — mai
* un valore di ripiego. È lo stesso principio di `applicaCurva`/`paramsValidi`, qui
* applicato a funzioni con più argomenti invece che a una curva coi parametri incompleti.
*
* FUORI DAL PERIMETRO (dichiarato, non dimenticato): spirometria (serve un calcolatore
* GLI che non esiste ancora), milfit, il 2km walk, il sit-to-stand, la trazione isometrica.
* L'oracolo li usa nella demo con segnaposto o non li usa affatto nei pesi correnti;
* restano fuori finché non arriva un test_id e una decisione su come raccoglierli.
*/
import type { Sesso } from './test-fisici';
import { clamp } from './curve';
import {
scoreHandgrip, scorePushup, scoreSollevamentoSuPeso, TIERS_ROW_M, TIERS_ROW_F,
TIERS_SQUAT_M, TIERS_SQUAT_F, scorePlank, scoreGrassoPercento, scoreMuscoloPercento,
scoreWhr, scoreVo2max, vo2maxDaMutt, vo2maxDaStepTest, scoreSpo2, scorePressione,
scoreRecuperoCardiaco, scoreFlamingo, scoreSitAndReach, scoreBackScratch, scoreMobilitaSpalla,
} from './test-fisici';
export type { Sesso };
/**
* `m` è la mappa test_id -> valore grezzo più recente e valido (vedi `misureValideAlla` in
* `motore/index.ts`), `eta` l'età alla data della sessione (nullable come in tutto il
* motore: senza età gli score che la richiedono restano assenti, mai calcolati a zero).
*
* Ordine dei blocchi: uguale a `demo()` nell'oracolo (forza, composizione, cardio,
* recupero, stabilità), così un confronto riga per riga fra questo file e quello resta
* diretto.
*/
export function sottoDominiFisici(
m: Map<string, number>, eta: number | null, sesso: Sesso
): Record<string, number> {
const out: Record<string, number> = {};
// --- Forza & Struttura ----------------------------------------------------
const handgrip = m.get('f_handgrip_kg');
if (handgrip !== undefined && eta !== null) out.handgrip = scoreHandgrip(handgrip, eta, sesso);
const pushup = m.get('f_pushup_rip');
if (pushup !== undefined && eta !== null) out.spinta = scorePushup(pushup, eta, sesso);
const pesoKg = m.get('f_peso_kg');
const rowCarico = m.get('f_row_carico_kg');
const rowRip = m.get('f_row_rip');
if (rowCarico !== undefined && pesoKg !== undefined && rowRip !== undefined) {
out.trazione = scoreSollevamentoSuPeso(rowCarico, pesoKg, rowRip, TIERS_ROW_M, TIERS_ROW_F, sesso);
}
const squatCarico = m.get('f_squat_carico_kg');
const squatRip = m.get('f_squat_rip');
if (squatCarico !== undefined && pesoKg !== undefined && squatRip !== undefined) {
out.arti_inferiori = scoreSollevamentoSuPeso(squatCarico, pesoKg, squatRip, TIERS_SQUAT_M, TIERS_SQUAT_F, sesso);
}
// Il plank pesa su DUE assi (core in Forza & Struttura, plank in Stabilità & Mobilità):
// confermato da Nicola il 22/08, vedi il commento su PESI_ASSE in registro.ts. Stessa
// misura, stessa funzione, due chiavi nel risultato — non è una duplicazione per errore.
const plank = m.get('f_plank_sec');
if (plank !== undefined) {
const punteggio = scorePlank(plank, sesso);
out.core = punteggio;
out.plank = punteggio;
}
// --- Composizione Corporea -------------------------------------------------
const grasso = m.get('f_grasso_pct');
if (grasso !== undefined) out.grasso = scoreGrassoPercento(grasso, sesso);
const muscolo = m.get('f_muscolo_pct');
if (muscolo !== undefined) out.muscolo = scoreMuscoloPercento(muscolo, sesso);
const whr = m.get('f_whr');
if (whr !== undefined) out.whr = scoreWhr(whr, sesso);
// --- Cardio-Respiratorio -----------------------------------------------------
// VO2max: diretto se misurato, altrimenti stimato da MUTT, altrimenti dallo step test —
// il primo presente vince, come nella cascata di fonti dell'oracolo (che nella demo usa
// solo MUTT, ma le tre vie coesistono nel resto del file Python).
const vo2Diretto = m.get('f_vo2max');
const muttVam = m.get('f_mutt_vam');
const stepHr = m.get('f_step_hr_recovery');
let vo2max: number | undefined;
if (vo2Diretto !== undefined) vo2max = vo2Diretto;
else if (muttVam !== undefined) vo2max = vo2maxDaMutt(muttVam);
else if (stepHr !== undefined) vo2max = vo2maxDaStepTest(stepHr, sesso);
if (vo2max !== undefined && eta !== null) out.vo2max = scoreVo2max(vo2max, eta, sesso);
const spo2 = m.get('f_spo2_pct');
if (spo2 !== undefined) out.wellness_tower_cardio = scoreSpo2(spo2);
// --- Recupero & Sistema Nervoso / Energia & Regolazione Stress ---------------
// hrv è già un punteggio 0-100 (lo calcola lo Stress Index a monte, non una curva di
// questo motore): qui si limita soltanto, non si ricalcola. Pesa su DUE assi (Recupero
// e Energia, vedi PESI_ASSE) con la stessa chiave 'hrv' — un solo valore in `out`, letto
// da entrambi in cascata.ts.
const hrv = m.get('f_hrv_score');
if (hrv !== undefined) out.hrv = clamp(hrv);
const sistolica = m.get('f_pa_sistolica');
const diastolica = m.get('f_pa_diastolica');
if (sistolica !== undefined && diastolica !== undefined) out.pressione = scorePressione(sistolica, diastolica);
const hrr = m.get('f_hrr_1min_bpm');
if (hrr !== undefined) out.hrr = scoreRecuperoCardiaco(hrr);
// --- Stabilità & Mobilità Funzionale ------------------------------------------
const flamingo = m.get('f_flamingo_cadute');
if (flamingo !== undefined) out.flamingo = scoreFlamingo(flamingo);
const sitReach = m.get('f_sit_reach_cm');
if (sitReach !== undefined) out.sit_and_reach = scoreSitAndReach(sitReach, sesso);
const backScratch = m.get('f_back_scratch_cm');
if (backScratch !== undefined && eta !== null) out.back_scratch = scoreBackScratch(backScratch, eta, sesso);
const outreach = m.get('f_spalla_outreach_deg');
const buckling = m.get('f_spalla_buckling_deg');
if (outreach !== undefined && buckling !== undefined) {
out.wellness_tower_shoulder = scoreMobilitaSpalla(outreach, buckling);
}
return out;
}
+136 -40
View File
@@ -6,15 +6,25 @@
* Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel
* registro (`registro_test.curva`), coi parametri dichiarati lì
* (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un
* test senza curva dichiarata (es. un campo testuale) non contribuisce al
* calcolo.
* test senza curva dichiarata (es. un campo testuale, o una misura fisica —
* vedi sotto) non contribuisce a QUESTO livello.
*
* Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione
* per sotto-dominio (media dei test dello stesso sotto-dominio — più test
* possono contribuire allo stesso sotto-dominio, es. spinta via push-up o
* panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`,
* `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette
* assi, i tre macro-score e la Fitness Age.
* Le misure fisiche (`f_*`) non passano da `applicaCurva`: le calcola
* `sottoDominiFisici` (`motore/fisici.ts`), che prende più argomenti di una
* curva generica (età, sesso, a volte un'altra misura come il peso
* corporeo). `sottoDominiDellaSessione` unisce le due fonti — sotto-domini
* da curva (questionario) e sotto-domini fisici — in un'unica mappa.
*
* Un check-up e un questionario sono sessioni SEPARATE: senza altro, una
* sessione vedrebbe solo le proprie misure e lascerebbe vuoti gli assi
* dell'altra sessione. `misureValideAlla` risolve questo leggendo, per ogni
* test_id, la misura valida più recente DEL CLIENTE fra tutte le sue
* sessioni fino a quella data — non solo quelle della sessione corrente.
*
* Livelli 2-4 (`calcolaSessione`): usa `sottoDominiDellaSessione`, poi
* `cascata.ts` (`calcolaAsse`, `calcolaMacro`, `calcolaFitnessAge`) coi pesi
* letti da `pesi` (via `pesiDi`) per i sette assi, i tre macro-score e la
* Fitness Age.
*
* `salvaScore` congela il risultato nella tabella `score`, una riga per
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
@@ -41,6 +51,7 @@ import {
} from './curve';
import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata';
import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro';
import { sottoDominiFisici, type Sesso } from './fisici';
/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */
type ParamsCurva = {
@@ -114,6 +125,118 @@ export function applicaCurva(voce: VoceRegistro, valore: number): number | null
}
}
/**
* Sotto questa età (in giorni) una misura smette di contare per una sessione successiva.
* `null` = nessun limite: oggi una misura resta valida per sempre, finché non ne arriva
* una più recente dello stesso test_id. Decisione aperta per Nicola — quanto "invecchia"
* un dato fisico prima di non poter più coprire il questionario di un mese dopo è una
* scelta di prodotto, non tecnica; finché non arriva si tiene la più permissiva delle due,
* perché la direzione di guasto opposta (scartare una misura buona) è quella che di nuovo
* produce assi vuoti che potrebbero non esserlo.
*/
export const FINESTRA_GIORNI: number | null = null;
/**
* Per ogni test_id, il valore valido più recente DI QUESTO CLIENTE fra tutte le sue
* sessioni fino a quella data compresa — non solo le misure della sessione `sessioneId`.
* Risolve il problema del check-up e del questionario come sessioni separate (vedi
* l'intestazione del file): un check-up del giorno D+10 vede ancora le risposte del
* questionario del giorno D, e viceversa una sessione del giorno D-1 non vede nulla di ciò
* che accadrà dopo di lei.
*
* "Più recente" = data della sessione DESC, poi id di sessione DESC (a parità di data,
* l'inserimento più tardo vince) — stesso criterio di `leggiScore`. A parità di SESSIONE
* (due misure dello stesso test_id nella stessa sessione: `registraMisure` non lo vieta,
* es. una correzione manuale dopo un refuso) l'ORDER BY aggiunge un terzo criterio
* esplicito, `m.id` di riga in `misure` DESC: vince quella scritta dopo. Senza questo
* terzo criterio le due righe uscirebbero in un ordine NON definito da SQL (nessuna delle
* due chiavi precedenti le distingue), non "implicito" come diceva una versione precedente
* di questo commento — un ordine che sembra stabile per come SQLite le restituisce oggi,
* ma su cui non si può contare.
*
* Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della
* spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da
* una precedente. La colonna della conferma non esiste ancora (§12 della spec, issue #7):
* quando ci sarà, questo filtro andrà rilassato per lasciar passare le misure confermate,
* non tolto del tutto.
*/
export function misureValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
const sessione = db.prepare(
`SELECT client_code, data, id FROM sessioni WHERE id = ?`
).get(sessioneId) as { client_code: string; data: string; id: number } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const righe = db.prepare(
`SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data
FROM misure m
JOIN sessioni s2 ON s2.id = m.sessione_id
WHERE m.client_code = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?))
ORDER BY s2.data DESC, s2.id DESC, m.id DESC`
).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as
{ test_id: string; valore_num: number; data: string }[];
const mappa = new Map<string, number>();
for (const r of righe) {
// gia' trovato un valore per questo test_id: e' il piu' recente, per data di sessione,
// poi id di sessione, poi (spareggio) id di riga in misure — tutti e tre espliciti
// nell'ORDER BY qui sopra, la prima riga per test_id in questo ciclo e' sempre quella giusta.
if (mappa.has(r.test_id)) continue;
if (FINESTRA_GIORNI !== null) {
const giorni = (Date.parse(sessione.data) - Date.parse(r.data)) / 86_400_000;
if (giorni > FINESTRA_GIORNI) continue;
}
mappa.set(r.test_id, r.valore_num);
}
return mappa;
}
/**
* I sotto-domini di una sessione, dalle due fonti che oggi esistono: il questionario
* (livello 1, `applicaCurva` sulle misure di `misureValideAlla`) e i test fisici
* (`sottoDominiFisici`, sull'età alla data della sessione e il sesso del cliente). In
* caso di chiave in conflitto vince il fisico — oggi non capita mai (i sotto-domini da
* curva sono tutti `questionario_*`), ma se domani un test_id q_* condividesse la chiave
* di un sotto-dominio fisico, un dato misurato in un check-up deve prevalere su
* un'autovalutazione.
*/
export function sottoDominiDellaSessione(db: Database.Database, sessioneId: number): Record<string, number> {
const sessione = db.prepare(
`SELECT data, eta_alla_data, client_code FROM sessioni WHERE id = ?`
).get(sessioneId) as { data: string; eta_alla_data: number | null; client_code: string } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const misurate = misureValideAlla(db, sessioneId);
// Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per
// sotto-dominio — più test nello stesso sotto-dominio contano come media.
const attivi = testAttivi(db, sessione.data);
const perSottoDominio = new Map<string, number[]>();
for (const voce of attivi) {
if (!voce.sotto_dominio) continue;
const grezzo = misurate.get(voce.test_id);
if (grezzo === undefined) continue;
const punteggio = applicaCurva(voce, grezzo);
if (punteggio === null) continue;
const lista = perSottoDominio.get(voce.sotto_dominio) ?? [];
lista.push(punteggio);
perSottoDominio.set(voce.sotto_dominio, lista);
}
const daCurva: Record<string, number> = {};
for (const [nome, valori] of perSottoDominio) {
daCurva[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length);
}
const soggetto = db.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(sessione.client_code) as
{ sesso: Sesso } | undefined;
// soggetto dovrebbe sempre esistere (sessioni.client_code referenzia soggetti, FK attive
// in db.ts): il controllo resta comunque, perche' l'assenza di dato fisico e' un {}
// sicuro, mai un sesso indovinato.
const daFisici = soggetto ? sottoDominiFisici(misurate, sessione.eta_alla_data, soggetto.sesso) : {};
return { ...daCurva, ...daFisici };
}
/**
* Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age.
* `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché
@@ -134,43 +257,16 @@ export type RisultatoSessione = {
/**
* Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da
* costanti: `testAttivi` alla data della sessione, i valori grezzi da
* `misure`, i pesi da `pesi` (tramite `pesiDi`).
* `misure` (tramite `sottoDominiDellaSessione`), i pesi da `pesi` (tramite `pesiDi`).
*/
export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione {
const sessione = db.prepare(
`SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?`
).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined;
`SELECT eta_alla_data, quest_version FROM sessioni WHERE id = ?`
).get(sessioneId) as { eta_alla_data: number | null; quest_version: string | null } | undefined;
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
const attivi = testAttivi(db, sessione.data);
// fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno
// non le conferma". La colonna della conferma non esiste ancora (punto aperto in
// §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar
// passare le misure confermate, non tolto del tutto.
const misurate = new Map<string, number>(
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as
{ test_id: string; valore_num: number | null }[])
.filter((m) => m.valore_num !== null)
.map((m) => [m.test_id, m.valore_num as number])
);
// Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per
// sotto-dominio — più test nello stesso sotto-dominio contano come media.
const perSottoDominio = new Map<string, number[]>();
for (const voce of attivi) {
if (!voce.sotto_dominio) continue;
const grezzo = misurate.get(voce.test_id);
if (grezzo === undefined) continue;
const punteggio = applicaCurva(voce, grezzo);
if (punteggio === null) continue;
const lista = perSottoDominio.get(voce.sotto_dominio) ?? [];
lista.push(punteggio);
perSottoDominio.set(voce.sotto_dominio, lista);
}
const sottoDominio: Record<string, number> = {};
for (const [nome, valori] of perSottoDominio) {
sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length);
}
// Livello 1 (questionario + fisici): vedi sottoDominiDellaSessione.
const sottoDominio = sottoDominiDellaSessione(db, sessioneId);
// Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso.
const nomiAssi = (db.prepare(
@@ -15,18 +15,15 @@
* comparabili fra protocolli), la seconda perché non è mai chiamata da
* nessun peso.
*
* ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate
* contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono
* irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo
* sulle sette curve generiche dichiarate nel vocabolario di `registro.ts`
* (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e
* non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`,
* senza modo di passare età e sesso, che quasi tutte queste funzioni
* richiedono. Agganciarle è lavoro del piano degli import, non di questa
* revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far
* arrivare età/sesso fino ad `applicaCurva` — quindi non sarà "una modifica ai
* dati" nel senso in cui lo intende la spec (registro senza migrazioni): serve
* anche codice nuovo nel motore, non solo righe nuove in `registro_test`.
* COLLEGATE AL MOTORE tramite `motore/fisici.ts` (`sottoDominiFisici`), non
* tramite `applicaCurva`: `applicaCurva` smista solo sulle sette curve
* generiche dichiarate nel vocabolario di `registro.ts` (`bell`, `lin_dec`,
* `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e la sua firma prende
* `(voce, valore)`, senza modo di passare età e sesso che quasi tutte queste
* funzioni richiedono — estenderla resta lavoro futuro, non fatto qui.
* `sottoDominiFisici` aggira il problema chiamando queste funzioni
* direttamente, con gli argomenti che servono presi dalla mappa delle misure
* del check-up (`registro.ts`, voci `f_*`).
*/
import { clamp, lerp, arrotonda1 } from './curve';
@@ -93,6 +93,71 @@ const QUESTIONARIO: VoceRegistro[] = [
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA },
];
// I test fisici del check-up: misure GREZZE, non punteggi. A differenza del questionario
// non passano da `applicaCurva` (nessuna `curva`/`params` qui: quelle sette curve generiche
// non sanno cosa farsene di un'età o di un sesso) — il calcolo è in `motore/fisici.ts`
// (`sottoDominiFisici`), che è il porting dell'oracolo per queste ventiquattro voci. Per
// questo `sotto_dominio` resta assente: il sotto-dominio non è un dato del registro come
// per il questionario, è il NOME della chiave che `sottoDominiFisici` restituisce.
// `asse` invece è valorizzato: serve solo all'interfaccia (raggruppare il modulo del
// check-up per sezione), nessun codice lo legge oggi. Due voci alimentano DUE
// sotto-domini dalla stessa misura (f_plank_sec -> core E plank, f_hrv_score -> hrv sia
// in Recupero che in Energia): l'asse riportato qui è quello principale, l'altro si vede
// in `sottoDominiFisici`.
// `range_min`/`range_max` sono limiti di PLAUSIBILITÀ FISIOLOGICA, larghi apposta: non sono
// soglie cliniche (quelle vivono nelle funzioni di `test-fisici.ts`), servono solo a
// intercettare un errore di battitura in fase di inserimento (es. 480 kg di squat).
const FISICI: VoceRegistro[] = [
{ test_id: 'f_peso_kg', etichetta: 'Peso corporeo', unita: 'kg', tipo_valore: 'num',
asse: 'Composizione Corporea', range_min: 30, range_max: 250, attivo_da: DA },
{ test_id: 'f_handgrip_kg', etichetta: 'Forza di presa (handgrip, dinamometro)', unita: 'kg', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA },
{ test_id: 'f_pushup_rip', etichetta: 'Push-up, ripetizioni massime', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 150, attivo_da: DA },
{ test_id: 'f_row_carico_kg', etichetta: 'Trazione/rematore, carico', unita: 'kg', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 300, attivo_da: DA },
{ test_id: 'f_row_rip', etichetta: 'Trazione/rematore, ripetizioni (test 5RM)', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 1, range_max: 20, attivo_da: DA },
{ test_id: 'f_squat_carico_kg', etichetta: 'Squat, carico', unita: 'kg', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 400, attivo_da: DA },
{ test_id: 'f_squat_rip', etichetta: 'Squat, ripetizioni (test 5RM)', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 1, range_max: 20, attivo_da: DA },
{ test_id: 'f_plank_sec', etichetta: 'Plank, tenuta', unita: 'sec', tipo_valore: 'num',
asse: 'Forza & Struttura', range_min: 0, range_max: 1200, attivo_da: DA },
{ test_id: 'f_grasso_pct', etichetta: 'Massa grassa', unita: '%', tipo_valore: 'num',
asse: 'Composizione Corporea', range_min: 2, range_max: 60, attivo_da: DA },
{ test_id: 'f_muscolo_pct', etichetta: 'Massa muscolare', unita: '%', tipo_valore: 'num',
asse: 'Composizione Corporea', range_min: 20, range_max: 70, attivo_da: DA },
{ test_id: 'f_whr', etichetta: 'Rapporto vita-fianchi (WHR)', tipo_valore: 'num',
asse: 'Composizione Corporea', range_min: 0.5, range_max: 1.5, attivo_da: DA },
{ test_id: 'f_vo2max', etichetta: 'VO2max (misura diretta)', unita: 'ml/kg/min', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 10, range_max: 90, attivo_da: DA },
{ test_id: 'f_mutt_vam', etichetta: 'VAM al test MUTT/tapis (per stimare il VO2max)', unita: 'm/min', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 50, range_max: 400, attivo_da: DA },
{ test_id: 'f_step_hr_recovery', etichetta: 'Frequenza cardiaca di recupero, Queen\'s College Step Test',
unita: 'bpm', tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 220, attivo_da: DA },
{ test_id: 'f_spo2_pct', etichetta: 'Saturazione O2 (SpO2)', unita: '%', tipo_valore: 'num',
asse: 'Cardio-Respiratorio', range_min: 50, range_max: 100, attivo_da: DA },
{ test_id: 'f_hrv_score', etichetta: 'HRV, punteggio (Stress Index)', tipo_valore: 'num',
asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA },
{ test_id: 'f_pa_sistolica', etichetta: 'Pressione arteriosa sistolica', unita: 'mmHg', tipo_valore: 'num',
asse: 'Recupero & Sistema Nervoso', range_min: 70, range_max: 250, attivo_da: DA },
{ test_id: 'f_pa_diastolica', etichetta: 'Pressione arteriosa diastolica', unita: 'mmHg', tipo_valore: 'num',
asse: 'Recupero & Sistema Nervoso', range_min: 40, range_max: 150, attivo_da: DA },
{ test_id: 'f_hrr_1min_bpm', etichetta: 'Recupero cardiaco a 1 minuto post-sforzo', unita: 'bpm', tipo_valore: 'num',
asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA },
{ test_id: 'f_flamingo_cadute', etichetta: 'Test del fenicottero, numero di cadute', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 50, attivo_da: DA },
{ test_id: 'f_sit_reach_cm', etichetta: 'Sit and reach', unita: 'cm', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: -30, range_max: 50, attivo_da: DA },
{ test_id: 'f_back_scratch_cm', etichetta: 'Back scratch (mobilità spalle)', unita: 'cm', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 20, attivo_da: DA },
{ test_id: 'f_spalla_outreach_deg', etichetta: 'Wellness Tower spalla, outreach', unita: 'deg', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA },
{ test_id: 'f_spalla_buckling_deg', etichetta: 'Wellness Tower spalla, buckling', unita: 'deg', tipo_valore: 'num',
asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA },
];
export function seedRegistro(db: Database.Database): void {
const ins = db.prepare(
`INSERT OR REPLACE INTO registro_test
@@ -114,6 +179,7 @@ export function seedRegistro(db: Database.Database): void {
}
});
tx(QUESTIONARIO);
tx(FISICI);
}
// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS,
+16 -16
View File
@@ -11,7 +11,7 @@
import type Database from 'better-sqlite3';
import { testAttivi, pesiDi, MODEL_VERSION } from './registro';
import { salvaCompilazione, CONSENT_VERSION } from './questionario';
import { leggiScore, calcolaSessione, salvaScore } from './motore';
import { leggiScore, calcolaSessione, salvaScore, sottoDominiDellaSessione } from './motore';
export type CampoQuestionario = {
id: string;
@@ -137,11 +137,13 @@ export type MacroVista = { nome: string; valore: number | null; copertura: numbe
/**
* Le etichette leggibili dei sotto-domini che alimentano un asse: le chiavi sono quelle
* di `PESI_ASSE` in registro.ts. Non tutte hanno oggi una riga in `registro_test` — i
* test fisici (handgrip, VO2max, plank...) arrivano con un piano successivo, il
* questionario è l'unica fonte di misure che esiste davvero — quindi la traduzione non
* può venire da lì: è dato locale a questo modulo, non duplica il registro, lo completa
* dove il registro non arriva ancora.
* di `PESI_ASSE` in registro.ts. Non sono chiavi che si leggono da `registro_test.sotto_dominio`
* per tutte: quelle del questionario sì, ma quelle fisiche (handgrip, VO2max, plank...) le
* produce `sottoDominiFisici` (`motore/fisici.ts`) come chiave del suo risultato — le voci
* FISICI del registro non portano un `sotto_dominio` proprio (vedi il commento su `FISICI`
* in registro.ts) — quindi la traduzione non può venire da una query sul registro: è dato
* locale a questo modulo. Unica eccezione rimasta davvero scoperta: `spirometria`, fuori
* dal perimetro di `sottoDominiFisici` (nessun calcolatore GLI, vedi quel file).
*/
const ETICHETTE_SOTTO_DOMINIO: Record<string, string> = {
handgrip: 'Forza di presa (handgrip)',
@@ -178,17 +180,15 @@ function contenitoriDi(db: Database.Database, livello: 'asse' | 'macro'): string
).all(MODEL_VERSION, livello) as { contenitore: string }[]).map((r) => r.contenitore);
}
/** I sotto-domini con almeno una misura valida (nel range atteso) in questa sessione:
* l'informazione grezza da cui `mancano` si costruisce, non un punteggio — leggere quali
* test sono stati fatti non è ricalcolare come pesano. */
/** I sotto-domini con un punteggio calcolabile per questa sessione — le chiavi di
* `sottoDominiDellaSessione`, che a differenza di una query sulla sola tabella `misure`
* vede anche le misure valide portate avanti da sessioni precedenti dello stesso cliente
* (`misureValideAlla` in `motore/index.ts`) e i sotto-domini fisici (`sottoDominiFisici`),
* non solo quelli da curva del questionario. Usa lo stesso calcolo che alimenta gli assi,
* non lo duplica: `mancano` si costruisce da qui, mai da un ricalcolo separato che
* potrebbe divergere. */
function sottoDominiPresenti(db: Database.Database, sessioneId: number): Set<string> {
const righe = db.prepare(
`SELECT DISTINCT rt.sotto_dominio AS sd
FROM misure m JOIN registro_test rt ON rt.test_id = m.test_id
WHERE m.sessione_id = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
AND rt.sotto_dominio IS NOT NULL`
).all(sessioneId) as { sd: string }[];
return new Set(righe.map((r) => r.sd));
return new Set(Object.keys(sottoDominiDellaSessione(db, sessioneId)));
}
/** Cosa manca per completare un asse insufficiente: i sotto-domini pesati per quell'asse
@@ -0,0 +1,242 @@
// Il piano degli import collega finalmente i test fisici (verificati contro l'oracolo in
// motore-fisici.test.ts, ma finora IRRAGGIUNGIBILI dal motore) a una sessione vera: la
// misura grezza del check-up (test_id f_*, registro.ts) arriva a un punteggio di
// sotto-dominio tramite sottoDominiFisici (motore/fisici.ts), che calcolaSessione unisce
// a quelli del questionario tramite sottoDominiDellaSessione. Questo file copre tre cose
// che PRIMA di questa revisione non esistevano: il caso end-to-end confrontato con
// l'oracolo, la regola "un ingresso mancante -> sotto-dominio assente" applicata a
// funzioni con piu' argomenti (non solo alle curve del questionario), e il motivo per cui
// serve tutto questo: un check-up e un questionario sono sessioni SEPARATE, e senza
// misureValideAlla l'una non vedrebbe mai i dati dell'altra.
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
import { calcolaSessione, sottoDominiDellaSessione, misureValideAlla } from '../../src/lib/longevity/motore';
import { sottoDominiFisici } from '../../src/lib/longevity/motore/fisici';
import { campiDelQuestionario } from '../../src/lib/longevity/vista';
function dbPronto(clientCode: string, sesso: 'M' | 'F' = 'M') {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso);
return db;
}
describe('il caso demo() dell oracolo, calcolato da una sessione vera', () => {
// Stesso cliente fittizio di demo() in isl_scoring_engine.py: M, 38 anni, peso 90.3.
// Un solo check-up con TUTTE le misure fisiche della demo, nessun questionario.
it('Forza & Struttura, Composizione Corporea e Stabilita & Mobilita Funzionale combaciano con l oracolo', () => {
const db = dbPronto('ISL-DEMO', 'M');
const s = apriSessione(db, { client_code: 'ISL-DEMO', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'manuale', [
{ test_id: 'f_peso_kg', valore_num: 90.3 },
{ test_id: 'f_handgrip_kg', valore_num: 48 },
{ test_id: 'f_pushup_rip', valore_num: 24 },
{ test_id: 'f_row_carico_kg', valore_num: 80 }, { test_id: 'f_row_rip', valore_num: 5 },
{ test_id: 'f_squat_carico_kg', valore_num: 140 }, { test_id: 'f_squat_rip', valore_num: 5 },
{ test_id: 'f_plank_sec', valore_num: 110 },
{ test_id: 'f_grasso_pct', valore_num: 12.8 },
{ test_id: 'f_muscolo_pct', valore_num: 48.8 },
{ test_id: 'f_whr', valore_num: 0.71 },
{ test_id: 'f_mutt_vam', valore_num: 230 },
{ test_id: 'f_spo2_pct', valore_num: 98 },
{ test_id: 'f_pa_sistolica', valore_num: 114 }, { test_id: 'f_pa_diastolica', valore_num: 81 },
{ test_id: 'f_hrr_1min_bpm', valore_num: 15 },
{ test_id: 'f_flamingo_cadute', valore_num: 5 },
{ test_id: 'f_sit_reach_cm', valore_num: 20 },
{ test_id: 'f_back_scratch_cm', valore_num: -5 },
{ test_id: 'f_spalla_outreach_deg', valore_num: 180 }, { test_id: 'f_spalla_buckling_deg', valore_num: 180 },
]);
registraMisure(db, s, 'stress_index', [{ test_id: 'f_hrv_score', valore_num: 68 }]);
const r = calcolaSessione(db, s);
// Valori attesi ricavati lanciando l'oracolo, non a mano:
// cd tests/longevity/riferimento && python3 -c "
// from isl_scoring_engine import *
// eta, sex, peso = 38, 'M', 90.3
// forza = {'handgrip': score_handgrip(48, eta, sex), 'spinta': score_pushup(24, eta, sex),
// 'trazione': score_bw_ratio_lift(80, peso, 5, TIERS_ROW_M, TIERS_ROW_F, sex),
// 'arti_inferiori': score_bw_ratio_lift(140, peso, 5, TIERS_SQUAT_M, TIERS_SQUAT_F, sex),
// 'core': score_plank(110, sex)}
// composizione = {'grasso': score_fat_percent(12.8, sex), 'muscolo': score_muscle_percent(48.8, sex),
// 'whr': score_whr(0.71, sex)}
// stabilita = {'flamingo': score_flamingo(5), 'sit_and_reach': score_sit_and_reach(20, sex),
// 'plank': score_plank(110, sex), 'back_scratch': score_back_scratch(-5, eta, sex),
// 'wellness_tower_shoulder': score_shoulder_mobility_wt(180, 180)}
// print(compute_axis('Forza & Struttura', forza)) # -> score 70.4, coverage 1.0, ok
// print(compute_axis('Composizione Corporea', composizione)) # -> score 94.8, coverage 1.0, ok
// print(compute_axis('Stabilità & Mobilità Funzionale', stabilita)) # -> score 65.5, coverage 1.0, ok
// "
const forza = r.assi['Forza & Struttura'];
expect(forza.stato).toBe('ok');
if (forza.stato === 'ok') expect(forza.valore).toBe(70.4);
const composizione = r.assi['Composizione Corporea'];
expect(composizione.stato).toBe('ok');
if (composizione.stato === 'ok') expect(composizione.valore).toBe(94.8);
const stabilita = r.assi['Stabilità & Mobilità Funzionale'];
expect(stabilita.stato).toBe('ok');
if (stabilita.stato === 'ok') expect(stabilita.valore).toBe(65.5);
});
// Cardio-Respiratorio, Recupero & Sistema Nervoso ed Energia & Regolazione Stress NON si
// confrontano con demo() dell'oracolo: demo() li riempie con segnaposto dichiarati tali nel
// codice Python (spirometria=75, questionario_sonno=72, questionario_energia_stress=60,
// hrv=65 su Energia — un HRV diverso da quello di Recupero, altro segnaposto) per mostrare
// un radar completo. Il nostro motore non inventa spirometria (fuori dal perimetro di
// sottoDominiFisici, nessun calcolatore GLI) ne' un questionario mai compilato in questa
// sessione: quei sotto-domini restano assenti DAVVERO, non finti, e il peso si ridistribuisce
// sugli altri (regola di copertura, cascata.ts). Il numero che ne esce e' quindi
// legittimamente diverso da quello della demo, ma calcolabile con la STESSA formula di
// aggregazione (aggregate() in Python, aggrega() qui) sul sottoinsieme vero di dati:
// cd tests/longevity/riferimento && python3 -c "
// from isl_scoring_engine import *
// eta, sex = 38, 'M'
// vo2 = vo2max_from_mutt(230)
// print(compute_axis('Cardio-Respiratorio', {'vo2max': score_vo2max(vo2, eta, sex), 'wellness_tower_cardio': score_spo2(98)}))
// print(compute_axis('Recupero & Sistema Nervoso', {'hrv': 68, 'pressione': score_blood_pressure(114, 81), 'hrr': score_hrr(15)}))
// print(compute_axis('Energia & Regolazione Stress', {'hrv': 68}))
// "
// -> Cardio 87.2 (copertura 0.8), Recupero 73.4 (copertura 0.8), Energia 68.0 (copertura 0.5).
it('Cardio-Respiratorio, Recupero ed Energia sono ok ma con un numero diverso dalla demo(): mancano spirometria e questionario, non sono finti', () => {
const db = dbPronto('ISL-DEMO2', 'M');
const s = apriSessione(db, { client_code: 'ISL-DEMO2', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'manuale', [
{ test_id: 'f_mutt_vam', valore_num: 230 },
{ test_id: 'f_spo2_pct', valore_num: 98 },
{ test_id: 'f_pa_sistolica', valore_num: 114 }, { test_id: 'f_pa_diastolica', valore_num: 81 },
{ test_id: 'f_hrr_1min_bpm', valore_num: 15 },
]);
registraMisure(db, s, 'stress_index', [{ test_id: 'f_hrv_score', valore_num: 68 }]);
const r = calcolaSessione(db, s);
const cardio = r.assi['Cardio-Respiratorio'];
expect(cardio.stato).toBe('ok');
if (cardio.stato === 'ok') { expect(cardio.valore).toBe(87.2); expect(cardio.copertura).toBe(0.8); }
const recupero = r.assi['Recupero & Sistema Nervoso'];
expect(recupero.stato).toBe('ok');
if (recupero.stato === 'ok') { expect(recupero.valore).toBe(73.4); expect(recupero.copertura).toBe(0.8); }
const energia = r.assi['Energia & Regolazione Stress'];
expect(energia.stato).toBe('ok');
if (energia.stato === 'ok') { expect(energia.valore).toBe(68); expect(energia.copertura).toBe(0.5); }
// Nessun questionario in questa sessione: l'asse che dipende solo da domande resta scoperto.
expect(r.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
});
});
describe('sottoDominiFisici: un ingresso mancante lascia il sotto-dominio assente, mai un ripiego', () => {
it('la trazione (sollevamento sul peso corporeo) manca se manca il peso, anche con carico e ripetizioni presenti', () => {
const m = new Map([['f_row_carico_kg', 80], ['f_row_rip', 5]]); // manca f_peso_kg
const r = sottoDominiFisici(m, 38, 'M');
expect(r.trazione).toBeUndefined();
});
it('senza eta: handgrip, spinta, vo2max e back_scratch assenti; il plank (che non la richiede) resta presente', () => {
// Stesse quattro funzioni di test-fisici.ts che prendono eta come argomento
// obbligatorio (scoreHandgrip, scorePushup, scoreVo2max, scoreBackScratch): con eta
// null nessuna delle quattro puo' essere chiamata, quindi il sotto-dominio che ne
// dipende resta assente. scorePlank non prende eta: il suo sotto-dominio non risente
// per niente dell'eta mancante.
const m = new Map([
['f_handgrip_kg', 48], ['f_pushup_rip', 24], ['f_plank_sec', 110],
['f_mutt_vam', 230], ['f_back_scratch_cm', -5],
]);
const r = sottoDominiFisici(m, null, 'M');
expect(r.handgrip).toBeUndefined();
expect(r.spinta).toBeUndefined();
expect(r.vo2max).toBeUndefined();
expect(r.back_scratch).toBeUndefined();
expect(r.core).toBe(47.8); // scorePlank(110, 'M'), verificato in motore-fisici.test.ts
expect(r.plank).toBe(47.8);
});
it('il plank pesa su due sotto-domini (core e plank) dalla STESSA misura, non da due misure diverse', () => {
const m = new Map([['f_plank_sec', 79]]);
const r = sottoDominiFisici(m, 38, 'F');
expect(r.core).toBe(r.plank);
});
});
describe('un check-up e un questionario sono sessioni separate, ma un cliente e uno solo', () => {
it('un check-up 10 giorni dopo il questionario vede ENTRAMBI i sotto-domini', () => {
const db = dbPronto('ISL-0020', 'M');
// Sessione D: questionario del giorno D.
const sQuest = salvaCompilazione(db, {
client_code: 'ISL-0020', data: '2026-08-01', eta: 38, risposte: { q_attivita: 5 },
});
// Sessione D+10: check-up, con una sola misura fisica.
const sCheckup = apriSessione(db, { client_code: 'ISL-0020', data: '2026-08-11', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, sCheckup, 'manuale', [{ test_id: 'f_handgrip_kg', valore_num: 48 }]);
// Prova diretta che le due sessioni sono davvero due righe distinte in `sessioni`:
// senza questo, "il check-up vede il questionario" sarebbe banale (sarebbe la STESSA
// sessione) invece che il punto vero di misureValideAlla.
expect(sQuest).not.toBe(sCheckup);
const sd = sottoDominiDellaSessione(db, sCheckup);
expect(sd).toHaveProperty('questionario_lifestyle'); // dal questionario di D, non di questa sessione
expect(sd).toHaveProperty('handgrip'); // dal check-up di questa sessione
});
it('una sessione del giorno PRIMA del questionario non vede le sue risposte', () => {
const db = dbPronto('ISL-0021', 'M');
salvaCompilazione(db, { client_code: 'ISL-0021', data: '2026-08-01', eta: 38, risposte: { q_attivita: 5 } });
// Aperta DOPO, ma con una data anteriore al questionario: il confronto e' sulla data
// della sessione, non sull'ordine di inserimento.
const sPrima = apriSessione(db, { client_code: 'ISL-0021', data: '2026-07-31', tipo: 'checkup', eta_alla_data: 38 });
const sd = sottoDominiDellaSessione(db, sPrima);
expect(sd).not.toHaveProperty('questionario_lifestyle');
});
it('due misure dello stesso test_id nella stessa sessione: vince quella inserita dopo', () => {
// registraMisure non vieta di scrivere due volte lo stesso test_id nella stessa
// sessione (es. una correzione manuale dopo un refuso di battitura). data di sessione
// e id di sessione sono identici per le due righe: senza uno spareggio esplicito su
// m.id (la riga in `misure`), l'ordine fra le due sarebbe indefinito.
const db = dbPronto('ISL-0023', 'M');
const s = apriSessione(db, { client_code: 'ISL-0023', data: '2026-08-05', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 20 }]); // scritta per prima, refuso
registraMisure(db, s, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 12.8 }]); // corretta subito dopo
const misurate = misureValideAlla(db, s);
expect(misurate.get('f_grasso_pct')).toBe(12.8); // l'ultima scritta, non la prima
});
it('una misura fuori range viene ignorata e sostituita dalla precedente misura valida', () => {
const db = dbPronto('ISL-0022', 'M');
const s1 = apriSessione(db, { client_code: 'ISL-0022', data: '2026-08-05', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s1, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 12.8 }]); // valida
const s2 = apriSessione(db, { client_code: 'ISL-0022', data: '2026-08-20', tipo: 'checkup', eta_alla_data: 38 });
// f_grasso_pct ha range_max 60 (registro.ts): 999 e' un errore di battitura evidente.
const esito = registraMisure(db, s2, 'manuale', [{ test_id: 'f_grasso_pct', valore_num: 999 }]);
expect(esito.fuoriRange).toEqual(['f_grasso_pct']);
const misurate = misureValideAlla(db, s2);
// NON 999 (fuori range, scartata) ne' assente (persa): la precedente misura valida,
// quella di s1.
expect(misurate.get('f_grasso_pct')).toBe(12.8);
const sd = sottoDominiDellaSessione(db, s2);
expect(sd.grasso).toBe(100); // score_fat_percent(12.8, 'M') dell'oracolo: banda 5-13 -> 100
});
});
describe('campiDelQuestionario resta un modulo di sole domande', () => {
it('nessun campo f_* (test fisico) compare fra i campi del questionario', () => {
const db = createLongevityDb(':memory:');
const campi = campiDelQuestionario(db, '2026-08-22');
expect(campi.length).toBeGreaterThan(0);
expect(campi.some((c) => c.id.startsWith('f_'))).toBe(false);
});
});