longevity: progressioneDi, l'andamento nel tempo letto da score, mai un ricalcolo

Una sessione per punto (solo quelle con righe in score), i punteggi congelati
da leggiScore, e cambioVersione quando model_version o quest_version (non
nullo) cambiano rispetto al punto precedente — un check-up in mezzo, con
quest_version NULL, non innesca né nasconde un cambio.

Refs #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 13:02:50 +00:00
parent af8afebba8
commit 4cd911098d
2 changed files with 277 additions and 0 deletions
@@ -0,0 +1,148 @@
// tests/longevity/progressione.test.ts
// L'andamento nel tempo: progressioneDi deve leggere SOLO ciò che è congelato in `score`
// (mai un ricalcolo, stessa regola di refertoDi) e marcare correttamente dove il modo di
// calcolare è cambiato (quest_version/model_version), senza farsi confondere da un
// check-up in mezzo a due questionari (quest_version NULL per un check-up).
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaDalForm } from '../../src/lib/longevity/vista';
import { apriSessione } from '../../src/lib/longevity/misure';
import { calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore';
import { progressioneDi } from '../../src/lib/longevity/progressione';
const RISPOSTE_COMPLETE = {
q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1,
q_energia_media: 7, q_esaurimento: 0, q_calo_pomeridiano: 3,
q_sopraffatto: 1, q_controllo: 8, q_sicurezza_gestione: 8, q_tensione: 1, q_pensieri_lavoro: 0.5,
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
};
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;
}
/** Una sessione di check-up, calcolata e congelata come farebbe l'operatore in sala:
* nessuna domanda del questionario, quindi `quest_version` resta NULL (vedi db.ts). */
function aggiungiCheckup(db: ReturnType<typeof dbPronto>, data: string) {
const sessioneId = apriSessione(db, { client_code: 'ISL-0001', data, tipo: 'checkup', eta_alla_data: 35 });
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
return sessioneId;
}
/** Un questionario compilato per intero, via salvaDalForm — la stessa funzione che
* chiama l'endpoint reale (vedi salvataggio-e2e.test.ts): calcola e congela da sola. */
function aggiungiQuestionario(db: ReturnType<typeof dbPronto>, data: string) {
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data, eta: 35,
risposte: RISPOSTE_COMPLETE, consensoSanitario: 'Si', liberi: {},
});
if (!esito.ok) throw new Error(esito.errore);
return esito.sessioneId;
}
describe('progressioneDi: due questionari e un check-up', () => {
it('produce tre punti, in ordine cronologico', () => {
const db = dbPronto();
const s1 = aggiungiQuestionario(db, '2026-01-10');
const s2 = aggiungiCheckup(db, '2026-02-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti.length).toBe(3);
expect(punti.map((p) => p.sessioneId)).toEqual([s1, s2, s3]);
expect(punti.map((p) => p.data)).toEqual(['2026-01-10', '2026-02-10', '2026-03-10']);
expect(punti[0].tipo).toBe('questionario');
expect(punti[1].tipo).toBe('checkup');
});
it('i valori coincidono con leggiScore, mai un ricalcolo', () => {
const db = dbPronto();
const s1 = aggiungiQuestionario(db, '2026-01-10');
const punti = progressioneDi(db, 'ISL-0001');
const punteggi = leggiScore(db, s1);
for (const [nome, p] of Object.entries(punteggi.assi)) {
expect(punti[0].assi[nome]).toBe(p.stato === 'ok' ? p.valore : null);
}
for (const [nome, p] of Object.entries(punteggi.macro)) {
expect(punti[0].macro[nome]).toBe(p.stato === 'ok' ? p.valore : null);
}
expect(punti[0].fitnessAge).toBe(punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null);
});
it('un asse senza dati sufficienti resta null, mai un numero inventato', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
const punti = progressioneDi(db, 'ISL-0001');
// 'Forza & Struttura' non ha nessuna misura fisica in questa sessione (solo questionario).
expect(punti[0].assi['Forza & Struttura']).toBeNull();
});
it('quest_version NULL per il check-up', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti[1].questVersion).toBeNull();
});
it('nessun cambio di versione: cambioVersione resta null su ogni punto dopo il primo', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
expect(punti[0].cambioVersione).toBeNull(); // primo punto: mai un "prima" da confrontare
expect(punti[1].cambioVersione).toBeNull(); // il check-up in mezzo
expect(punti[2].cambioVersione).toBeNull(); // il secondo questionario, stessa versione
});
it('un check-up fra due questionari alla stessa versione non produce un cambio', () => {
// Stesso scenario del test sopra, isolato: qui si verifica in particolare che il
// check-up (quest_version NULL) non "nasconda" né inventi un cambio fra i due
// questionari v1.0 che lo circondano.
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
aggiungiCheckup(db, '2026-02-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
const punti = progressioneDi(db, 'ISL-0001');
const puntoCheckup = punti.find((p) => p.tipo === 'checkup')!;
const puntoSecondoQuest = punti.find((p) => p.sessioneId === s3)!;
expect(puntoCheckup.cambioVersione).toBeNull();
expect(puntoSecondoQuest.cambioVersione).toBeNull();
});
it('un cambio di quest_version produce il testo del cambio', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
const s3 = aggiungiQuestionario(db, '2026-03-10');
// Nessuna seconda versione delle domande esiste davvero oggi (QUEST_VERSION è una sola
// costante): si simula il cambio scrivendo direttamente la riga congelata, come farebbe
// un domani un vero v1.1 del questionario.
db.prepare(`UPDATE score SET quest_version = 'v1.1' WHERE sessione_id = ?`).run(s3);
const punti = progressioneDi(db, 'ISL-0001');
const punto = punti.find((p) => p.sessioneId === s3)!;
expect(punto.cambioVersione).toMatch(/questionario v1\.0 → v1\.1/);
});
it('una sessione senza righe in score resta esclusa dalla progressione', () => {
const db = dbPronto();
aggiungiQuestionario(db, '2026-01-10');
// Sessione aperta ma mai calcolata: nessuna riga in score (consenso non dato, o un
// calcolo mai lanciato — non un caso teorico, vedi il commento in salvaDalForm).
apriSessione(db, { client_code: 'ISL-0001', data: '2026-02-01', tipo: 'checkup', eta_alla_data: 35 });
const punti = progressioneDi(db, 'ISL-0001');
expect(punti.length).toBe(1);
});
});