From 67ad9701c13baced9a536a624c21b1761942fa18 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Mon, 28 Sep 2026 06:43:57 +0000 Subject: [PATCH] =?UTF-8?q?longevity:=20#14=20unit=C3=A0=20A,=20andamento?= =?UTF-8?q?=20dei=20test=20nel=20tempo=20(dati)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- apps/platforms/src/lib/longevity/andamento.ts | 319 ++++++++++++++++++ .../tests/longevity/andamento.test.ts | 194 +++++++++++ 2 files changed, 513 insertions(+) create mode 100644 apps/platforms/src/lib/longevity/andamento.ts create mode 100644 apps/platforms/tests/longevity/andamento.test.ts diff --git a/apps/platforms/src/lib/longevity/andamento.ts b/apps/platforms/src/lib/longevity/andamento.ts new file mode 100644 index 0000000..2c0d32c --- /dev/null +++ b/apps/platforms/src/lib/longevity/andamento.ts @@ -0,0 +1,319 @@ +// +// L'andamento nel tempo dei VALORI DEI TEST del check-up (issue #14): a differenza di +// progressione.ts — che mostra i punteggi congelati (assi, macro, Fitness Age), uno per +// sessione — questo modulo legge le misure GREZZE (`misure`, mai `score`), un punto per +// sessione e per test_id, così come sono state prese: nessuna curva, nessuna normalizzazione. +// +// Il catalogo qui sotto rispecchia il modulo cartaceo del check-up (STRUTTURA_RAW in +// gestionale.ts: Pre-test -> Mobilità -> Equilibrio -> Presa -> Core -> Forza spinta -> +// Forza trazione -> Arti inferiori -> Aerobico -> Strumenti), ma NON lo importa: le pagine +// cliente non devono importare moduli del gestionale (issue #14, unità A — questo file non +// importa gestionale.ts, giornale.ts né consulenza.ts). Le etichette e le unità di ogni +// linea vengono SEMPRE dal registro (`registro_test`), mai ripetute qui: solo i test_id, il +// titolo di ogni grafico (un raggruppamento dell'interfaccia, non un dato clinico) e chi lo +// può vedere (`soloTrainer`). +// +// Un test dismesso (es. f_handgrip_kg, sostituito da f_handgrip_dx_kg/f_handgrip_sx_kg il +// 28/09/2026, issue #13) resta nel suo grafico storico: è storia del cliente, non sparisce +// (DECISIONI f dell'issue #14 — "il cliente vede anche i dismessi"). Le etichette/unità si +// leggono da TUTTE le righe di `registro_test`, non da `testAttivi` (che filtra per data e +// farebbe sparire l'etichetta di un test non più attivo oggi). +// +// Ogni test_id del catalogo qui sotto è stato verificato a mano contro registro.ts (FISICI): +// nessuna divergenza — comprese le tre alternative storiche (f_handgrip_kg, +// f_back_scratch_cm, f_flamingo_cadute) e gli otto step del MUTT (f_mutt_fc_7_5 … +// f_mutt_fc_14_5, generati in registro.ts con lo stesso `String(kmh).replace('.', '_')` +// usato qui sotto). +import type Database from 'better-sqlite3'; + +/** Una linea del grafico: un test_id, più — quando il grafico lo prevede (es. il carico di + * un 5RM) — il test_id di un secondo valore da mostrare solo nel tooltip (le ripetizioni), + * mai come una linea a sé stante. */ +type LineaCatalogo = { testId: string; extraTestId?: string }; + +type GraficoCatalogo = { titolo: string; linee: LineaCatalogo[]; soloTrainer?: boolean }; + +type SezioneCatalogo = { titolo: string; grafici: GraficoCatalogo[] }; + +// Gli otto step del MUTT, un km/h alla volta (stessa lista di registro.ts), un test_id +// ciascuno: "FC per step solo trainer (8 linee in un grafico o elenco: scelta dell'agente)" +// — scelto un grafico solo con 8 linee, più semplice di 8 grafici da un punto ciascuno. +const MUTT_STEP_KMH = [7.5, 8.5, 9.5, 10.5, 11.5, 12.5, 13.5, 14.5] as const; + +const CATALOGO: SezioneCatalogo[] = [ + { titolo: 'Pre-test', grafici: [ + { titolo: 'Peso', linee: [{ testId: 'f_peso_kg' }] }, + { titolo: 'Frequenza cardiaca a riposo', linee: [{ testId: 'f_fc_riposo_bpm' }], soloTrainer: true }, + ] }, + { titolo: 'Mobilità', grafici: [ + { titolo: 'Back scratch', linee: [{ testId: 'f_back_scratch_dx_cm' }, { testId: 'f_back_scratch_sx_cm' }] }, + { titolo: 'Back scratch (storico)', linee: [{ testId: 'f_back_scratch_cm' }] }, + { titolo: 'Sit and reach', linee: [{ testId: 'f_sit_reach_cm' }] }, + ] }, + { titolo: 'Equilibrio', grafici: [ + { titolo: 'Flamingo, occhi aperti', linee: [{ testId: 'f_flamingo_oa_dx' }, { testId: 'f_flamingo_oa_sx' }] }, + { titolo: 'Flamingo, occhi chiusi', linee: [{ testId: 'f_flamingo_oc_dx' }, { testId: 'f_flamingo_oc_sx' }] }, + { titolo: 'Flamingo (storico)', linee: [{ testId: 'f_flamingo_cadute' }] }, + ] }, + { titolo: 'Presa', grafici: [ + { titolo: 'Handgrip', linee: [{ testId: 'f_handgrip_dx_kg' }, { testId: 'f_handgrip_sx_kg' }] }, + { titolo: 'Handgrip (storico)', linee: [{ testId: 'f_handgrip_kg' }] }, + ] }, + { titolo: 'Core', grafici: [ + { titolo: 'Plank sulle ginocchia', linee: [{ testId: 'f_plank_ginocchia_sec' }] }, + { titolo: 'Plank', linee: [{ testId: 'f_plank_sec' }] }, + ] }, + { titolo: 'Spinta', grafici: [ + { titolo: 'Panca con manubri', linee: [{ testId: 'f_panca_man_carico_kg', extraTestId: 'f_panca_man_rip' }] }, + { titolo: 'Push-up', linee: [{ testId: 'f_pushup_rip' }] }, + { titolo: 'Panca con bilanciere', linee: [{ testId: 'f_panca_bil_carico_kg', extraTestId: 'f_panca_bil_rip' }] }, + ] }, + { titolo: 'Trazione', grafici: [ + { titolo: 'Rematore', linee: [{ testId: 'f_row_carico_kg', extraTestId: 'f_row_rip' }] }, + { titolo: 'Trazioni', linee: [{ testId: 'f_trazioni_rip' }] }, + ] }, + { titolo: 'Arti inferiori', grafici: [ + { titolo: 'Sit-to-stand', linee: [{ testId: 'f_sit_to_stand_rip' }] }, + { titolo: 'Squat', linee: [{ testId: 'f_squat_carico_kg', extraTestId: 'f_squat_rip' }] }, + ] }, + { titolo: 'Aerobico', grafici: [ + { titolo: '6 Minute Walk Test', linee: [{ testId: 'f_6mwt_m' }] }, + { titolo: "Queen's College Step Test", linee: [{ testId: 'f_step_hr_recovery' }] }, + { titolo: 'Camminata 2 km', linee: [{ testId: 'f_walk2km_min', extraTestId: 'f_walk2km_fc_bpm' }] }, + { titolo: 'MUTT', linee: [{ testId: 'f_mutt_vam' }] }, + // Separato dal grafico MUTT sopra (non fuso in un solo grafico con 4 linee): la VAM è + // visibile al cliente, il dettaglio della frequenza cardiaca no (DECISIONI f) — e + // `soloTrainer` è una proprietà del grafico intero, non della singola linea. + { titolo: 'MUTT, dettaglio frequenza cardiaca', soloTrainer: true, linee: [ + { testId: 'f_mutt_fc_max_bpm' }, { testId: 'f_mutt_fc_1min_bpm' }, { testId: 'f_mutt_fc_2min_bpm' }, + ] }, + { titolo: 'MUTT, frequenza cardiaca per step', soloTrainer: true, + linee: MUTT_STEP_KMH.map((kmh) => ({ testId: `f_mutt_fc_${String(kmh).replace('.', '_')}` })) }, + { titolo: 'MILFIT', linee: [{ testId: 'f_milfit_watt' }] }, + { titolo: 'VO2max (misura diretta)', linee: [{ testId: 'f_vo2max' }] }, + { titolo: 'Recupero cardiaco (storico)', linee: [{ testId: 'f_hrr_1min_bpm' }], soloTrainer: true }, + ] }, + { titolo: 'Strumenti', grafici: [ + { titolo: 'Massa grassa', linee: [{ testId: 'f_grasso_pct' }] }, + { titolo: 'Massa muscolare', linee: [{ testId: 'f_muscolo_pct' }] }, + { titolo: 'Rapporto vita-fianchi (WHR)', linee: [{ testId: 'f_whr' }] }, + { titolo: 'HRV', linee: [{ testId: 'f_hrv_score' }] }, + // Outreach e buckling insieme (scelta dell'agente, prevista dall'issue): stessa unità + // (gradi), stesso strumento (Wellness Tower spalla), un solo grafico più leggibile. + { titolo: 'Spalla (outreach/buckling)', linee: [{ testId: 'f_spalla_outreach_deg' }, { testId: 'f_spalla_buckling_deg' }] }, + { titolo: 'Pressione arteriosa', linee: [{ testId: 'f_pa_sistolica' }, { testId: 'f_pa_diastolica' }] }, + { titolo: 'Saturazione O2 (SpO2)', linee: [{ testId: 'f_spo2_pct' }] }, + ] }, +]; + +const TITOLO_ALTRE_MISURE = 'Altre misure'; + +export type PuntoTest = { data: string; sessioneId: number; valore: number; extra?: number }; +export type LineaAndamento = { testId: string; etichetta: string; punti: PuntoTest[] }; +export type GraficoAndamento = { titolo: string; unita: string | null; linee: LineaAndamento[] }; +export type CambioSezione = { data: string; da: string; a: string }; +export type SezioneAndamento = { titolo: string; grafici: GraficoAndamento[]; cambi: CambioSezione[] }; + +type RigaMisura = { id: number; testId: string; valoreNum: number; sessioneId: number; data: string }; +type PuntoBase = { data: string; sessioneId: number; valore: number }; + +/** + * Tutte le misure valide (stessa regola di `misureValideAlla` in motore/index.ts:165 — + * `fuori_range = 0` oppure confermata dal trainer, `verifica = 'confermata'`) di un + * cliente, in ordine cronologico. A differenza di `misureValideAlla` — che per ogni + * test_id tiene solo il valore più recente valido a una data — qui serve TUTTA la storia: + * un punto per sessione. A parità di sessione (più righe dello stesso test_id, es. una + * correzione manuale) vince la riga con id più alto — lo stesso spareggio del motore + * (`righeMisureGrezze`, motore/index.ts). + * Legge sia i test `f_*` (check-up) sia `q_*` (questionario): il filtro sul prefisso è + * responsabilità del chiamante — qui restano fuori solo le misure non valide. + */ +function misureGrezzeDelCliente(db: Database.Database, clientCode: string): RigaMisura[] { + const righe = db.prepare( + `SELECT m.id AS id, m.test_id AS testId, m.valore_num AS valoreNum, + m.sessione_id AS sessioneId, s.data AS data + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + WHERE m.client_code = ? + AND m.valore_num IS NOT NULL + AND (m.fuori_range = 0 OR m.verifica = 'confermata') + ORDER BY s.data ASC, s.id ASC, m.id DESC` + ).all(clientCode) as RigaMisura[]; + + const visti = new Set(); + const risultato: RigaMisura[] = []; + for (const r of righe) { + const chiave = `${r.testId}|${r.sessioneId}`; + if (visti.has(chiave)) continue; // già presa la riga con id più alto per questa sessione + visti.add(chiave); + risultato.push(r); + } + return risultato; +} + +/** Etichetta e unità di un test_id, da TUTTE le righe di `registro_test` (non da + * `testAttivi`, che esclude per data i test dismessi: qui servono anche quelli, per lo + * storico). Assente solo se il test_id non è mai stato nel registro — non dovrebbe + * succedere per una riga che viene da `misure` (`registraMisure` rifiuta un test_id + * sconosciuto all'inserimento). */ +function vociRegistro(db: Database.Database): Map { + const righe = db.prepare(`SELECT test_id AS testId, etichetta, unita FROM registro_test`).all() as + { testId: string; etichetta: string; unita: string | null }[]; + return new Map(righe.map((r) => [r.testId, { etichetta: r.etichetta, unita: r.unita }])); +} + +/** + * L'andamento nel tempo di ogni test del check-up per un cliente: una sezione per + * capacità (l'ordine del modulo cartaceo), ciascuna con i grafici che hanno almeno una + * misura valida e con i cambi di test segnalati (issue #14, DECISIONI a/e/f/g). + * + * `trainer: false` esclude i grafici `soloTrainer` (FC a riposo, dettaglio MUTT, FC per + * step, recupero cardiaco storico) — sia dai grafici mostrati sia dal calcolo dei cambi: + * un cliente non deve vedere il nome di un test che non può vedere, nemmeno dentro il testo + * di un cambio. + * + * Un test_id `f_*` con misure valide ma assente dal catalogo finisce in una sezione + * «Altre misure», in un grafico a sé (mai perso). I test del questionario (`q_*`) restano + * fuori da questo modulo per costruzione: sono percezioni del cliente, già rappresentate + * dagli assi in progressione.ts, non "test" del check-up — quindi non entrano nemmeno in + * «Altre misure». + * + * `g.linee.length > 0 && grafico.linee[0]` sono garantiti dal catalogo qui sopra (mai un + * grafico con zero linee dichiarate). + */ +export function andamentoTest( + db: Database.Database, clientCode: string, opts: { trainer: boolean } +): SezioneAndamento[] { + const misure = misureGrezzeDelCliente(db, clientCode); + const registro = vociRegistro(db); + + // test_id -> i suoi punti, in ordine cronologico (misureGrezzeDelCliente è già ordinata + // data ASC, sessione ASC). + const puntiPerTest = new Map(); + for (const r of misure) { + const lista = puntiPerTest.get(r.testId); + const punto = { data: r.data, sessioneId: r.sessioneId, valore: r.valoreNum }; + if (lista) lista.push(punto); + else puntiPerTest.set(r.testId, [punto]); + } + + // Per il tooltip: valore del test_id "extra" nella STESSA sessione del punto principale. + const extraPerTestESessione = new Map>(); + for (const [testId, punti] of puntiPerTest) { + extraPerTestESessione.set(testId, new Map(punti.map((p) => [p.sessioneId, p.valore]))); + } + + function costruisciLinea(l: LineaCatalogo): LineaAndamento | null { + const punti = puntiPerTest.get(l.testId); + if (!punti || punti.length === 0) return null; + const voce = registro.get(l.testId); + const mappaExtra = l.extraTestId ? extraPerTestESessione.get(l.extraTestId) : undefined; + return { + testId: l.testId, + etichetta: voce?.etichetta ?? l.testId, + punti: punti.map((p) => { + const extra = mappaExtra?.get(p.sessioneId); + return extra === undefined ? { ...p } : { ...p, extra }; + }), + }; + } + + function costruisciGrafico(g: GraficoCatalogo): GraficoAndamento | null { + const linee: LineaAndamento[] = []; + for (const l of g.linee) { + const linea = costruisciLinea(l); + if (linea) linee.push(linea); + } + if (linee.length === 0) return null; + const primoTestId = g.linee[0]!.testId; + return { titolo: g.titolo, unita: registro.get(primoTestId)?.unita ?? null, linee }; + } + + const idCatalogati = new Set(CATALOGO.flatMap((s) => s.grafici.flatMap((g) => g.linee.map((l) => l.testId)))); + + const sezioni: SezioneAndamento[] = []; + + for (const s of CATALOGO) { + const graficiSezione = s.grafici.filter((g) => opts.trainer || !g.soloTrainer); + const graficiVisibili: GraficoAndamento[] = []; + for (const g of graficiSezione) { + const grafico = costruisciGrafico(g); + if (grafico) graficiVisibili.push(grafico); + } + if (graficiVisibili.length === 0) continue; + + sezioni.push({ + titolo: s.titolo, + grafici: graficiVisibili, + cambi: cambiDellaSezione(graficiSezione, graficiVisibili, puntiPerTest), + }); + } + + // «Altre misure»: ogni test_id f_* con misure valide ma non nel catalogo, un grafico a sé. + const idAltri = [...puntiPerTest.keys()].filter((id) => id.startsWith('f_') && !idCatalogati.has(id)).sort(); + if (idAltri.length > 0) { + const grafici: GraficoAndamento[] = idAltri.map((id) => { + const voce = registro.get(id); + const etichetta = voce?.etichetta ?? id; + return { + titolo: etichetta, + unita: voce?.unita ?? null, + linee: [{ testId: id, etichetta, punti: puntiPerTest.get(id)!.map((p) => ({ ...p })) }], + }; + }); + sezioni.push({ titolo: TITOLO_ALTRE_MISURE, grafici, cambi: [] }); + } + + return sezioni; +} + +/** + * I cambi di test dentro una sezione: per ogni sessione con almeno una misura valida di + * QUALUNQUE grafico della sezione (fra quelli visibili a chi guarda), si prende il primo + * grafico — nell'ordine del catalogo — che ha una misura in quella sessione, e si segna un + * cambio ogni volta che questo grafico è diverso da quello della sessione precedente + * (DECISIONI a dell'issue #14: "Dal 12/03/2027: Trazioni (prima: Rematore 5RM)"). + * + * Applicato a livello di SEZIONE, non solo alle "alternative" vere e proprie (es. rematore + * vs trazioni): in una sezione con un solo grafico visibile non produce mai un cambio; in + * una sezione che mescola un'alternativa storica con un test sempre presente (es. Mobilità: + * back scratch/back scratch storico insieme a sit and reach, che non sono alternativi fra + * loro) può in teoria segnalare un cambio se in una sessione manca del tutto il primo + * grafico del catalogo mentre è presente un altro — caso raro nella pratica (i test si + * misurano nella stessa seduta) e non coperto dai test di questa unità: lasciato così + * perché è la lettura più diretta del testo dell'analisi ("calcolati per sezione ... il + * primo test usato in ogni sessione"). Da rivedere se in produzione risultasse rumoroso. + */ +function cambiDellaSezione( + graficiSezioneOrdinati: GraficoCatalogo[], + graficiVisibili: GraficoAndamento[], + puntiPerTest: Map +): CambioSezione[] { + if (graficiVisibili.length <= 1) return []; + const titoliVisibili = new Set(graficiVisibili.map((g) => g.titolo)); + + // sessioneId -> {data, titolo del primo grafico — nell'ordine del catalogo — con una + // misura in quella sessione}. + const perSessione = new Map(); + for (const g of graficiSezioneOrdinati) { + if (!titoliVisibili.has(g.titolo)) continue; + for (const l of g.linee) { + const punti = puntiPerTest.get(l.testId) ?? []; + for (const p of punti) { + if (!perSessione.has(p.sessioneId)) perSessione.set(p.sessioneId, { data: p.data, titolo: g.titolo }); + } + } + } + + const sessioniOrdinate = [...perSessione.entries()] + .sort(([idA, a], [idB, b]) => (a.data < b.data ? -1 : a.data > b.data ? 1 : idA - idB)) + .map(([, v]) => v); + + const cambi: CambioSezione[] = []; + for (let i = 1; i < sessioniOrdinate.length; i++) { + const prima = sessioniOrdinate[i - 1]!; + const dopo = sessioniOrdinate[i]!; + if (dopo.titolo !== prima.titolo) cambi.push({ data: dopo.data, da: prima.titolo, a: dopo.titolo }); + } + return cambi; +} diff --git a/apps/platforms/tests/longevity/andamento.test.ts b/apps/platforms/tests/longevity/andamento.test.ts new file mode 100644 index 0000000..d483dcd --- /dev/null +++ b/apps/platforms/tests/longevity/andamento.test.ts @@ -0,0 +1,194 @@ +// tests/longevity/andamento.test.ts — issue #14, unità A (dati). +// andamentoTest legge le misure GREZZE di `misure` (mai `score`), un punto per sessione e +// per test_id: serie con buchi, doppie linee (dx/sx), tooltip del 5RM, cambio di test +// segnalato per sezione, misure fuori range escluse finché non confermate, visibilità +// trainer/cliente, test fuori catalogo in «Altre misure». +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { apriSessione, registraMisure, verificaMisura } from '../../src/lib/longevity/misure'; +import { andamentoTest } from '../../src/lib/longevity/andamento'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); + return db; +} + +function checkup(db: ReturnType, data: string) { + return apriSessione(db, { client_code: 'ISL-0001', data, tipo: 'checkup', eta_alla_data: 40 }); +} + +describe('andamentoTest: serie nel tempo, buchi e doppie linee', () => { + it('un grafico con buchi: solo le sessioni con misura hanno un punto', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + registraMisure(db, s1, 'manuale', [{ test_id: 'f_peso_kg', valore_num: 80 }]); + const s2 = checkup(db, '2026-03-10'); + registraMisure(db, s2, 'manuale', [{ test_id: 'f_squat_carico_kg', valore_num: 100 }]); // niente peso + const s3 = checkup(db, '2026-05-10'); + registraMisure(db, s3, 'manuale', [{ test_id: 'f_peso_kg', valore_num: 78 }]); + + const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true }); + const preTest = sezioni.find((s) => s.titolo === 'Pre-test')!; + const peso = preTest.grafici.find((g) => g.titolo === 'Peso')!; + expect(peso.linee).toHaveLength(1); + expect(peso.linee[0]!.punti).toEqual([ + { data: '2026-01-10', sessioneId: s1, valore: 80 }, + { data: '2026-05-10', sessioneId: s3, valore: 78 }, + ]); + }); + + it('destro e sinistro sono due linee nello stesso grafico', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + registraMisure(db, s1, 'manuale', [ + { test_id: 'f_handgrip_dx_kg', valore_num: 32 }, + { test_id: 'f_handgrip_sx_kg', valore_num: 28 }, + ]); + + const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true }); + const presa = sezioni.find((s) => s.titolo === 'Presa')!; + const handgrip = presa.grafici.find((g) => g.titolo === 'Handgrip')!; + expect(handgrip.linee.map((l) => l.testId).sort()).toEqual(['f_handgrip_dx_kg', 'f_handgrip_sx_kg']); + expect(handgrip.unita).toBe('kg'); + const dx = handgrip.linee.find((l) => l.testId === 'f_handgrip_dx_kg')!; + expect(dx.punti[0]!.valore).toBe(32); + // etichetta letta dal registro (non ripetuta nel catalogo): contiene "destro". + expect(dx.etichetta.toLowerCase()).toContain('destro'); + }); + + it('il tooltip del 5RM porta le ripetizioni della stessa sessione, mai come linea a sé', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + registraMisure(db, s1, 'manuale', [ + { test_id: 'f_row_carico_kg', valore_num: 60 }, + { test_id: 'f_row_rip', valore_num: 5 }, + ]); + + const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true }); + const trazione = sezioni.find((s) => s.titolo === 'Trazione')!; + const rematore = trazione.grafici.find((g) => g.titolo === 'Rematore')!; + expect(rematore.linee).toHaveLength(1); // f_row_rip non è una linea a sé + expect(rematore.linee[0]!.testId).toBe('f_row_carico_kg'); + expect(rematore.linee[0]!.punti[0]).toEqual({ data: '2026-01-10', sessioneId: s1, valore: 60, extra: 5 }); + }); +}); + +describe('andamentoTest: cambio di test dentro la stessa sezione (Trazione)', () => { + it('rematore -> trazioni -> rematore: due cambi, con la data giusta', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + registraMisure(db, s1, 'manuale', [{ test_id: 'f_row_carico_kg', valore_num: 60 }, { test_id: 'f_row_rip', valore_num: 5 }]); + const s2 = checkup(db, '2026-03-10'); + registraMisure(db, s2, 'manuale', [{ test_id: 'f_row_carico_kg', valore_num: 65 }, { test_id: 'f_row_rip', valore_num: 5 }]); + const s3 = checkup(db, '2026-05-10'); + registraMisure(db, s3, 'manuale', [{ test_id: 'f_trazioni_rip', valore_num: 8 }]); + const s4 = checkup(db, '2026-07-10'); + registraMisure(db, s4, 'manuale', [{ test_id: 'f_row_carico_kg', valore_num: 70 }, { test_id: 'f_row_rip', valore_num: 6 }]); + + const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true }); + const trazione = sezioni.find((s) => s.titolo === 'Trazione')!; + expect(trazione.cambi).toEqual([ + { data: '2026-05-10', da: 'Rematore', a: 'Trazioni' }, + { data: '2026-07-10', da: 'Trazioni', a: 'Rematore' }, + ]); + // entrambi i grafici restano, coi loro punti: un cambio non unisce le due serie. + // Rematore ha 3 punti (s1, s2, s4): un cambio segna il momento, non cancella la serie. + expect(trazione.grafici.find((g) => g.titolo === 'Rematore')!.linee[0]!.punti).toHaveLength(3); + expect(trazione.grafici.find((g) => g.titolo === 'Trazioni')!.linee[0]!.punti).toHaveLength(1); + }); + + it('un solo test in tutta la sezione: nessun cambio', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + registraMisure(db, s1, 'manuale', [{ test_id: 'f_trazioni_rip', valore_num: 8 }]); + const s2 = checkup(db, '2026-03-10'); + registraMisure(db, s2, 'manuale', [{ test_id: 'f_trazioni_rip', valore_num: 9 }]); + + const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true }); + const trazione = sezioni.find((s) => s.titolo === 'Trazione')!; + expect(trazione.cambi).toEqual([]); + }); +}); + +describe('andamentoTest: solo misure valide', () => { + it('fuori range non confermata è esclusa; confermata entra', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + // f_milfit_watt: range 20-500 (registro.ts) — 999 è fuori range. + const { fuoriRange } = registraMisure(db, s1, 'manuale', [{ test_id: 'f_milfit_watt', valore_num: 999 }]); + expect(fuoriRange).toEqual(['f_milfit_watt']); + + let sezioni = andamentoTest(db, 'ISL-0001', { trainer: true }); + expect(sezioni.find((s) => s.titolo === 'Aerobico')).toBeUndefined(); + + const misuraRiga = db.prepare( + `SELECT id FROM misure WHERE sessione_id = ? AND test_id = 'f_milfit_watt'` + ).get(s1) as { id: number }; + const esito = verificaMisura(db, { misuraId: misuraRiga.id, esito: 'confermata', operatore: 'nicola' }); + expect(esito.ok).toBe(true); + + sezioni = andamentoTest(db, 'ISL-0001', { trainer: true }); + const aerobico = sezioni.find((s) => s.titolo === 'Aerobico')!; + const milfit = aerobico.grafici.find((g) => g.titolo === 'MILFIT')!; + expect(milfit.linee[0]!.punti).toEqual([{ data: '2026-01-10', sessioneId: s1, valore: 999 }]); + }); +}); + +describe('andamentoTest: visibilità trainer vs cliente', () => { + it('il cliente non vede i grafici soloTrainer, il trainer sì', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + registraMisure(db, s1, 'manuale', [ + { test_id: 'f_peso_kg', valore_num: 80 }, + { test_id: 'f_fc_riposo_bpm', valore_num: 60 }, + ]); + + const trainer = andamentoTest(db, 'ISL-0001', { trainer: true }); + const preTrainer = trainer.find((s) => s.titolo === 'Pre-test')!; + expect(preTrainer.grafici.map((g) => g.titolo).sort()).toEqual(['Frequenza cardiaca a riposo', 'Peso']); + + const cliente = andamentoTest(db, 'ISL-0001', { trainer: false }); + const preCliente = cliente.find((s) => s.titolo === 'Pre-test')!; + expect(preCliente.grafici.map((g) => g.titolo)).toEqual(['Peso']); + }); + + it('una sezione fatta solo di grafici soloTrainer sparisce del tutto per il cliente', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + registraMisure(db, s1, 'manuale', [{ test_id: 'f_fc_riposo_bpm', valore_num: 60 }]); + + const cliente = andamentoTest(db, 'ISL-0001', { trainer: false }); + expect(cliente.find((s) => s.titolo === 'Pre-test')).toBeUndefined(); + + const trainer = andamentoTest(db, 'ISL-0001', { trainer: true }); + expect(trainer.find((s) => s.titolo === 'Pre-test')).toBeDefined(); + }); +}); + +describe('andamentoTest: test fuori catalogo', () => { + it('un test f_* misurato ma non nel catalogo finisce in «Altre misure», mai perso', () => { + const db = dbPronto(); + const s1 = checkup(db, '2026-01-10'); + registraMisure(db, s1, 'manuale', [{ test_id: 'f_altezza_cm', valore_num: 175 }]); + + const sezioni = andamentoTest(db, 'ISL-0001', { trainer: true }); + const altre = sezioni.find((s) => s.titolo === 'Altre misure')!; + expect(altre).toBeDefined(); + expect(altre.grafici).toHaveLength(1); + expect(altre.grafici[0]!.linee[0]!.testId).toBe('f_altezza_cm'); + expect(altre.grafici[0]!.unita).toBe('cm'); + expect(altre.grafici[0]!.linee[0]!.punti[0]!.valore).toBe(175); + }); +}); + +describe('andamentoTest: cliente senza misure', () => { + it('nessuna sessione, nessuna misura -> nessuna sezione', () => { + const db = dbPronto(); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','M')`).run(); + const sezioni = andamentoTest(db, 'ISL-0002', { trainer: true }); + expect(sezioni).toEqual([]); + }); +});