Files
InsanityLab-web/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts
T
Adriano f57a07a964 longevity: test unitari e di sessione per la regola dei gruppi (#13)
motore-fisici-gruppi.test.ts (nuovo): un caso per ogni alternativa che da
punteggio (handgrip/spinta/arti_inferiori/vo2max/hrr/flamingo/back_scratch,
media dx/sx e storico), un lato solo -> nessun punteggio, recenza fra
alternative (pushup/panca in entrambe le direzioni), alternativa piu recente
ma incompleta esclusa (sit-to-stand senza altezza), i test "solo registrati"
non producono chiavi.

motore-fisici-sessione.test.ts: due nuovi casi end-to-end su DB (due sessioni,
due test diversi per la stessa sotto-metrica) che confermano che lo score usa
il test piu recente, in entrambe le direzioni.

Refs #13

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:48:07 +02:00

278 lines
16 KiB
TypeScript

// 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);
});
});