motore: chiude i tre buchi laterali della barriera dell'insufficiente
- eta_alla_data NULL non usa più il ripiego a zero: senza età la Fitness Age è null, non un'età di forma negativa dichiarata valida. Gli assi e i macro-score restano calcolabili (non dipendono dall'età). - applicaCurva valida i parametri richiesti da ciascuna curva prima di applicarla: un registro con params vuoti/incompleti dà null, mai il punteggio pieno che dava lerp con estremi indefiniti. - calcolaSessione esclude le misure fuori_range=1 (§9 della spec): non entrano nello score finché non esiste la colonna di conferma (§12, punto aperto). - leggiScore(db, sessioneId): legge la tabella score ritipata come Punteggio, con l'ultimo calcolo per tipo+elemento esplicito nella query (MAX(id)), non l'ordine naturale delle righe — salvaScore non sovrascrive di proposito (la storia degli score si tiene, discende dal congelamento della §6). - documentata la seconda divergenza dall'oracolo, mai scritta finora: la Fitness Age esclude gli assi insufficienti, l'oracolo li include comunque. Comportamento giusto, ma cambia il numero (25,0 contro 34,8 sullo stesso profilo parziale). - nuovo test che prova che i pesi di due model_version diverse restano separati (pesiDi filtra su model_version, non li fonde). Ogni fix verificato in TDD (test rosso prima, verde dopo) e ri-rotto a mano per confermare che discrimina davvero (vedi report). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
This commit is contained in:
@@ -10,14 +10,31 @@
|
|||||||
* peso si ridistribuisce su quelli disponibili; se la copertura di peso
|
* peso si ridistribuisce su quelli disponibili; se la copertura di peso
|
||||||
* disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente.
|
* disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente.
|
||||||
*
|
*
|
||||||
* UNICA DIVERGENZA VOLUTA dall'oracolo: nell'oracolo `aggregate()` restituisce
|
* DUE DIVERGENZE VOLUTE dall'oracolo:
|
||||||
* il punteggio pieno anche quando lo dichiara insufficiente (la docstring
|
*
|
||||||
* dice il contrario di cio' che il codice fa) - chi consuma deve ricordarsi
|
* 1) Nell'oracolo `aggregate()` restituisce il punteggio pieno anche quando lo
|
||||||
* di guardare lo stato, e prima o poi qualcuno non lo fa. Qui il tipo
|
* dichiara insufficiente (la docstring dice il contrario di cio' che il
|
||||||
* `Punteggio` chiude la trappola: nel ramo 'insufficiente' il campo `valore`
|
* codice fa) - chi consuma deve ricordarsi di guardare lo stato, e prima o
|
||||||
* non esiste, quindi la regola di prodotto - un asse con dati insufficienti
|
* poi qualcuno non lo fa. Qui il tipo `Punteggio` chiude la trappola: nel
|
||||||
* si mostra tratteggiato, mai con un numero pieno fasullo - e' impossibile
|
* ramo 'insufficiente' il campo `valore` non esiste, quindi la regola di
|
||||||
* da violare per distrazione. Tutto il resto e' fedele all'oracolo.
|
* prodotto - un asse con dati insufficienti si mostra tratteggiato, mai
|
||||||
|
* con un numero pieno fasullo - e' impossibile da violare per distrazione.
|
||||||
|
*
|
||||||
|
* 2) Nel calcolo della Fitness Age (vedi `motore/index.ts`, `valoreAsse`)
|
||||||
|
* escludiamo gli assi 'insufficiente' dal composito: `cardio`,
|
||||||
|
* `forza_resto`, `composizione` e `stabilita` entrano solo se il rispettivo
|
||||||
|
* asse e' 'ok'. L'oracolo invece li include comunque - per lo stesso motivo
|
||||||
|
* del punto 1: `compute_axis` restituisce uno `score` numerico anche
|
||||||
|
* quando lo stato e' "insufficiente", e `compute_fitness_age` lo riceve e
|
||||||
|
* lo usa senza controllare lo stato. E' il comportamento GIUSTO (un asse
|
||||||
|
* sotto soglia non e' un dato affidabile da far pesare sull'eta biologica),
|
||||||
|
* ma cambia il numero: su un profilo con assi parzialmente insufficienti,
|
||||||
|
* verificato a mano, la nostra Fitness Age e' 25,0 contro 34,8 dell'oracolo
|
||||||
|
* - quasi dieci anni di scarto. Chi confronta il nostro referto col Python
|
||||||
|
* del cliente su un profilo parziale concludera' che il porting e' rotto:
|
||||||
|
* non lo e', diverge di proposito qui.
|
||||||
|
*
|
||||||
|
* Tutto il resto e' fedele all'oracolo.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { COPERTURA_MINIMA } from '../db';
|
import { COPERTURA_MINIMA } from '../db';
|
||||||
@@ -96,9 +113,17 @@ export function calcolaMacro(pesiMacro: Record<string, number>, assi: Record<str
|
|||||||
* composito di sei elementi (handgrip e HRV isolati, non tramite l'intero
|
* composito di sei elementi (handgrip e HRV isolati, non tramite l'intero
|
||||||
* asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e'
|
* asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e'
|
||||||
* insufficiente non c'e' Fitness Age da mostrare.
|
* insufficiente non c'e' Fitness Age da mostrare.
|
||||||
|
*
|
||||||
|
* `etaAnagrafica` e' `number | null` perche' `sessioni.eta_alla_data` e'
|
||||||
|
* nullable nello schema: l'apertura di una sessione e il salvataggio di un
|
||||||
|
* questionario accettano l'eta come opzionale. Senza eta la Fitness Age NON
|
||||||
|
* ESISTE - deve restituire `null`, mai un numero calcolato con un ripiego
|
||||||
|
* (es. 0), che produrrebbe un'eta di forma negativa dichiarata valida. Gli
|
||||||
|
* assi e i macro-score non dipendono dall'eta e restano calcolabili a monte:
|
||||||
|
* solo questo ultimo livello si ferma.
|
||||||
*/
|
*/
|
||||||
export function calcolaFitnessAge(
|
export function calcolaFitnessAge(
|
||||||
etaAnagrafica: number,
|
etaAnagrafica: number | null,
|
||||||
pesi: Record<string, number>,
|
pesi: Record<string, number>,
|
||||||
voci: Record<string, number | null>
|
voci: Record<string, number | null>
|
||||||
): { fitnessAge: number | null; composito: Punteggio } {
|
): { fitnessAge: number | null; composito: Punteggio } {
|
||||||
@@ -107,7 +132,7 @@ export function calcolaFitnessAge(
|
|||||||
peso,
|
peso,
|
||||||
}));
|
}));
|
||||||
const composito = aggrega(vociPesate);
|
const composito = aggrega(vociPesate);
|
||||||
if (composito.stato === 'insufficiente') {
|
if (composito.stato === 'insufficiente' || etaAnagrafica === null) {
|
||||||
return { fitnessAge: null, composito };
|
return { fitnessAge: null, composito };
|
||||||
}
|
}
|
||||||
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
|
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
|
||||||
|
|||||||
@@ -19,6 +19,19 @@
|
|||||||
* `salvaScore` congela il risultato nella tabella `score`, una riga per
|
* `salvaScore` congela il risultato nella tabella `score`, una riga per
|
||||||
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
|
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
|
||||||
* insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo).
|
* insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo).
|
||||||
|
* `leggiScore` fa il percorso inverso — legge `score` e la ritipa come
|
||||||
|
* `Punteggio` — perché nessun altro punto del codice deve fare quella query
|
||||||
|
* a mano su una colonna nullable.
|
||||||
|
*
|
||||||
|
* Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente
|
||||||
|
* → null, mai un numero fasullo": l'età mancante (nullable nello schema) non
|
||||||
|
* usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di
|
||||||
|
* forma negativa; `applicaCurva` valida i parametri della curva prima di
|
||||||
|
* applicarla, e restituisce `null` se il registro (dato modificabile senza
|
||||||
|
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
|
||||||
|
* `lerp` con estremi indefiniti; e la lettura delle misure esclude
|
||||||
|
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma
|
||||||
|
* (punto aperto in §12).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
@@ -36,15 +49,51 @@ type ParamsCurva = {
|
|||||||
steps?: [number, number][]; zeroVal?: number;
|
steps?: [number, number][]; zeroVal?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */
|
||||||
|
function numeroValido(x: unknown): x is number {
|
||||||
|
return typeof x === 'number' && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica che `params` contenga tutto cio' che la curva dichiarata richiede,
|
||||||
|
* PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile
|
||||||
|
* senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti
|
||||||
|
* o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi
|
||||||
|
* indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il
|
||||||
|
* punteggio pieno, la peggior direzione di guasto possibile per un motore
|
||||||
|
* clinico.
|
||||||
|
*/
|
||||||
|
function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean {
|
||||||
|
switch (curva) {
|
||||||
|
case 'bell':
|
||||||
|
return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high);
|
||||||
|
case 'lin_dec':
|
||||||
|
return numeroValido(p.best) && numeroValido(p.worst);
|
||||||
|
case 'inc_plateau':
|
||||||
|
return numeroValido(p.worst) && numeroValido(p.plateauStart);
|
||||||
|
case 'x10':
|
||||||
|
case 'x10_inv':
|
||||||
|
return true; // nessun parametro richiesto
|
||||||
|
case 'decstep':
|
||||||
|
case 'incstep':
|
||||||
|
return Array.isArray(p.steps) && p.steps.length > 0 &&
|
||||||
|
p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1]));
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizza un valore grezzo secondo la curva dichiarata nel registro per
|
* Normalizza un valore grezzo secondo la curva dichiarata nel registro per
|
||||||
* quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel
|
* quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel
|
||||||
* motore. Un test senza curva dichiarata restituisce `null`: non contribuisce
|
* motore. Un test senza curva dichiarata restituisce `null`: non contribuisce
|
||||||
* al calcolo di sotto-dominio.
|
* al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri
|
||||||
|
* mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`).
|
||||||
*/
|
*/
|
||||||
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
|
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
|
||||||
if (!voce.curva) return null;
|
if (!voce.curva) return null;
|
||||||
const p = (voce.params ?? {}) as ParamsCurva;
|
const p = (voce.params ?? {}) as ParamsCurva;
|
||||||
|
if (!paramsValidi(voce.curva, p)) return null;
|
||||||
switch (voce.curva) {
|
switch (voce.curva) {
|
||||||
case 'bell':
|
case 'bell':
|
||||||
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
|
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
|
||||||
@@ -94,8 +143,12 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu
|
|||||||
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
|
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
|
||||||
|
|
||||||
const attivi = testAttivi(db, sessione.data);
|
const attivi = testAttivi(db, sessione.data);
|
||||||
|
// fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno
|
||||||
|
// non le conferma". La colonna della conferma non esiste ancora (punto aperto in
|
||||||
|
// §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar
|
||||||
|
// passare le misure confermate, non tolto del tutto.
|
||||||
const misurate = new Map<string, number>(
|
const misurate = new Map<string, number>(
|
||||||
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ?`).all(sessioneId) as
|
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as
|
||||||
{ test_id: string; valore_num: number | null }[])
|
{ test_id: string; valore_num: number | null }[])
|
||||||
.filter((m) => m.valore_num !== null)
|
.filter((m) => m.valore_num !== null)
|
||||||
.map((m) => [m.test_id, m.valore_num as number])
|
.map((m) => [m.test_id, m.valore_num as number])
|
||||||
@@ -156,7 +209,7 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu
|
|||||||
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
|
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
|
||||||
};
|
};
|
||||||
const { fitnessAge, composito } = calcolaFitnessAge(
|
const { fitnessAge, composito } = calcolaFitnessAge(
|
||||||
sessione.eta_alla_data ?? 0,
|
sessione.eta_alla_data,
|
||||||
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
|
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
|
||||||
vociFitnessAge
|
vociFitnessAge
|
||||||
);
|
);
|
||||||
@@ -201,3 +254,54 @@ export function salvaScore(db: Database.Database, sessioneId: number, risultato:
|
|||||||
});
|
});
|
||||||
tx();
|
tx();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */
|
||||||
|
export type PunteggiSessione = {
|
||||||
|
assi: Record<string, Punteggio>;
|
||||||
|
macro: Record<string, Punteggio>;
|
||||||
|
/** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */
|
||||||
|
fitnessAge: Punteggio | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come
|
||||||
|
* `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno
|
||||||
|
* legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che
|
||||||
|
* l'interfaccia scriverà su una colonna nullable perderebbe la barriera del
|
||||||
|
* tipo se restasse un semplice `SELECT *`. Qui non la perde.
|
||||||
|
*
|
||||||
|
* `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene,
|
||||||
|
* non si sovrascrive (discende dal congelamento della §6: sovrascrivere
|
||||||
|
* cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata
|
||||||
|
* più volte ha quindi più righe per lo stesso tipo/elemento: questa query
|
||||||
|
* dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per
|
||||||
|
* tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe,
|
||||||
|
* che non è un criterio).
|
||||||
|
*/
|
||||||
|
export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione {
|
||||||
|
const righe = db.prepare(
|
||||||
|
`SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato
|
||||||
|
FROM score s
|
||||||
|
JOIN (
|
||||||
|
SELECT tipo, elemento, MAX(id) AS ultimo_id
|
||||||
|
FROM score
|
||||||
|
WHERE sessione_id = ?
|
||||||
|
GROUP BY tipo, elemento
|
||||||
|
) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id
|
||||||
|
WHERE s.sessione_id = ?`
|
||||||
|
).all(sessioneId, sessioneId) as
|
||||||
|
{ tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[];
|
||||||
|
|
||||||
|
const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio =>
|
||||||
|
r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura };
|
||||||
|
|
||||||
|
const assi: Record<string, Punteggio> = {};
|
||||||
|
const macro: Record<string, Punteggio> = {};
|
||||||
|
let fitnessAge: Punteggio | null = null;
|
||||||
|
for (const r of righe) {
|
||||||
|
if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r);
|
||||||
|
else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r);
|
||||||
|
else fitnessAge = aPunteggio(r);
|
||||||
|
}
|
||||||
|
return { assi, macro, fitnessAge };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
|
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
|
||||||
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
||||||
import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
|
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
|
||||||
|
import { calcolaAsse } from '../../src/lib/longevity/motore/cascata';
|
||||||
|
import { applicaCurva, calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore';
|
||||||
|
|
||||||
function dbPronto() {
|
function dbPronto() {
|
||||||
const db = createLongevityDb(':memory:');
|
const db = createLongevityDb(':memory:');
|
||||||
@@ -76,6 +78,27 @@ describe('il motore legge le curve dal registro', () => {
|
|||||||
expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo));
|
expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('una curva con i parametri mancanti nel registro non calcola un punteggio: restituisce null, mai 100', () => {
|
||||||
|
// Riproduce il difetto trovato in revisione: registro modificabile senza migrazioni,
|
||||||
|
// una riga con params vuoti o incompleti non deve mai dare il punteggio pieno.
|
||||||
|
const db = dbPronto();
|
||||||
|
// bell (q_ore_sonno): params svuotati del tutto.
|
||||||
|
const sonno = { ...voce(db, 'q_ore_sonno'), params: {} };
|
||||||
|
expect(applicaCurva(sonno as never, 3)).toBeNull(); // 3 ore di sonno: mai 100
|
||||||
|
// lin_dec (q_alcol_life): manca 'worst'.
|
||||||
|
const alcol = { ...voce(db, 'q_alcol_life'), params: { best: 7 } };
|
||||||
|
expect(applicaCurva(alcol as never, 40)).toBeNull(); // 40 unita/settimana: mai 100
|
||||||
|
// decstep (q_sigarette): steps mancanti.
|
||||||
|
const sigarette = { ...voce(db, 'q_sigarette'), params: {} };
|
||||||
|
expect(applicaCurva(sigarette as never, 40)).toBeNull(); // 40 sigarette/giorno: mai 100
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una curva coi parametri completi continua a funzionare (il controllo non e troppo severo)', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
|
||||||
|
expect(applicaCurva(v as never, 8)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
it('due test dello stesso sotto-dominio danno la MEDIA dei punteggi, non la somma ne un singolo valore', () => {
|
it('due test dello stesso sotto-dominio danno la MEDIA dei punteggi, non la somma ne un singolo valore', () => {
|
||||||
// q_attivita e q_alimentazione condividono il sotto-dominio 'questionario_lifestyle',
|
// q_attivita e q_alimentazione condividono il sotto-dominio 'questionario_lifestyle',
|
||||||
// che pesa 1.00 (unico elemento) dentro l'asse "Stile di Vita & Sonno": il valore
|
// che pesa 1.00 (unico elemento) dentro l'asse "Stile di Vita & Sonno": il valore
|
||||||
@@ -133,4 +156,150 @@ describe('calcolo e congelamento di una sessione', () => {
|
|||||||
expect(pesi.handgrip).toBe(0.25);
|
expect(pesi.handgrip).toBe(0.25);
|
||||||
expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
|
expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('senza eta la Fitness Age e null, non un numero negativo dichiarato valido', () => {
|
||||||
|
// Bug trovato in revisione: eta_alla_data e' nullable nello schema (l'apertura di
|
||||||
|
// una sessione e il salvataggio di un questionario accettano l'eta opzionale), ma
|
||||||
|
// il motore la leggeva con un ripiego a zero. Qui costruiamo un composito Fitness
|
||||||
|
// Age davvero 'ok' (copertura esattamente 0.40: cardio 0.30 + composizione 0.10),
|
||||||
|
// cosi' il difetto e' osservabile: col ripiego a zero l'eta anagrafica diventa 0
|
||||||
|
// e la formula "eta - (composito - 50) * 0.4" esce negativa ma con stato 'ok'.
|
||||||
|
// Due voci fisiche aggiunte al registro qui (arriveranno davvero col piano degli
|
||||||
|
// import): servono solo a rendere il composito calcolabile senza aspettarle.
|
||||||
|
const db = dbPronto();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
|
||||||
|
VALUES ('wt_vo2max', 'VO2max stimato', 'num', 'lin_dec', ?, 'Cardio-Respiratorio', 'vo2max', '2026-01-01')`
|
||||||
|
).run(JSON.stringify({ best: 40, worst: 20 }));
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
|
||||||
|
VALUES ('wt_grasso', 'Grasso % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'grasso', '2026-01-01')`
|
||||||
|
).run(JSON.stringify({ best: 10, worst: 40 }));
|
||||||
|
|
||||||
|
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup' }); // nessuna eta
|
||||||
|
registraMisure(db, s, 'wellness_tower', [
|
||||||
|
{ test_id: 'wt_vo2max', valore_num: 40 }, // curva a 100
|
||||||
|
{ test_id: 'wt_grasso', valore_num: 10 }, // curva a 100
|
||||||
|
]);
|
||||||
|
|
||||||
|
const r = calcolaSessione(db, s);
|
||||||
|
expect(r.assi['Cardio-Respiratorio'].stato).toBe('ok');
|
||||||
|
expect(r.assi['Composizione Corporea'].stato).toBe('ok');
|
||||||
|
expect(r.fitnessAgeCopertura).toBe(0.4); // composito 'ok': la soglia e' inclusiva
|
||||||
|
expect(r.fitnessAge).toBeNull(); // ma senza eta anagrafica, la Fitness Age non esiste
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una misura fuori range non entra nello score (§9 della spec)', () => {
|
||||||
|
// Caso letterale della spec: una risposta 0-10 di 999 e' fuori dal fondoscala
|
||||||
|
// dichiarato (come la capacita' vitale di 5148 mL su un fondoscala 2000-3000),
|
||||||
|
// si scrive e si marca, ma non deve produrre un punteggio.
|
||||||
|
// q_alimentazione e' l'UNICA voce del sotto-dominio 'questionario_lifestyle'
|
||||||
|
// (peso 1.00, unico elemento dell'asse "Stile di Vita & Sonno"): se la misura
|
||||||
|
// fuori range entrasse nel calcolo l'asse risulterebbe 'ok' con valore 100
|
||||||
|
// (curvaDirettaX10(999) clampato); esclusa, il sotto-dominio resta scoperto e
|
||||||
|
// l'asse e' 'insufficiente'. E' la differenza osservabile che prova il filtro.
|
||||||
|
const db = dbPronto();
|
||||||
|
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'questionario', eta_alla_data: 35 });
|
||||||
|
const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_alimentazione', valore_num: 999 }]);
|
||||||
|
expect(esito.fuoriRange).toEqual(['q_alimentazione']);
|
||||||
|
|
||||||
|
const r = calcolaSessione(db, s);
|
||||||
|
expect(r.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
|
||||||
|
|
||||||
|
const riga = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_alimentazione'`).get() as { fuori_range: number };
|
||||||
|
expect(riga.fuori_range).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrive nella tabella score un valore vero e la copertura vera, non solo lo stato', () => {
|
||||||
|
// Sabotaggi che questo test deve intercettare: tutti i valori scritti a null, o
|
||||||
|
// tutte le coperture forzate a 1. Serve un caso con copertura NON piena (0.5, non
|
||||||
|
// 1.0) perche' un "sempre 1" non si distinguerebbe da un caso gia' pieno.
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
// q_energia_media (x10) e' l'unica voce misurata del sotto-dominio
|
||||||
|
// 'questionario_energia_stress' (peso 0.50 nell'asse, l'altro 0.50 e' hrv,
|
||||||
|
// non ancora nel registro): copertura 0.50, non 1.
|
||||||
|
risposte: { q_energia_media: 8 },
|
||||||
|
});
|
||||||
|
const risultato = calcolaSessione(db, 1);
|
||||||
|
salvaScore(db, 1, risultato);
|
||||||
|
const riga = db.prepare(
|
||||||
|
`SELECT valore, copertura, stato FROM score WHERE tipo = 'asse' AND elemento = ?`
|
||||||
|
).get('Energia & Regolazione Stress') as { valore: number; copertura: number; stato: string };
|
||||||
|
expect(riga.stato).toBe('ok');
|
||||||
|
expect(riga.valore).toBe(80); // curvaDirettaX10(8) = 80, unico contributo
|
||||||
|
expect(riga.copertura).toBe(0.5); // 0.50 di peso disponibile su 1.00
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leggiScore legge dalla tabella, tipato come Punteggio', () => {
|
||||||
|
it('restituisce assi/macro/fitness_age come Punteggio: nessun valore leggibile da un insufficiente', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: { q_attivita: 3, q_alimentazione: 6 },
|
||||||
|
});
|
||||||
|
const risultato = calcolaSessione(db, 1);
|
||||||
|
salvaScore(db, 1, risultato);
|
||||||
|
const letto = leggiScore(db, 1);
|
||||||
|
expect(letto.assi['Stile di Vita & Sonno'].stato).toBe('ok');
|
||||||
|
if (letto.assi['Stile di Vita & Sonno'].stato === 'ok') {
|
||||||
|
expect(letto.assi['Stile di Vita & Sonno'].valore).toBe(62.5);
|
||||||
|
}
|
||||||
|
expect(letto.assi['Forza & Struttura'].stato).toBe('insufficiente');
|
||||||
|
expect((letto.assi['Forza & Struttura'] as { valore?: number }).valore).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('due calcoli sulla stessa sessione non si sovrascrivono: leggiScore prende l ultimo', () => {
|
||||||
|
// Decisione presa: salvaScore non ha vincolo di unicita', la storia si tiene.
|
||||||
|
// leggiScore deve dichiarare esplicitamente nella query il criterio dell'ultimo
|
||||||
|
// calcolo (non l'ordine naturale delle righe).
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: { q_attivita: 3, q_alimentazione: 6 }, // -> 62.5
|
||||||
|
});
|
||||||
|
salvaScore(db, 1, calcolaSessione(db, 1));
|
||||||
|
|
||||||
|
// Aggiunge una risposta lifestyle: cambia la media del sotto-dominio, ricalcola
|
||||||
|
// e salva una SECONDA volta sulla stessa sessione.
|
||||||
|
registraMisure(db, 1, 'questionario', [{ test_id: 'q_sigarette', valore_num: 0 }]); // curva decstep, v=0 -> 100
|
||||||
|
salvaScore(db, 1, calcolaSessione(db, 1));
|
||||||
|
|
||||||
|
const righe = db.prepare(
|
||||||
|
`SELECT COUNT(*) n FROM score WHERE sessione_id = 1 AND tipo = 'asse' AND elemento = 'Stile di Vita & Sonno'`
|
||||||
|
).get() as { n: number };
|
||||||
|
expect(righe.n).toBe(2); // la storia si tiene: due righe, non una sovrascritta
|
||||||
|
|
||||||
|
const letto = leggiScore(db, 1);
|
||||||
|
const asse = letto.assi['Stile di Vita & Sonno'];
|
||||||
|
expect(asse.stato).toBe('ok');
|
||||||
|
// media di tre voci ora (65+60+100)/3 = 75, non piu' 62.5 del primo calcolo
|
||||||
|
if (asse.stato === 'ok') expect(asse.valore).toBe(75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('i pesi sono legati alla versione del modello, non fusi insieme', () => {
|
||||||
|
it('togliendo il filtro sulla versione dalla lettura dei pesi, due versioni diverse darebbero lo stesso punteggio: qui devono differire', () => {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedPesi(db, 'v1.0');
|
||||||
|
// Seconda versione del modello, con pesi DIVERSI per lo stesso asse.
|
||||||
|
const ins = db.prepare(
|
||||||
|
`INSERT INTO pesi (model_version, livello, contenitore, elemento, peso) VALUES (?, 'asse', 'Forza & Struttura', ?, ?)`
|
||||||
|
);
|
||||||
|
ins.run('v2.0', 'handgrip', 0.9);
|
||||||
|
ins.run('v2.0', 'core', 0.1);
|
||||||
|
|
||||||
|
const punteggi = { handgrip: 100, spinta: 0, trazione: 0, arti_inferiori: 0, core: 0 };
|
||||||
|
const v1 = calcolaAsse(pesiDi(db, 'v1.0', 'asse', 'Forza & Struttura'), punteggi);
|
||||||
|
const v2 = calcolaAsse(pesiDi(db, 'v2.0', 'asse', 'Forza & Struttura'), punteggi);
|
||||||
|
expect(v1.stato).toBe('ok');
|
||||||
|
expect(v2.stato).toBe('ok');
|
||||||
|
if (v1.stato === 'ok' && v2.stato === 'ok') {
|
||||||
|
expect(v1.valore).toBe(25); // 100*0.25 (v1.0)
|
||||||
|
expect(v2.valore).toBe(90); // 100*0.9 (v2.0)
|
||||||
|
expect(v1.valore).not.toBe(v2.valore);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user