f57a07a964
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>
278 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|