// 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'; import * as F from '../../src/lib/longevity/motore/test-fisici'; // per i valori attesi della regola dei gruppi (#13) 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('la regola dei gruppi (#13) su un DB vero: due sessioni, lo score usa il test piu recente', () => { it('pushup a gennaio, panca a marzo: il check-up di marzo usa la panca', () => { const db = dbPronto('ISL-0030', 'M'); const sGennaio = apriSessione(db, { client_code: 'ISL-0030', data: '2026-01-15', tipo: 'checkup', eta_alla_data: 38 }); registraMisure(db, sGennaio, 'manuale', [ { test_id: 'f_peso_kg', valore_num: 80 }, { test_id: 'f_pushup_rip', valore_num: 24 }, ]); const sMarzo = apriSessione(db, { client_code: 'ISL-0030', data: '2026-03-10', tipo: 'checkup', eta_alla_data: 38 }); registraMisure(db, sMarzo, 'manuale', [ { test_id: 'f_panca_bil_carico_kg', valore_num: 60 }, { test_id: 'f_panca_bil_rip', valore_num: 8 }, ]); // misureValideAlla porta avanti ANCHE il pushup di gennaio (mai sovrascritto): al check-up // di marzo il gruppo "spinta" vede entrambe le alternative complete, e vince la piu recente. const sd = sottoDominiDellaSessione(db, sMarzo); expect(sd.spinta).toBeCloseTo(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M'), 5); }); it('panca a gennaio, pushup a marzo: il check-up di marzo usa il pushup (il contrario del caso sopra)', () => { const db = dbPronto('ISL-0031', 'M'); const sGennaio = apriSessione(db, { client_code: 'ISL-0031', data: '2026-01-15', tipo: 'checkup', eta_alla_data: 38 }); registraMisure(db, sGennaio, 'manuale', [ { test_id: 'f_peso_kg', valore_num: 80 }, { test_id: 'f_panca_bil_carico_kg', valore_num: 60 }, { test_id: 'f_panca_bil_rip', valore_num: 8 }, ]); const sMarzo = apriSessione(db, { client_code: 'ISL-0031', data: '2026-03-10', tipo: 'checkup', eta_alla_data: 38 }); registraMisure(db, sMarzo, 'manuale', [{ test_id: 'f_pushup_rip', valore_num: 24 }]); const sd = sottoDominiDellaSessione(db, sMarzo); expect(sd.spinta).toBeCloseTo(F.scorePushup(24, 38, 'M'), 5); }); }); 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); }); });