From f57a07a964cfdffcc27d191c5199eeccabe114b7 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sun, 27 Sep 2026 22:48:07 +0200 Subject: [PATCH] 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 --- .../longevity/motore-fisici-gruppi.test.ts | 179 ++++++++++++++++++ .../longevity/motore-fisici-sessione.test.ts | 35 ++++ 2 files changed, 214 insertions(+) create mode 100644 apps/platforms/tests/longevity/motore-fisici-gruppi.test.ts diff --git a/apps/platforms/tests/longevity/motore-fisici-gruppi.test.ts b/apps/platforms/tests/longevity/motore-fisici-gruppi.test.ts new file mode 100644 index 0000000..a52d3ea --- /dev/null +++ b/apps/platforms/tests/longevity/motore-fisici-gruppi.test.ts @@ -0,0 +1,179 @@ +// La regola dei gruppi (issue #13, manuale batteria test di Nicola del 27/09/2026): per +// alcune sotto-metriche (handgrip, spinta, arti_inferiori, vo2max, hrr, flamingo, +// back_scratch) sottoDominiFisici sceglie fra piu' alternative COMPLETE quella la cui +// misura piu' recente vince (`recenza`, opzionale). Questo file copre la regola da sola, +// con `sottoDominiFisici` chiamata direttamente (mappe di misure a mano, non un DB): i +// valori attesi si calcolano con le STESSE funzioni di test-fisici.ts (gia' verificate +// contro l'oracolo in motore-fisici.test.ts), mai a mano. +import { describe, it, expect } from 'vitest'; +import { sottoDominiFisici } from '../../src/lib/longevity/motore/fisici'; +import * as F from '../../src/lib/longevity/motore/test-fisici'; + +describe('un caso per ogni alternativa che da punteggio', () => { + it('handgrip: media dx/sx (manuale)', () => { + const m = new Map([['f_handgrip_dx_kg', 45], ['f_handgrip_sx_kg', 51]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.handgrip).toBe(F.scoreHandgrip(48, 38, 'M')); // media (45+51)/2 = 48 + }); + + it('handgrip: storico f_handgrip_kg (ancora letto)', () => { + const m = new Map([['f_handgrip_kg', 44]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.handgrip).toBe(F.scoreHandgrip(44, 38, 'M')); + }); + + it('spinta: panca con bilanciere (carico, ripetizioni, peso)', () => { + const m = new Map([['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.spinta).toBe(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M')); + }); + + it('spinta: push-up (invariato)', () => { + const m = new Map([['f_pushup_rip', 24]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.spinta).toBe(F.scorePushup(24, 38, 'M')); + }); + + it('arti_inferiori: sit-to-stand (ripetizioni, eta, sesso, bmi da peso/altezza)', () => { + const m = new Map([['f_sit_to_stand_rip', 14], ['f_peso_kg', 65], ['f_altezza_cm', 160]]); + const r = sottoDominiFisici(m, 65, 'F'); + const bmi = 65 / (160 / 100) ** 2; + expect(r.arti_inferiori).toBe(F.scoreSitToStand(14, 65, 'F', bmi)); + }); + + it('vo2max: MILFIT (potenza, peso)', () => { + const m = new Map([['f_milfit_watt', 150], ['f_peso_kg', 80]]); + const r = sottoDominiFisici(m, 40, 'M'); + const vo2 = F.vo2maxDaMilfit(150, 80); + expect(r.vo2max).toBe(F.scoreVo2max(vo2, 40, 'M')); + }); + + it('vo2max: camminata 2 km UKK (tempo, fc, eta, bmi)', () => { + const m = new Map([ + ['f_walk2km_min', 18], ['f_walk2km_fc_bpm', 130], ['f_peso_kg', 80], ['f_altezza_cm', 180], + ]); + const r = sottoDominiFisici(m, 40, 'M'); + const bmi = 80 / (180 / 100) ** 2; + const vo2 = F.vo2maxDa2kmWalk(18, 130, 40, bmi); + expect(r.vo2max).toBe(F.scoreVo2max(vo2, 40, 'M')); + }); + + it('hrr: MUTT (FC max - FC dopo 1 minuto, manuale)', () => { + const m = new Map([['f_mutt_fc_max_bpm', 180], ['f_mutt_fc_1min_bpm', 140]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.hrr).toBe(F.scoreRecuperoCardiaco(40)); // calo = 180 - 140 + }); + + it('hrr: storico f_hrr_1min_bpm (gia un calo, ancora letto)', () => { + const m = new Map([['f_hrr_1min_bpm', 18]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.hrr).toBe(F.scoreRecuperoCardiaco(18)); + }); + + it('flamingo: media dx/sx occhi aperti (manuale)', () => { + const m = new Map([['f_flamingo_oa_dx', 3], ['f_flamingo_oa_sx', 5]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.flamingo).toBe(F.scoreFlamingo(4)); // media (3+5)/2 = 4 + }); + + it('flamingo: storico f_flamingo_cadute (ancora letto)', () => { + const m = new Map([['f_flamingo_cadute', 6]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.flamingo).toBe(F.scoreFlamingo(6)); + }); + + it('back_scratch: media dx/sx (manuale)', () => { + const m = new Map([['f_back_scratch_dx_cm', -8], ['f_back_scratch_sx_cm', -4]]); + const r = sottoDominiFisici(m, 45, 'F'); + expect(r.back_scratch).toBe(F.scoreBackScratch(-6, 45, 'F')); // media (-8-4)/2 = -6 + }); + + it('back_scratch: storico f_back_scratch_cm (ancora letto)', () => { + const m = new Map([['f_back_scratch_cm', -10]]); + const r = sottoDominiFisici(m, 45, 'F'); + expect(r.back_scratch).toBe(F.scoreBackScratch(-10, 45, 'F')); + }); + + it('la cascata del vo2max per una sessione con solo f_vo2max resta uguale', () => { + const m = new Map([['f_vo2max', 45]]); + const r = sottoDominiFisici(m, 40, 'M'); + expect(r.vo2max).toBe(F.scoreVo2max(45, 40, 'M')); + }); +}); + +describe('un lato solo, senza storico, non da punteggio', () => { + it('handgrip solo destro: nessun punteggio (nessuna media possibile, nessuno storico)', () => { + const m = new Map([['f_handgrip_dx_kg', 45]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.handgrip).toBeUndefined(); + }); + + it('flamingo occhi aperti solo sinistro: nessun punteggio', () => { + const m = new Map([['f_flamingo_oa_sx', 4]]); + const r = sottoDominiFisici(m, 38, 'M'); + expect(r.flamingo).toBeUndefined(); + }); + + it('back_scratch solo sinistro: nessun punteggio', () => { + const m = new Map([['f_back_scratch_sx_cm', -6]]); + const r = sottoDominiFisici(m, 45, 'F'); + expect(r.back_scratch).toBeUndefined(); + }); +}); + +describe('la regola dei gruppi sceglie per recenza, non solo per ordine di priorita', () => { + it('pushup a gennaio + panca a marzo: vale panca (piu recente)', () => { + const m = new Map([ + ['f_pushup_rip', 24], ['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80], + ]); + // recenza: posizione 0 = piu recente. Panca (marzo) piu recente del pushup (gennaio). + const recenza = new Map([ + ['f_panca_bil_carico_kg', 0], ['f_panca_bil_rip', 0], ['f_pushup_rip', 1], + ]); + const r = sottoDominiFisici(m, 38, 'M', recenza); + expect(r.spinta).toBe(F.scoreSollevamentoSuPeso(60, 80, 8, F.TIERS_BENCH_M, F.TIERS_BENCH_F, 'M')); + }); + + it('panca a gennaio + pushup a marzo: vale pushup (piu recente) — il contrario del caso sopra', () => { + const m = new Map([ + ['f_pushup_rip', 24], ['f_panca_bil_carico_kg', 60], ['f_panca_bil_rip', 8], ['f_peso_kg', 80], + ]); + const recenza = new Map([ + ['f_pushup_rip', 0], ['f_panca_bil_carico_kg', 1], ['f_panca_bil_rip', 1], + ]); + const r = sottoDominiFisici(m, 38, 'M', recenza); + expect(r.spinta).toBe(F.scorePushup(24, 38, 'M')); + }); + + it('alternativa incompleta ma piu recente (sit-to-stand senza altezza) non entra: vale lo squat precedente', () => { + const m = new Map([ + ['f_sit_to_stand_rip', 14], // manca f_altezza_cm: bmi non calcolabile, alternativa incompleta + ['f_squat_carico_kg', 100], ['f_squat_rip', 5], ['f_peso_kg', 80], + ]); + // sit-to-stand risulterebbe il piu recente SE fosse completo — ma non lo e', quindi + // resta escluso dal gruppo a prescindere dalla recenza (regola: solo le alternative + // complete entrano). + const recenza = new Map([ + ['f_sit_to_stand_rip', 0], ['f_squat_carico_kg', 1], ['f_squat_rip', 1], + ]); + const r = sottoDominiFisici(m, 65, 'M', recenza); + expect(r.arti_inferiori).toBe(F.scoreSollevamentoSuPeso(100, 80, 5, F.TIERS_SQUAT_M, F.TIERS_SQUAT_F, 'M')); + }); +}); + +describe('i test "solo registrati" (nessuna tabella nel manuale) non producono chiavi', () => { + it('panca manubri, trazioni, plank ginocchia, 6MWT, flamingo occhi chiusi: nessuna chiave nel risultato', () => { + const m = new Map([ + ['f_panca_man_carico_kg', 12], ['f_panca_man_rip', 10], + ['f_trazioni_rip', 8], + ['f_plank_ginocchia_sec', 90], + ['f_6mwt_m', 550], + ['f_flamingo_oc_dx', 10], ['f_flamingo_oc_sx', 12], + ['f_mutt_fc_2min_bpm', 110], + ['f_fc_riposo_bpm', 60], + ]); + const r = sottoDominiFisici(m, 38, 'M'); + // nessuna di queste misure alimenta una funzione di scoring: il risultato resta {} + expect(r).toEqual({}); + }); +}); diff --git a/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts b/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts index b67c909..6e5e73c 100644 --- a/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts +++ b/apps/platforms/tests/longevity/motore-fisici-sessione.test.ts @@ -16,6 +16,7 @@ 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:'); @@ -232,6 +233,40 @@ describe('un check-up e un questionario sono sessioni separate, ma un cliente e }); }); +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:');