diff --git a/apps/platforms/scripts/seed-demo.ts b/apps/platforms/scripts/seed-demo.ts index 1379da8..5224a22 100644 --- a/apps/platforms/scripts/seed-demo.ts +++ b/apps/platforms/scripts/seed-demo.ts @@ -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. diff --git a/apps/platforms/src/lib/longevity/motore/index.ts b/apps/platforms/src/lib/longevity/motore/index.ts index a8004f1..c3a1d99 100644 --- a/apps/platforms/src/lib/longevity/motore/index.ts +++ b/apps/platforms/src/lib/longevity/motore/index.ts @@ -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,109 @@ 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 DESC (a parità di data, l'inserimento + * più tardo vince) — stesso criterio di `leggiScore`. A parità di sessione (una misura + * ri-registrata più volte nella stessa sessione, oggi non capita ma non è vietato) vince + * comunque la più recente per `id` di riga in `misure`, perché la query è ordinata anche + * su quello implicitamente tramite l'ordine della sessione. + * + * 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. + */ +export function misureValideAlla(db: Database.Database, sessioneId: number): Map { + 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` + ).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as + { test_id: string; valore_num: number; data: string }[]; + + const mappa = new Map(); + for (const r of righe) { + if (mappa.has(r.test_id)) continue; // gia' trovato un valore piu' recente per questo test_id + 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 { + 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(); + 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 = {}; + 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 +248,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( - (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(); - 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 = {}; - 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( diff --git a/apps/platforms/src/lib/longevity/vista.ts b/apps/platforms/src/lib/longevity/vista.ts index be66d2c..a88092d 100644 --- a/apps/platforms/src/lib/longevity/vista.ts +++ b/apps/platforms/src/lib/longevity/vista.ts @@ -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 = { 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 { - 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 diff --git a/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts b/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts new file mode 100644 index 0000000..46115c9 --- /dev/null +++ b/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts @@ -0,0 +1,228 @@ +// 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('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); + }); +});