Files
InsanityLab-web/apps/sito/tests/longevity/motore.test.ts
T
AdrianoDev 510d7ca4ec Struttura: il sito esistente si sposta sotto apps/sito
Primo passo della separazione sito / piattaforme decisa da Adriano il 03/09:
due applicazioni autosufficienti, senza workspace di root, cosi' che ognuna
resti un progetto Astro completo ed estraibile.

Qui non cambia niente del codice: solo la radice. I pattern del .gitignore
che avevano una barra erano ancorati alla radice e dopo lo spostamento non
avrebbero piu' coperto niente (data/*.db*, uploads/*): resi validi a
qualunque profondita'.

Suite: 45 file, 347 test verdi prima e dopo lo spostamento.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 11:05:54 +00:00

306 lines
16 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
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() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
const voce = (db: ReturnType<typeof dbPronto>, id: string) =>
db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record<string, unknown>;
describe('il motore legge le curve dal registro', () => {
it('applica la curva dichiarata per il test, non una scritta nel codice', () => {
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); // dentro il picco 7-9
expect(applicaCurva(v as never, 4)).toBeLessThan(20);
});
it('rispetta la soglia dell alcol come e scritta nel registro', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 0)).toBe(100);
expect(applicaCurva(v as never, 7)).toBe(100);
expect(applicaCurva(v as never, 14)).toBe(0);
});
it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => {
const db = dbPronto();
db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`)
.run(JSON.stringify({ best: 0, worst: 7 }));
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100
});
it('un test disattivato non entra nel calcolo', () => {
// NOTA: la versione originale di questo test disattivava 'q_ore_sonno' e si
// aspettava una differenza. Ma 'q_ore_sonno' e' uno delle SEI voci del
// sotto-dominio 'questionario_sonno' (peso 0.20 dentro "Recupero & Sistema
// Nervoso", che pesa in tutto 1.00: hrv 0.50 + pressione 0.15 + hrr 0.15 +
// questionario_sonno 0.20). Quell'asse resta 'insufficiente' con o senza
// 'q_ore_sonno' (0.20 < COPERTURA_MINIMA 0.4, e hrv/pressione/hrr non hanno
// ancora una voce nel registro): siccome un asse insufficiente non porta
// 'valore' nel tipo Punteggio, il JSON prima/dopo risultava IDENTICO — la
// suite lo ha confermato (verificato eseguendo il test: fallisce con le due
// stringhe uguali). Corretto qui usando 'q_attivita': NON e' l'unica voce
// del sotto-dominio 'questionario_lifestyle' (ne ha sei: q_attivita,
// q_alimentazione, q_sigarette, q_alcol_life, q_luce, q_schermi), ma in
// QUESTO fixture e' l'unica delle sei a cui si risponde. Disattivarla
// svuota davvero il sotto-dominio (nessun'altra voce lifestyle compilata a
// sostituirla), la copertura dell'asse "Stile di Vita & Sonno" (peso 1.00
// su quel solo sotto-dominio) cade da 1.00 a 0 e lo stato passa da 'ok' a
// 'insufficiente' — una differenza vera e osservabile. Se in futuro il
// fixture compilasse anche un'altra risposta lifestyle, questo test
// andrebbe aggiornato: la disattivazione non svuoterebbe piu' il
// sotto-dominio da sola.
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: {
q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0,
q_attivita: 5,
},
});
const prima = calcolaSessione(db, 1);
expect(prima.assi['Stile di Vita & Sonno'].stato).toBe('ok');
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_attivita'`).run();
const dopo = calcolaSessione(db, 1);
expect(dopo.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
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', () => {
// 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
// dell'asse coincide col valore del sotto-dominio, quindi la media si legge diretta.
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
// q_attivita curva incstep, steps [[0,20],[2,50],[4,80],[999,100]]: valore 3 ->
// lerp fra (2,50) e (4,80) a meta' strada -> punteggio 65.
// q_alimentazione curva x10: valore 6 -> punteggio 60.
// media = (65+60)/2 = 62.5: diversa dalla somma (125) e da ciascun addendo (65, 60).
risposte: { q_attivita: 3, q_alimentazione: 6 },
});
const r = calcolaSessione(db, 1);
const asse = r.assi['Stile di Vita & Sonno'];
expect(asse.stato).toBe('ok');
if (asse.stato === 'ok') expect(asse.valore).toBe(62.5);
});
});
describe('calcolo e congelamento di una sessione', () => {
it('produce i sette assi, e quelli senza dati sono insufficienti', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 },
});
const r = calcolaSessione(db, 1);
expect(Object.keys(r.assi).length).toBe(7);
// il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40%
expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente');
// Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto
expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente');
});
it('congela i punteggi con le DUE versioni', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_riposato: 7 },
});
salvaScore(db, 1, calcolaSessione(db, 1));
const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record<string, unknown>[];
expect(righe.length).toBeGreaterThan(0);
for (const r of righe) {
expect(r.model_version).toBe(MODEL_VERSION);
expect(r.quest_version).toBe('v1.0');
if (r.stato === 'insufficiente') expect(r.valore).toBeNull();
}
});
it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => {
const db = dbPronto();
const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura');
expect(pesi.handgrip).toBe(0.25);
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);
}
});
});