Separazione: le aree riservate diventano un'app propria (apps/platforms)

Campus, Stress Index e Longevity escono dal sito e diventano
apps.insanitylab.it, con utenti, sessioni, login e database propri.
Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni
autosufficienti, cosi' che ognuna resti estraibile.

Misura che ha guidato il taglio: le tre aree condividevano col sito
SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts.
Layout, librerie e dati erano gia' disgiunti.

Lato piattaforme:
- ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali
- canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non
  elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno)
- tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti
- /api risponde 401/403, non un redirect verso una pagina di login
- tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi

Lato sito:
- ROLES tornano i tre redazionali; via le regole e il login pubblico
- il login del pannello RESPINGE un account emigrato invece di aprirgli una
  sessione: senza, landingFor lo mandava su /admin, il middleware glielo
  negava, e si formava un ciclo di redirect che sembra un guasto del sito
- l'header non mostra piu' un menu delle piattaforme: un link che esce

Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi
i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra
in Campus e viene rimbalzato da Longevity, il cliente il contrario.

Non ancora in produzione: manca il record DNS di apps.insanitylab.it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
AdrianoDev
2026-09-03 11:21:12 +00:00
parent 510d7ca4ec
commit 6301f521fd
383 changed files with 8477 additions and 350 deletions
+105
View File
@@ -0,0 +1,105 @@
// Chi entra dove, in QUEST'app. Sostituisce tests/auth-piattaforme.test.ts del repo unico:
// là si verificava che il ruolo `piattaforme` non sfondasse dentro il pannello del sito,
// qui il pannello non esiste — le rotte del sito non sono raggiungibili nemmeno sbagliando
// indirizzo, perché stanno su un'altra applicazione, su un altro host, con un altro database.
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { createUser, login, getSessionUser, canAccessPath, landingFor, isRole, isProtectedPath, ROLES } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('utenti e sessioni di quest\'app', () => {
it('createUser accetta i ruoli dell\'app e getSessionUser li restituisce', () => {
for (const ruolo of ['piattaforme', 'cliente', 'trainer', 'admin'] as const) {
createUser(db, `u-${ruolo}`, 'segreta123', ruolo);
expect(getSessionUser(db, login(db, `u-${ruolo}`, 'segreta123')!)).toMatchObject({ role: ruolo });
}
});
it('i ruoli redazionali del sito qui non esistono', () => {
expect(isRole('superuser')).toBe(false);
expect(isRole('user')).toBe(false);
expect(isRole('campus')).toBe(false);
expect(ROLES).not.toContain('superuser');
expect(ROLES).not.toContain('user');
});
it('isRole accetta esattamente i ruoli di ROLES, nessuno in più o in meno', () => {
// Ereditata da tests/longevity/ruoli.test.ts del repo unico: quel file verificava
// sei ruoli e la tendina di admin/users.astro, che qui non esistono piu'. Questa e'
// la sua asserzione che vale ancora — le due liste devono restare la stessa cosa.
for (const ruolo of ROLES) expect(isRole(ruolo)).toBe(true);
for (const finto of ['campus', 'superuser', 'user', 'Admin', '', 'cliente ']) expect(isRole(finto)).toBe(false);
});
it('landingFor manda ognuno a casa propria, e nessuno su /admin', () => {
expect(landingFor('cliente')).toBe('/longevity/io');
expect(landingFor('trainer')).toBe('/longevity/gestionale');
expect(landingFor('piattaforme')).toBe('/piattaforme');
expect(landingFor('admin')).toBe('/piattaforme');
for (const ruolo of ROLES) expect(landingFor(ruolo)).not.toMatch(/^\/admin/);
});
});
describe('canAccessPath — le tre sezioni', () => {
it('piattaforme: Campus e Stress Index sì, Longevity no', () => {
expect(canAccessPath('piattaforme', '/campus')).toBe(true);
expect(canAccessPath('piattaforme', '/campus/corso-base/lezione-01')).toBe(true);
expect(canAccessPath('piattaforme', '/campus/assets/slide/x.png')).toBe(true);
expect(canAccessPath('piattaforme', '/piattaforme')).toBe(true);
expect(canAccessPath('piattaforme', '/piattaforme/stress-index/clienti')).toBe(true);
// Il ruolo storico non eredita i dati sanitari: sono arrivati dopo, e per altri.
expect(canAccessPath('piattaforme', '/longevity')).toBe(false);
expect(canAccessPath('piattaforme', '/longevity/io')).toBe(false);
expect(canAccessPath('piattaforme', '/api/longevity/questionario')).toBe(false);
});
it('cliente: il proprio spazio sì, il gestionale e le altre piattaforme no', () => {
expect(canAccessPath('cliente', '/longevity')).toBe(true);
expect(canAccessPath('cliente', '/longevity/io')).toBe(true);
expect(canAccessPath('cliente', '/longevity/questionario')).toBe(true);
expect(canAccessPath('cliente', '/api/longevity/questionario')).toBe(true);
expect(canAccessPath('cliente', '/longevity/gestionale')).toBe(false);
expect(canAccessPath('cliente', '/api/longevity/gestionale/misure')).toBe(false);
expect(canAccessPath('cliente', '/campus')).toBe(false);
expect(canAccessPath('cliente', '/piattaforme/stress-index')).toBe(false);
});
it('trainer: entrambe le parti di Longevity, niente Campus né Stress Index', () => {
expect(canAccessPath('trainer', '/longevity/gestionale')).toBe(true);
expect(canAccessPath('trainer', '/longevity/io')).toBe(true);
expect(canAccessPath('trainer', '/api/longevity/gestionale/misure')).toBe(true);
expect(canAccessPath('trainer', '/campus')).toBe(false);
expect(canAccessPath('trainer', '/piattaforme/stress-index')).toBe(false);
});
it('admin passa sempre', () => {
for (const p of ['/campus', '/piattaforme', '/piattaforme/stress-index', '/longevity', '/longevity/gestionale'])
expect(canAccessPath('admin', p)).toBe(true);
});
it('⚠️ il default è CHIUSO: una rotta senza regola non è di nessuno', () => {
// Differenza sostanziale dal sito, dove il default finale era `return true` perché
// blog e upload erano di chiunque fosse loggato. Qui una sezione nuova nasce vietata:
// il difetto si vede provandola, non leggendo un log di accessi.
for (const ruolo of ['piattaforme', 'cliente', 'trainer'])
expect(canAccessPath(ruolo, '/sezione-nuova-senza-regola')).toBe(false);
});
});
describe('isProtectedPath — cosa si apre senza sessione', () => {
it('solo login, logout, 404 e la favicon', () => {
expect(isProtectedPath('/login')).toBe(false);
expect(isProtectedPath('/logout')).toBe(false);
expect(isProtectedPath('/404')).toBe(false);
expect(isProtectedPath('/favicon.svg')).toBe(false);
});
it('tutto il resto è protetto, comprese le pagine che ancora non esistono', () => {
for (const p of ['/', '/campus', '/piattaforme', '/longevity/io', '/api/longevity/questionario',
'/longevity/gestionale', '/qualcosa-che-non-esiste'])
expect(isProtectedPath(p)).toBe(true);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('auth', () => {
it('hash e verifica password', () => {
const h = hashPassword('segreta123');
expect(h).not.toBe('segreta123');
expect(verifyPassword('segreta123', h)).toBe(true);
expect(verifyPassword('sbagliata', h)).toBe(false);
});
it('login corretto crea sessione recuperabile', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123');
expect(token).toBeTruthy();
const user = getSessionUser(db, token!);
expect(user).toMatchObject({ username: 'adriano' });
});
it('login errato ritorna null', () => {
createUser(db, 'adriano', 'segreta123');
expect(login(db, 'adriano', 'sbagliata')).toBeNull();
expect(login(db, 'inesistente', 'x')).toBeNull();
});
it('sessione scaduta viene rifiutata ed eliminata', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123')!;
db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run();
expect(getSessionUser(db, token)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 });
});
it('logout elimina la sessione', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123')!;
logout(db, token);
expect(getSessionUser(db, token)).toBeNull();
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { getNav, findArea, findLesson, chapterOf, readLessonHtml, resolveAsset } from '../src/lib/campus';
// I test girano sui contenuti reali generati da `npm run sync-campus`.
const nav = getNav();
describe('getNav', () => {
it('carica aree e capitoli dai contenuti generati', () => {
expect(nav).not.toBeNull();
expect(nav!.count).toBeGreaterThan(100);
expect(nav!.areas.map((a) => a.slug)).toEqual(
['corso-base', 'slide', 'live-e-ospiti', 'consulenze-masterclass', 'qa']);
for (const a of nav!.areas) expect(a.chapters.length).toBeGreaterThan(0);
});
});
describe('lookup', () => {
it('trova area e guida, e il capitolo della guida', () => {
const area = findArea(nav!, 'corso-base');
expect(area?.title).toBe('Corso base');
const l = findLesson(nav!, 'lezione-01-ottimizzazione-del-sonno');
expect(l).toMatchObject({ area: 'corso-base', chapter: 1, label: 'L1' });
expect(chapterOf(nav!, l!)?.title).toBe('Sonno e ritmi');
});
it('ritorna null per slug inesistenti', () => {
expect(findArea(nav!, 'inventata')).toBeNull();
expect(findLesson(nav!, 'lezione-99-inventata')).toBeNull();
});
it('la catena prev/next collega tutte le guide in ordine', () => {
const all = nav!.areas.flatMap((a) => a.chapters.flatMap((c) => c.lessons));
expect(all[0].prev).toBeNull();
expect(all.at(-1)!.next).toBeNull();
for (let i = 0; i < all.length - 1; i++) expect(all[i].next?.slug).toBe(all[i + 1].slug);
});
});
describe('readLessonHtml', () => {
it('legge il frammento di una guida esistente', () => {
const html = readLessonHtml('lezione-01-ottimizzazione-del-sonno');
expect(html).toContain('<h2');
expect(html).not.toContain('Guida allo studio</h1>');
});
it('ritorna null per una guida inesistente', () => {
expect(readLessonHtml('non-esiste')).toBeNull();
});
it('le immagini puntano alla route protetta', () => {
const html = readLessonHtml('slide-hrv')!;
expect(html).toContain('src="/campus/assets/');
expect(html).not.toContain('src="assets/');
});
});
describe('resolveAsset', () => {
it('risolve un asset esistente', () => {
expect(resolveAsset('slide/slide-hrv')).toMatch(/campus-content\/assets\/slide\/slide-hrv$/);
});
it('rifiuta i path traversal e i path assoluti', () => {
expect(resolveAsset('../pages/lezione-01-ottimizzazione-del-sonno.html')).toBeNull();
expect(resolveAsset('../../package.json')).toBeNull();
expect(resolveAsset('/etc/passwd')).toBeNull();
});
it('ritorna null per un asset inesistente', () => {
expect(resolveAsset('slide/non-esiste.png')).toBeNull();
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { parseGuide, kindOf, rewriteAssets } from '../scripts/campus/parse.mjs';
import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs';
const body = `
> Sintesi di studio basata sulla trascrizione della lezione del corso.
## In sintesi
Testo di prova.
## Concetti chiave
- **Il sonno è il pilastro.** Spiegazione.
- **Ritmi circadiani.** Altra spiegazione.
`;
describe('kindOf / sourceDirFor', () => {
it('riconosce il tipo dallo slug', () => {
expect(kindOf('lezione-01-ottimizzazione-del-sonno')).toBe('lezione');
expect(kindOf('lezione-live-1-2026-02-24')).toBe('live');
expect(kindOf('accelerator-1-2025-11-11')).toBe('accelerator');
expect(kindOf('slide-hrv')).toBe('slide');
expect(kindOf('speaker-resta-marketing')).toBe('speaker');
expect(kindOf('consulenza-2025-02-04-cristina-ruggeri')).toBe('consulenza');
expect(kindOf('masterclass-2025-10-08-luca-ruggeri')).toBe('masterclass');
expect(kindOf('qa-01-tony-04')).toBe('qa');
});
it('mappa lo slug alla cartella sorgente', () => {
expect(sourceDirFor('lezione-01-ottimizzazione-del-sonno')).toBe('studio');
expect(sourceDirFor('lezione-live-1-2026-02-24')).toBe('lezioni-live');
expect(sourceDirFor('qa-01-tony-04')).toBe('qa');
expect(sourceDirFor('consulenza-2025-02-04-cristina-ruggeri')).toBe('consulenze');
});
});
describe('parseGuide', () => {
it('lezione numerata con parte e relatore', () => {
const raw = '# Lezione 2 — Movimento e fitness (Parte 1) (Tony Manfron) — Guida allo studio' + body;
const g = parseGuide(raw, 'lezione-02-movimento-fitness-parte1', 1);
expect(g).toMatchObject({
kind: 'lezione', num: 2, relatore: 'Tony Manfron', parte: 'Parte 1',
titleFull: 'Movimento e fitness (Parte 1)', titleShort: 'Movimento e fitness',
label: 'L2 · Parte 1',
});
});
it('lezione live e accelerator usano la data come titolo', () => {
const live = parseGuide(
'# Lezione live 1 — 24 febbraio 2026 (Massimo Filippi) — Guida allo studio' + body,
'lezione-live-1-2026-02-24', 1);
expect(live).toMatchObject({
kind: 'live', num: 1, titleFull: 'Lezione live 1 — 24 febbraio 2026',
titleShort: 'Lezione live 1', relatore: 'Massimo Filippi · 24 febbraio 2026', label: 'LV1',
});
const acc = parseGuide(
'# Accelerator 3 — 28 gennaio 2026 (Massimo Filippi) — Guida allo studio' + body,
'accelerator-3-2026-01-28', 3);
expect(acc).toMatchObject({ kind: 'accelerator', num: 3, titleShort: 'Accelerator 3', label: 'A3' });
});
it('slide, speaker, consulenza, masterclass e Q&A', () => {
expect(parseGuide('# Slide — HRV: Variabilità della frequenza cardiaca — Guida allo studio' + body, 'slide-hrv', 1))
.toMatchObject({ kind: 'slide', num: 1, titleFull: 'HRV: Variabilità della frequenza cardiaca', label: 'SL1' });
expect(parseGuide('# Intervento — Marketing nel BioHacking (Roberto Resta) — Guida allo studio' + body, 'speaker-resta-marketing', 5))
.toMatchObject({ kind: 'speaker', titleFull: 'Marketing nel BioHacking', relatore: 'Roberto Resta', label: 'SP5' });
expect(parseGuide('# Consulenza — Cristina Ruggeri (04/02/2025) — Guida allo studio' + body, 'consulenza-2025-02-04-cristina-ruggeri', 1))
.toMatchObject({ kind: 'consulenza', titleFull: 'Cristina Ruggeri', relatore: '04/02/2025', label: 'C1' });
expect(parseGuide('# Masterclass — Luca Ruggeri (08/10/2025) — Guida allo studio' + body, 'masterclass-2025-10-08-luca-ruggeri', 1))
.toMatchObject({ kind: 'masterclass', titleFull: 'Luca Ruggeri', relatore: '08/10/2025', label: 'MC1' });
expect(parseGuide('# Q&A Tony n°4 — Guida allo studio' + body, 'qa-01-tony-04', 1))
.toMatchObject({ kind: 'qa', titleFull: 'Q&A Tony n°4', label: 'QA1' });
});
it('estrae i concetti chiave in grassetto e rimuove H1 e blockquote', () => {
const g = parseGuide('# Lezione 1 — Sonno (Tony Manfron) — Guida allo studio' + body,
'lezione-01-ottimizzazione-del-sonno', 1);
expect(g.concetti).toEqual(['Il sonno è il pilastro', 'Ritmi circadiani']);
expect(g.html).not.toContain('Guida allo studio');
expect(g.html).not.toContain('Sintesi di studio basata');
expect(g.html).toContain('<h2');
expect(g.html).toContain('Testo di prova');
});
it('rifiuta un H1 non riconosciuto', () => {
expect(() => parseGuide('# Titolo qualunque\n\ntesto', 'lezione-99-x', 1)).toThrow(/H1 non riconosciuto/);
});
});
describe('rewriteAssets', () => {
it('riscrive i path relativi delle immagini verso la route protetta', () => {
const html = '<img src="assets/slide/slide-hrv/fig-01.png" alt="x"><img src="/altro.png">';
expect(rewriteAssets(html)).toBe(
'<img src="/campus/assets/slide/slide-hrv/fig-01.png" alt="x"><img src="/altro.png">');
});
});
describe('struttura del corso', () => {
const chapterNums = CHAPTERS.map((c) => c[0] as number);
it('ogni capitolo ha un colore e ogni area capitoli esistenti', () => {
expect(CH_COLORS.length).toBe(CHAPTERS.length);
for (const area of AREAS)
for (const n of area[4] as number[]) expect(chapterNums).toContain(n);
});
it('nessuno slug duplicato e ogni capitolo è in una sola area', () => {
const slugs = CHAPTERS.flatMap((c) => c[3] as string[]);
expect(new Set(slugs).size).toBe(slugs.length);
const assigned = AREAS.flatMap((a) => a[4] as number[]);
expect(new Set(assigned).size).toBe(assigned.length);
expect(assigned.sort((a, b) => a - b)).toEqual(chapterNums);
});
});
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
import { creaCliente, codicePerUtente, etaAllaData } from '../../src/lib/longevity/anagrafica';
describe('anagrafica pseudonimizzata', () => {
it('il nome sta in identity, il sesso in longevity, e i due non si mescolano', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'Mario', cognome: 'Rossi', sesso: 'M', data_nascita: '1988-03-04' });
const inIdentity = id.prepare(`SELECT nome, cognome FROM clienti WHERE client_code = ?`).get(code) as
{ nome: string; cognome: string };
expect(inIdentity.nome).toBe('Mario');
const inLongevity = lg.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(code) as { sesso: string };
expect(inLongevity.sesso).toBe('M');
// in longevity non deve esistere nessuna colonna che contenga il nome
const dump = JSON.stringify(lg.prepare(`SELECT * FROM soggetti`).all());
expect(dump).not.toContain('Mario');
expect(dump).not.toContain('Rossi');
expect(dump).not.toContain('1988-03-04');
});
it('assegna codici progressivi e non riusa i vecchi', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' });
const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
expect(a).toMatch(/^ISL-\d{4}$/);
expect(b).not.toBe(a);
});
it('ritrova il codice a partire dall utente del sito', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F', user_id: 42 });
expect(codicePerUtente(id, 42)).toBe(code);
expect(codicePerUtente(id, 99)).toBeNull();
});
it('calcola l eta alla data della sessione', () => {
expect(etaAllaData('1988-03-04', '2026-08-21')).toBe(38);
expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato
});
it('un codice cancellato dall anagrafica non torna mai disponibile: le misure del vecchio non si attribuiscono al nuovo', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' });
const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
// Il secondo cliente viene cancellato dall'anagrafica (disdetta, richiesta di
// cancellazione, errore): le sue misure restano in longevity, pseudonimizzate.
id.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(b);
lg.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES (?, '2026-08-01', 'checkup')`).run(b);
const c = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F' });
// Il terzo cliente riceve un codice nuovo, mai il codice riciclato del secondo.
expect(c).not.toBe(b);
expect(c).not.toBe(a);
// Le sessioni del vecchio cliente (b) restano leggibili sotto il suo codice...
const sessioniDiB = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(b);
expect(sessioniDiB.length).toBe(1);
// ...e non risultano in nessun modo attribuite al nuovo cliente (c).
const sessioniDiC = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(c);
expect(sessioniDiC.length).toBe(0);
});
it('se la scrittura su longevity fallisce, quella gia fatta su identity viene annullata', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const primaDelTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
// 'Z' non è un sesso valido (CHECK IN ('M','F') su longevity.soggetti): la scrittura
// su longevity fallisce per costruzione, dopo che quella su identity è già avvenuta.
expect(() =>
creaCliente(id, lg, { nome: 'Errato', cognome: 'Errato', sesso: 'Z' as unknown as 'M' | 'F' })
).toThrow();
const dopoIlTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
expect(dopoIlTentativo).toBe(primaDelTentativo); // nessun cliente orfano rimasto in identity
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
});
it('rete secondaria: nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
// ⚠️ QUESTO TEST NON È LA GARANZIA. La garanzia vera è che getIdentityDb non è più
// esportata da db.ts (vive privata dentro anagrafica.ts): non esiste, da nessuna
// parte del codice fuori da lì, un nome da importare per ottenere quella connessione.
// Un controllo testuale come questo non può bastare da solo — è stato dimostrato:
// tre file, uno che riesporta la connessione a identity, uno che riesporta quella a
// longevity, un terzo che importa da entrambi e le usa insieme, e NESSUNO dei tre
// contiene singolarmente le due parole. Un controllo file-per-file non vede una
// giunzione che attraversa più file, e non c'è formulazione di questo test che lo
// possa vedere: la risposta era rendere la cosa impossibile, non sorvegliarla meglio.
// Questo test resta come rete secondaria — cattura il caso comune (una giunzione
// dentro un solo file) e protegge contro un domani in cui getIdentityDb tornasse
// esportata per errore — non come prova che la giunzione non possa esistere.
// Cerca i due riferimenti letterali "Identity"/"Longevity" (con l'iniziale maiuscola,
// come compaiono in qualunque accessore camelCase) invece di un elenco chiuso di nomi:
// un elenco di nomi vietati invecchia (è già successo qui: getIdentityDb/getLongevityDb
// sono nati dopo la prima versione di questo test e sono passati indisturbati).
// ⚠️ VERIFICATO IL 22/08, UN TERZO BUCO, STAVOLTA IN UN FILE SOLO: `createIdentityDb`
// (a differenza del singleton privato getIdentityDb) È esportata da db.ts — serve a
// creaCliente, che riceve due connessioni già aperte da fuori. Un file che chiama
// `createIdentityDb()` direttamente e apre longevity con una connessione propria (es.
// `new Database(process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db')`, mai col nome
// `getLongevityDb`/`createLongevityDb`) contiene "Identity" ma MAI "Longevity" con
// l'iniziale maiuscola — LONGEVITY_DB_PATH è tutto maiuscolo, 'longevity.db' tutto
// minuscolo — e questo test lo lascia passare. Provato con un file del genere in
// src/lib/longevity/: zero test rossi. Non è una falla da chiudere con un controllo
// più furbo (già tentato e aggirato due volte prima di questa): la garanzia resta solo
// quella architetturale sopra, e su `createIdentityDb` non c'è — è pensata per essere
// chiamata da fuori anagrafica.ts (creaCliente la riceve come parametro), quindi non si
// può chiuderla come si è chiusa getIdentityDb senza cambiare quella funzione.
const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = [];
const files = readdirSync(srcDir, { recursive: true, withFileTypes: false }) as string[];
for (const f of files) {
const path = join(srcDir, f);
// Salta node_modules e dist
if (f.includes('node_modules') || f.includes('dist')) continue;
// Accetta solo .ts e .astro
if (!f.endsWith('.ts') && !f.endsWith('.astro')) continue;
// Esclude i moduli autorizzati
if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue;
const src = readFileSync(path, 'utf8');
if (src.includes('Identity') && src.includes('Longevity')) {
colpevoli.push(f);
}
}
expect(colpevoli).toEqual([]);
});
});
@@ -0,0 +1,52 @@
// Il consenso veniva scritto DOPO che salvaCompilazione aveva già chiuso la propria
// transazione: se il passo successivo falliva, restavano i dati sanitari SENZA il record
// di consenso che li giustifica — l'esatto contrario dell'invariante ("o entra tutto nel
// fascicolo, o non entra niente"). Questo file forza un fallimento nel calcolo dei
// punteggi (mockando calcolaSessione) DOPO che la sessione e le misure sono già scritte, e
// verifica che l'intera transazione — sessione, misure, punteggi, consenso — vada
// indietro insieme. Il mock vive in un file a parte perché vi.mock è per-file: gli altri
// test su calcolaSessione (referto.test.ts, salvataggio-e2e.test.ts) devono restare reali.
import { describe, it, expect, vi } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
vi.mock('../../src/lib/longevity/motore', async (importOriginal) => {
const reale = await importOriginal<typeof import('../../src/lib/longevity/motore')>();
return {
...reale,
calcolaSessione: () => { throw new Error('rottura simulata nel calcolo dei punteggi'); },
};
});
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;
}
describe('salvaDalForm — atomicità di dati, punteggi e consenso', () => {
it('se il calcolo dei punteggi fallisce DOPO che sessione e misure sono state scritte, tutto torna indietro: niente sessione, niente misure, niente nota di consenso', async () => {
const { salvaDalForm } = await import('../../src/lib/longevity/vista');
const db = dbPronto();
expect(() =>
salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_riposato: 8 }, consensoSanitario: 'Si', liberi: {},
})
).toThrow(/rottura simulata/);
const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
const score = db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number };
const consenso = db.prepare(`SELECT COUNT(*) n FROM profilo_note WHERE campo_id = 'consenso_sanitario'`)
.get() as { n: number };
expect(sessioni.n).toBe(0);
expect(misure.n).toBe(0);
expect(score.n).toBe(0);
expect(consenso.n).toBe(0);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb, createIdentityDb, COPERTURA_MINIMA } from '../../src/lib/longevity/db';
describe('schema longevity', () => {
it('crea le tabelle previste dalla spec', () => {
const db = createLongevityDb(':memory:');
const nomi = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
.map((r) => r.name);
expect(nomi).toEqual(
expect.arrayContaining(['soggetti', 'sessioni', 'misure', 'registro_test', 'pesi', 'profilo_note', 'score'])
);
});
it('rifiuta un sesso non previsto', () => {
const db = createLongevityDb(':memory:');
expect(() =>
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'X')`).run()
).toThrow();
});
it('rifiuta una misura con test_id non nel registro', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte)
VALUES (1, 'ISL-0001', 'test_inventato', 10, 'manuale')`
).run()
).toThrow();
});
it('uno score insufficiente puo non avere valore', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')`
).run();
const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string };
expect(row.valore).toBeNull();
expect(row.stato).toBe('insufficiente');
});
it('la tabella rifiuta uno score insufficiente CON un valore scritto', () => {
// Il tipo Punteggio protegge il codice, ma l'interfaccia legge dalla tabella,
// dove il tipo non c'e' piu': senza questo vincolo si potrebbe scrivere un
// numero dichiarato "insufficiente" e chi legge crud dalla tabella lo prenderebbe.
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', 42, 0.2, 'insufficiente', 'v1.0')`
).run()
).toThrow();
});
it('la tabella rifiuta uno score ok SENZA valore', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 1, 'ok', 'v1.0')`
).run()
).toThrow();
});
it('identity tiene la persona, longevity non la conosce', () => {
const id = createIdentityDb(':memory:');
const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name);
expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita']));
const lg = createLongevityDb(':memory:');
const tutte = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
.flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name));
expect(tutte).not.toContain('nome');
expect(tutte).not.toContain('cognome');
expect(tutte).not.toContain('data_nascita');
});
it('la soglia di copertura e 0.40', () => {
expect(COPERTURA_MINIMA).toBe(0.4);
});
});
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { esportaMisure, esportaCsv } from '../../src/lib/longevity/export';
function dbConDati() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21', eta: 35,
risposte: { q_ore_sonno: 7, q_riposato: 8 },
liberi: { farmaci: 'nessuno' },
});
return db;
}
describe('export per le statistiche', () => {
it('esporta una riga per misura con codice, non con nome', () => {
const db = dbConDati();
const out = esportaMisure(db);
expect(out.intestazioni).toEqual(
['client_code', 'sesso', 'eta', 'data', 'tipo_sessione', 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range']
);
expect(out.righe.length).toBe(2);
expect(out.righe[0][0]).toBe('ISL-0001');
});
it('l export non contiene campi identificativi', () => {
const db = dbConDati();
const testo = JSON.stringify(esportaMisure(db));
for (const vietato of ['nome', 'cognome', 'email', 'telefono', 'data_nascita']) {
expect(testo).not.toContain(vietato);
}
});
it('i campi liberi restano fuori dall export statistico', () => {
const db = dbConDati();
expect(JSON.stringify(esportaMisure(db))).not.toContain('nessuno');
});
it('produce un CSV con intestazione e una riga per misura', () => {
const db = dbConDati();
const righe = esportaCsv(db).trim().split('\n');
expect(righe.length).toBe(3); // intestazione + 2 misure
expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range');
});
it('ogni valore sta sotto l intestazione giusta, colonna per colonna (asserzione posizionale)', () => {
// Non un indice scritto a mano: l'indice si cerca per NOME nell'intestazione prodotta.
// Se un domani la SELECT in export.ts viene riordinata senza riallineare INTESTAZIONI,
// il valore letto a quell'indice smette di corrispondere e l'asserzione fallisce -
// invece di restare verde con l'età etichettata come sesso.
const db = dbConDati();
const { intestazioni, righe } = esportaMisure(db);
const idx = (col: string) => {
const i = intestazioni.indexOf(col);
if (i < 0) throw new Error(`intestazione mancante: ${col}`);
return i;
};
const rigaSonno = righe.find((r) => r[idx('test_id')] === 'q_ore_sonno')!;
expect(rigaSonno).toBeDefined();
expect(rigaSonno[idx('client_code')]).toBe('ISL-0001');
expect(rigaSonno[idx('sesso')]).toBe('F');
expect(rigaSonno[idx('eta')]).toBe(35);
expect(rigaSonno[idx('data')]).toBe('2026-08-21');
expect(rigaSonno[idx('tipo_sessione')]).toBe('questionario');
expect(rigaSonno[idx('quest_version')]).toBe('v1.0');
expect(rigaSonno[idx('valore')]).toBe(7);
// salvaCompilazione non passa 'unita' per le risposte del questionario: resta null.
expect(rigaSonno[idx('unita')]).toBeNull();
expect(rigaSonno[idx('fonte')]).toBe('questionario');
expect(rigaSonno[idx('fuori_range')]).toBe(0);
const rigaRiposato = righe.find((r) => r[idx('test_id')] === 'q_riposato')!;
expect(rigaRiposato).toBeDefined();
expect(rigaRiposato[idx('client_code')]).toBe('ISL-0001');
expect(rigaRiposato[idx('sesso')]).toBe('F');
expect(rigaRiposato[idx('eta')]).toBe(35);
expect(rigaRiposato[idx('valore')]).toBe(8);
expect(rigaRiposato[idx('unita')]).toBeNull();
});
});
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { formattaNumero } from '../../src/lib/longevity/formato';
describe('formattaNumero — i numeri del referto in italiano', () => {
it('un decimale usa la virgola, non il punto', () => {
expect(formattaNumero(72.5)).toBe('72,5');
});
it('un intero resta senza decimali spuri', () => {
expect(formattaNumero(80)).toBe('80');
});
it('un negativo (possibile solo per differenze, non per un punteggio 0-100) mantiene il segno', () => {
expect(formattaNumero(-3.2)).toBe('-3,2');
});
});
@@ -0,0 +1,89 @@
// Il difetto più serio di questo ramo era "una funzione di autorizzazione corretta che
// nessuno chiama": isProtectedPath e canAccessAdminPath erano giuste, ma finché niente le
// collegava dentro src/middleware.ts le pagine sanitarie restavano pubbliche. Nessun altro
// test in tutto il repo importa src/middleware.ts: qui lo si carica davvero (vedi l'alias
// 'astro:middleware' in vitest.config.ts, necessario perché quel modulo è virtuale e
// risolto solo dentro la pipeline vite di Astro) e si esercita la funzione vera, non una
// sua reimplementazione.
import { describe, it, expect, beforeAll } from 'vitest';
// getDb() (usato da src/middleware.ts) apre 'data/insanitylab.db' su disco per default:
// DB_PATH va puntato a :memory: PRIMA che qualcosa lo chiami (il singleton è pigro).
process.env.DB_PATH = ':memory:';
type FakeContext = {
url: URL;
cookies: { get: (name: string) => { value: string } | undefined };
redirect: (path: string, status?: number) => Response;
locals: Record<string, unknown>;
};
function contesto(pathname: string, cookieValue?: string): FakeContext {
return {
url: new URL(`http://localhost${pathname}`),
cookies: { get: () => (cookieValue !== undefined ? { value: cookieValue } : undefined) },
redirect: (path, status) => new Response(null, { status: status ?? 302, headers: { Location: path } }),
locals: {},
};
}
function next() {
let chiamata = false;
const fn = async () => { chiamata = true; return new Response('PAGINA-SERVITA'); };
return { fn, fuChiamata: () => chiamata };
}
describe('src/middleware.ts — protezione reale delle rotte longevity', () => {
let onRequest: (context: FakeContext, next: () => Promise<Response>) => Response | Promise<Response>;
beforeAll(async () => {
const mod = await import('../../src/middleware.ts');
// FakeContext è un doppio minimale di APIContext (solo cio' che onRequest usa
// davvero: url, cookies, redirect, locals), non un APIContext completo: il cast
// diretto viene rifiutato dal typecheck perche' i due tipi non si sovrappongono
// abbastanza. Si passa da 'unknown' perche' e' un cast intenzionale, non un bug.
onRequest = mod.onRequest as unknown as typeof onRequest;
});
it('una rotta longevity senza sessione (nessun cookie) redirige al login e non serve la pagina', async () => {
const ctx = contesto('/longevity/io');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(false);
expect(res).toBeInstanceOf(Response);
expect([301, 302, 303, 307, 308]).toContain((res as Response).status);
expect((res as Response).headers.get('Location')).toMatch(/^\/login/);
});
it('una rotta longevity con un token di sessione inesistente redirige comunque al login', async () => {
const ctx = contesto('/longevity/gestionale', 'token-che-non-esiste-in-nessuna-sessione');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(false);
expect((res as Response).headers.get('Location')).toMatch(/^\/login/);
});
it('una API longevity senza sessione risponde 401, non serve la richiesta', async () => {
const ctx = contesto('/api/longevity/qualcosa');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(false);
expect((res as Response).status).toBe(401);
const body = await (res as Response).json();
expect(body.error).toBeTruthy();
});
// ⚠️ In quest'app le rotte pubbliche sono l'eccezione, non la regola: /blog non esiste
// piu' (e' rimasto sul sito) e la sola pagina raggiungibile senza sessione e' il login.
it('il login non passa da nessun controllo: next() viene chiamata', async () => {
const ctx = contesto('/login');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(true);
expect(await (res as Response).text()).toBe('PAGINA-SERVITA');
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('registrazione delle misure', () => {
it('scrive le misure di una sessione', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' });
const esito = registraMisure(db, s, 'questionario', [
{ test_id: 'q_ore_sonno', valore_num: 7.5 },
{ test_id: 'q_riposato', valore_num: 8 },
]);
expect(esito.scritte).toBe(2);
const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ?`).get(s) as { n: number };
expect(n.n).toBe(2);
});
it('rifiuta un test sconosciuto e non scrive niente del lotto', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'checkup' });
expect(() =>
registraMisure(db, s, 'manuale', [
{ test_id: 'q_ore_sonno', valore_num: 7 },
{ test_id: 'test_inventato', valore_num: 1 },
])
).toThrow(/test_inventato/);
const n = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
expect(n.n).toBe(0);
});
it('un valore fuori dal range atteso si scrive e si marca', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario' });
const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]);
expect(esito.fuoriRange).toEqual(['q_ore_sonno']);
const row = db.prepare(`SELECT valore_num, fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { valore_num: number; fuori_range: number };
expect(row.fuori_range).toBe(1);
expect(row.valore_num).toBe(26);
});
it('la sessione porta la versione del questionario', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' });
const row = db.prepare(`SELECT quest_version FROM sessioni WHERE id = ?`).get(s) as { quest_version: string };
expect(row.quest_version).toBe('v1.0');
});
it('il client_code della misura viene dalla sessione, non da chi chiama', () => {
const db = dbPronto();
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run();
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-21', tipo: 'checkup' });
registraMisure(db, s, 'manuale', [{ test_id: 'q_riposato', valore_num: 5 }]);
const row = db.prepare(`SELECT client_code FROM misure`).get() as { client_code: string };
expect(row.client_code).toBe('ISL-0002');
});
});
@@ -0,0 +1,99 @@
// tests/longevity/motore-cascata.test.ts
import { describe, it, expect } from 'vitest';
import { COPERTURA_MINIMA } from '../../src/lib/longevity/db';
import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata';
describe('aggregazione e rinormalizzazione', () => {
it('con tutti i dati fa la media pesata', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]);
expect(r.stato).toBe('ok');
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); }
});
it('un dato mancante ridistribuisce il suo peso, non vale zero', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]);
expect(r.stato).toBe('ok');
// 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); }
});
it('sotto la soglia di copertura NON esiste un valore da leggere', () => {
const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBeCloseTo(0.2, 2);
// il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero
expect((r as { valore?: number }).valore).toBeUndefined();
});
it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => {
const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]);
const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]);
expect(pocoSotto.stato).toBe('insufficiente');
expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo
});
it('senza nessun dato e insufficiente con copertura zero', () => {
const r = aggrega([{ punteggio: null, peso: 1 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBe(0);
});
// Bug trovato in revisione: il confronto con la soglia usava la copertura
// GIA' arrotondata a due decimali, non quella grezza. Una copertura grezza
// di 0.396 arrotonda a 0.40 - uguale alla soglia - e con l'arrotondamento
// fatto PRIMA del confronto passava per 'ok'. Verificato anche contro
// l'oracolo Python: aggregate([WeightedScore(90, 0.396), WeightedScore(None,
// 0.604)]) -> (90.0, 0.4, 'insufficiente').
it('una copertura grezza appena sotto la soglia resta insufficiente anche se arrotonda a 0.40', () => {
const r = aggrega([{ punteggio: 90, peso: 0.396 }, { punteggio: null, peso: 0.604 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBeCloseTo(0.40, 2); // il valore esposto arrotonda a 0.40, ma lo stato resta insufficiente
});
// Simmetrico: una copertura grezza appena SOPRA la soglia (0.404, che
// arrotonda anch'essa a 0.40) deve restare 'ok' - il fix non deve aver
// spostato la soglia dall'altra parte. Oracolo: aggregate([WeightedScore(90,
// 0.404), WeightedScore(None, 0.596)]) -> (90.0, 0.4, 'ok').
it('una copertura grezza appena sopra la soglia resta ok: il fix non ha spostato la soglia', () => {
const r = aggrega([{ punteggio: 90, peso: 0.404 }, { punteggio: null, peso: 0.596 }]);
expect(r.stato).toBe('ok');
if (r.stato === 'ok') expect(r.copertura).toBeCloseTo(0.40, 2);
});
});
describe('assi, macro e Fitness Age', () => {
const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 };
it('un asse si calcola sui suoi sotto-domini', () => {
const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 });
expect(r.stato).toBe('ok');
// 70*.25 + 60*.2 + 65*.2 + 80*.2 + 50*.15 = 66.0 (verificato anche a mano, non 66.25 come
// nel brief: la costante li' e' un refuso, corretto qui dopo averlo verificato col conto)
if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.0, 1);
});
it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => {
const assi = {
A: { stato: 'ok', valore: 80, copertura: 1 } as const,
B: { stato: 'insufficiente', copertura: 0.1 } as const,
};
const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi);
expect(r.stato).toBe('ok');
// se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); }
});
it('la Fitness Age scende sotto l eta quando il composito supera 50', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 });
// 40 - (75 - 50) * 0.4 = 30
expect(r.fitnessAge).toBeCloseTo(30, 1);
});
it('senza dati sufficienti la Fitness Age non esiste', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null });
expect(r.composito.stato).toBe('insufficiente');
expect(r.fitnessAge).toBeNull();
});
});
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
import { calcolaSessione } from '../../src/lib/longevity/motore';
/**
* Riproduce la situazione descritta nella documentazione del cliente: una persona di cui
* si conosce solo la composizione corporea, misurata dalla Wellness Tower. Un asse pieno,
* tutti gli altri scoperti. È il caso in cui un motore ingenuo mostrerebbe sei zeri.
* Dati sintetici: nessuna persona reale.
*/
describe('un profilo con una sola area misurata', () => {
it('mostra l asse coperto e dichiara insufficienti gli altri sei', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','M')`).run();
// i tre test di composizione esistono nel registro solo dopo il piano degli import:
// finche non ci sono, questo test dimostra il comportamento con zero misure fisiche
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
const insufficienti = Object.values(r.assi).filter((a) => a.stato === 'insufficiente').length;
expect(insufficienti).toBe(7);
expect(r.fitnessAge).toBeNull();
});
it('nessun asse insufficiente porta con se un valore da mostrare per sbaglio', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','F')`).run();
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 35 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
for (const [nome, asse] of Object.entries(r.assi)) {
if (asse.stato === 'insufficiente') {
expect((asse as { valore?: number }).valore, `${nome} espone un valore`).toBeUndefined();
}
}
});
/**
* Il caso misto: quello che la prima dashboard disegnera' davvero, non "tutto
* insufficiente". Realizza per intero la premessa del describe qui sopra — la
* composizione corporea davvero misurata dalla Wellness Tower — aggiungendo le
* due voci al registro (arrivano col piano degli import; qui bastano due righe,
* non serve aspettarle) e registrando le misure end-to-end.
*/
it('un asse valido (Composizione Corporea) e sei insufficienti, end-to-end', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0003','M')`).run();
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 }));
db.prepare(
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
VALUES ('wt_muscolo', 'Muscolo % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'muscolo', '2026-01-01')`
).run(JSON.stringify({ best: 50, worst: 30 }));
const s = apriSessione(db, { client_code: 'ISL-0003', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'wellness_tower', [
{ test_id: 'wt_grasso', valore_num: 15 },
{ test_id: 'wt_muscolo', valore_num: 45 },
]);
const r = calcolaSessione(db, s);
expect(r.assi['Composizione Corporea'].stato).toBe('ok');
const insufficienti = Object.entries(r.assi)
.filter(([nome, a]) => nome !== 'Composizione Corporea' && a.stato === 'insufficiente');
expect(insufficienti.length).toBe(6);
});
});
@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
clamp, lerp, arrotonda1, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini,
} from '../../src/lib/longevity/motore/curve';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn);
describe('curve del questionario, confrontate con l oracolo', () => {
it('la campana combacia su tutto il dominio', () => {
const casi = casiDi('score_bell_curve');
expect(casi.length).toBeGreaterThan(20);
for (const c of casi) {
const [v, low, pl, ph, high] = c.args as number[];
expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1);
}
});
it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => {
const casi = casiDi('score_decreasing');
expect(casi.length).toBeGreaterThan(40);
for (const c of casi) {
const [v, best, worst] = c.args as number[];
expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1);
}
});
it('la crescente con plateau combacia', () => {
const casi = casiDi('score_increasing_plateau');
expect(casi.length).toBeGreaterThan(20);
for (const c of casi) {
const [v, worst, plateau] = c.args as number[];
expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1);
}
});
it('la diretta per dieci combacia', () => {
const casi = casiDi('score_direct_x10');
expect(casi.length).toBeGreaterThan(10);
for (const c of casi) {
expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1);
}
});
// Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca.
// Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10.
it('la diretta invertita e il complemento della diretta', () => {
for (const v of [0, 2.5, 5, 7.5, 10]) {
expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6);
}
expect(curvaDirettaX10Invertita(0)).toBe(100);
expect(curvaDirettaX10Invertita(10)).toBe(0);
});
it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => {
const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]];
expect(curvaGradini(0, steps, undefined, true)).toBe(100);
expect(curvaGradini(5, steps, undefined, true)).toBe(60);
expect(curvaGradini(10, steps, undefined, true)).toBe(40);
expect(curvaGradini(20, steps, undefined, true)).toBe(20);
expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20);
});
// Il ramo crescente non era esercitato da nessun test: l'ancoraggio (0, 20)
// — invece di (0, 100) — poteva essere invertito o sbagliato senza che
// niente diventasse rosso. Parametri veri da src/lib/longevity/registro.ts.
it('i gradini crescenti riproducono il prototipo: q_attivita e q_luce', () => {
// q_attivita: nessun zeroVal, i gradini partono già da (0, 20).
const stepsAttivita: [number, number][] = [[0, 20], [2, 50], [4, 80], [999, 100]];
expect(curvaGradini(0, stepsAttivita, undefined, false)).toBe(20);
expect(curvaGradini(2, stepsAttivita, undefined, false)).toBe(50);
expect(curvaGradini(4, stepsAttivita, undefined, false)).toBe(80);
expect(curvaGradini(999, stepsAttivita, undefined, false)).toBe(100);
expect(curvaGradini(1, stepsAttivita, undefined, false)).toBe(35); // interpolato fra (0,20) e (2,50)
expect(curvaGradini(3, stepsAttivita, undefined, false)).toBe(65); // interpolato fra (2,50) e (4,80)
// q_luce: zeroVal 20, gradini che partono da 0.5 — l'ancoraggio (0, 20)
// serve davvero per interpolare fra 0 (escluso, coperto da zeroVal) e 0.5.
const stepsLuce: [number, number][] = [[0.5, 60], [1, 90], [999, 100]];
expect(curvaGradini(0.5, stepsLuce, 20, false)).toBe(60);
expect(curvaGradini(1, stepsLuce, 20, false)).toBe(90);
expect(curvaGradini(999, stepsLuce, 20, false)).toBe(100);
expect(curvaGradini(0.75, stepsLuce, 20, false)).toBe(75); // interpolato fra (0.5,60) e (1,90)
expect(curvaGradini(0.25, stepsLuce, 20, false)).toBe(40); // interpolato fra l'ancoraggio (0,20) e (0.5,60)
});
// Non basta controllare che a zero il risultato sia il valore atteso: se
// zeroVal COINCIDE con l'ancoraggio di default (100 per il decrescente,
// 20 per il crescente, come in q_schermi e q_luce) il test passa anche
// cancellando dal codice la riga che gestisce lo zero. Qui zeroVal è
// deliberatamente diverso dall'ancoraggio, in entrambe le direzioni: se
// la riga sparisse, l'interpolazione a v=0 darebbe l'ancoraggio (100 o 20),
// non questi valori, e il test diventerebbe rosso.
it('zeroVal vince sui gradini anche quando NON coincide con l ancoraggio', () => {
// q_schermi, il caso reale: qui zeroVal (100) coincide con l'ancoraggio
// decrescente (100), quindi da solo NON è discriminante — resta utile
// come controllo del valore vero, non come prova dello zero speciale.
const stepsSchermi: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]];
expect(curvaGradini(0, stepsSchermi, 100, true)).toBe(100);
expect(curvaGradini(15, stepsSchermi, 100, true)).toBe(85);
// Discriminante, decrescente: ancoraggio di default sarebbe 100,
// zeroVal è 42. Senza la riga dello zero speciale risulterebbe 100.
const stepsDecrescenteDiscriminante: [number, number][] = [[10, 80], [30, 50], [999, 10]];
expect(curvaGradini(0, stepsDecrescenteDiscriminante, 42, true)).toBe(42);
// Discriminante, crescente: ancoraggio di default sarebbe 20,
// zeroVal è 55. Senza la riga dello zero speciale risulterebbe 20.
const stepsCrescenteDiscriminante: [number, number][] = [[5, 40], [20, 70], [999, 100]];
expect(curvaGradini(0, stepsCrescenteDiscriminante, 55, false)).toBe(55);
});
it('clamp e lerp si comportano come nell oracolo', () => {
expect(clamp(150)).toBe(100);
expect(clamp(-5)).toBe(0);
expect(lerp(5, 0, 10, 0, 100)).toBe(50);
expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1]
expect(lerp(11, 0, 10, 0, 100)).toBe(100);
});
// round(x, 1) di Python arrotonda al pari sui pareggi ESATTI (banker's
// rounding), non sempre in su. Senza questi casi un arrotondamento naive
// (Math.round(x*10)/10, che su 86.25 darebbe 86.3) sarebbe indistinguibile
// dal corretto in tutti gli altri test: qui il pareggio e' deliberato.
it('arrotonda1 arrotonda al pari sui pareggi esatti, come round() di Python', () => {
expect(arrotonda1(86.25)).toBe(86.2); // 862 e' pari: resta 86.2, non sale a 86.3
expect(arrotonda1(86.75)).toBe(86.8); // 867 e' dispari: sale a 86.8, non resta 86.7
expect(arrotonda1(86.24)).toBe(86.2); // non un pareggio: arrotondamento normale, per basso
expect(arrotonda1(86.26)).toBe(86.3); // non un pareggio: arrotondamento normale, per alto
});
// 86.35 NON e' un pareggio vero: il double che gli sta dietro vale
// 86.34999999999999431..., quindi Python arrotonda (senza ambiguita') a
// 86.3. Una prima versione di arrotonda1, che moltiplicava per 10 e
// testava una tolleranza, ci cadeva: 86.35 * 10 arrotonda ESATTAMENTE a
// 863.5 in virgola mobile (un pareggio creato dalla moltiplicazione, non
// presente nel valore originale) e restituiva 86.4. Questo test blocca
// proprio quella regressione.
it('arrotonda1 non inventa un pareggio dove il double non ce l ha (86.35 -> 86.3, non un pareggio)', () => {
expect(arrotonda1(86.35)).toBe(86.3);
expect(arrotonda1(-86.35)).toBe(-86.3);
});
// Generalizzazione a `decimali` cifre (usata da cascata.ts per la copertura,
// a 2 decimali): stesso criterio dei test sopra, un gradino piu' in la'.
// 0.125 e 0.375 sono pareggi ESATTI a due decimali (double rappresentabili
// in binario senza resto: 1/8 e 3/8), verificati anche a mano con
// round(0.125, 2) e round(0.375, 2) in Python.
it('arrotonda1(x, 2) arrotonda al pari sui pareggi esatti a due decimali', () => {
expect(arrotonda1(0.125, 2)).toBe(0.12); // 12 e' pari: resta 0.12, non sale a 0.13
expect(arrotonda1(0.375, 2)).toBe(0.38); // 37 e' dispari: sale a 0.38, non resta 0.37
expect(arrotonda1(0.124, 2)).toBe(0.12); // non un pareggio: arrotondamento normale, per basso
expect(arrotonda1(0.126, 2)).toBe(0.13); // non un pareggio: arrotondamento normale, per alto
});
// 0.045 e' l'analogo a due decimali di 86.35: il double che gli sta dietro
// vale 0.044999999999999998..., quindi Python (e l'oracolo) arrotondano
// SENZA ambiguita' a 0.04. Ma 0.045 * 100 arrotonda ESATTAMENTE a 4.5 in
// virgola mobile - un pareggio creato dalla moltiplicazione, non presente
// nel valore originale - e un arrotondamento naive (Math.round(x*100)/100,
// la stessa `arrotonda2` locale rimossa da cascata.ts) ci sarebbe caduto,
// restituendo 0.05 invece di 0.04.
it('arrotonda1(x, 2) non inventa un pareggio dove il double non ce l ha (0.045 -> 0.04, non un pareggio)', () => {
expect(arrotonda1(0.045, 2)).toBe(0.04);
expect(arrotonda1(-0.045, 2)).toBe(-0.04);
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as F from '../../src/lib/longevity/motore/test-fisici';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
/** Ogni funzione dell'oracolo con la sua gemella in TypeScript. */
const COPPIE: [string, (...a: never[]) => number][] = [
['score_fat_percent', F.scoreGrassoPercento as never],
['score_muscle_percent', F.scoreMuscoloPercento as never],
['score_whr', F.scoreWhr as never],
['score_vo2max', F.scoreVo2max as never],
['score_spo2', F.scoreSpo2 as never],
['score_blood_pressure', F.scorePressione as never],
['score_hrr', F.scoreRecuperoCardiaco as never],
['score_handgrip', F.scoreHandgrip as never],
['score_pushup', F.scorePushup as never],
['score_flexed_arm_hang', F.scoreTrazioneIsometrica as never],
['score_sit_to_stand_1min', F.scoreSitToStand as never],
['score_plank', F.scorePlank as never],
['score_back_scratch', F.scoreBackScratch as never],
['score_shoulder_mobility_wt', F.scoreMobilitaSpalla as never],
['score_flamingo', F.scoreFlamingo as never],
['score_sit_and_reach', F.scoreSitAndReach as never],
['vo2max_from_step_test', F.vo2maxDaStepTest as never],
['vo2max_from_2km_walk', F.vo2maxDa2kmWalk as never],
['vo2max_from_mutt', F.vo2maxDaMutt as never],
['vo2max_from_milfit', F.vo2maxDaMilfit as never],
];
describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => {
for (const [nomePython, fnTs] of COPPIE) {
it(`${nomePython} combacia su tutti i casi del riferimento`, () => {
const casi = RIF.casi.filter((c) => c.fn === nomePython);
expect(casi.length, `nessun caso per ${nomePython}: la griglia non lo copre`).toBeGreaterThan(0);
const divergenti: string[] = [];
for (const c of casi) {
const ottenuto = (fnTs as (...a: unknown[]) => number)(...c.args);
if (Math.abs(ottenuto - c.atteso) > 0.05) {
divergenti.push(`${nomePython}(${c.args.join(', ')}): atteso ${c.atteso}, ottenuto ${ottenuto}`);
}
}
expect(divergenti.slice(0, 5).join('\n')).toBe('');
});
}
it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle DEFINITE NEL CODICE', () => {
// Prima versione di questo test: passava alla funzione le tabelle lette dagli
// argomenti del riferimento (c.args), non le costanti TIERS_* definite nel
// codice — le sei tabelle non erano confrontate contro l'oracolo da nessuno.
// Qui si usano SOLO le costanti del modulo: se una venisse alterata, nessun
// caso combacerebbe piu' con gli argomenti del riferimento e il test cade.
const casi = RIF.casi.filter((c) => c.fn === 'score_bw_ratio_lift');
expect(casi.length).toBeGreaterThan(50);
const TABELLE: [string, [number, number][], [number, number][]][] = [
['bench', F.TIERS_BENCH_M, F.TIERS_BENCH_F],
['squat', F.TIERS_SQUAT_M, F.TIERS_SQUAT_F],
['row', F.TIERS_ROW_M, F.TIERS_ROW_F],
];
const perTabella: Record<string, number> = { bench: 0, squat: 0, row: 0 };
for (const c of casi) {
const [carico, peso, rip, tiersMRif, , sesso] = c.args as [number, number, number, unknown, unknown, 'M' | 'F'];
// Identifica QUALE tabella del codice questo caso sta esercitando confrontando
// gli argomenti del riferimento con le costanti vere — non il contrario.
const trovata = TABELLE.find(([, tm]) => JSON.stringify(tm) === JSON.stringify(tiersMRif));
expect(trovata, `nessuna tabella del codice combacia con gli argomenti del caso: carico ${carico} ${sesso}`).toBeDefined();
const [nome, tiersM, tiersF] = trovata!;
perTabella[nome]++;
const ottenuto = F.scoreSollevamentoSuPeso(carico, peso, rip, tiersM, tiersF, sesso);
expect(ottenuto, `${nome} carico ${carico} ${sesso}`).toBeCloseTo(c.atteso, 1);
}
// Tutte e tre le tabelle sono state esercitate davvero, non solo dichiarate.
expect(perTabella.bench).toBeGreaterThan(0);
expect(perTabella.squat).toBeGreaterThan(0);
expect(perTabella.row).toBeGreaterThan(0);
});
it('le due funzioni escluse dal porting non esistono nel modulo', () => {
// Agilita: il cliente l'ha esclusa dallo score il 13/08 (scale non comparabili fra protocolli).
expect((F as Record<string, unknown>).scoreAgilita).toBeUndefined();
expect((F as Record<string, unknown>).scoreAgilityMs).toBeUndefined();
// score_generic_range_local: definita in fondo all'oracolo ma non chiamata da nessun peso.
expect((F as Record<string, unknown>).scoreGenericRangeLocal).toBeUndefined();
});
});
@@ -0,0 +1,305 @@
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);
}
});
});
@@ -0,0 +1,96 @@
// tests/longevity/pagine.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
describe('il guscio della piattaforma', () => {
it('il layout esiste e non monta header e footer pubblici', () => {
const l = leggi('src/layouts/Longevity.astro');
expect(l).not.toMatch(/import\s+Header/);
expect(l).not.toMatch(/import\s+Footer/);
expect(l).toMatch(/noindex/); // area riservata: mai indicizzata
});
it('il foglio di stile e globale e prefissato, non scoped', () => {
expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
const css = leggi('src/styles/longevity.css');
const classi = [...css.matchAll(/^\.([a-z-]+)/gm)].map((m) => m[1]);
expect(classi.length).toBeGreaterThan(3);
// "lg" da solo o "lg-qualcosa": il solo startsWith('lg') lascerebbe passare anche "lgfoo".
expect(classi.every((c) => c === 'lg' || c.startsWith('lg-'))).toBe(true);
});
it('usa i token del sito invece di inventare colori', () => {
const css = leggi('src/styles/longevity.css');
expect(css).toMatch(/var\(--c-/);
});
it('nessuna variabile porta un nome da semaforo (verde/buono/ok)', () => {
const css = leggi('src/styles/longevity.css').toLowerCase();
// il mattone del fuori range e ammesso; un verde "tutto bene" no
expect(css).not.toMatch(/--lg-ok|--lg-buono|--lg-verde/);
});
it('in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate', () => {
// Il test sopra cerca nomi vietati: chi scrivesse un verde in esadecimale, o lo
// chiamasse con un nome innocuo (var(--lg-buona-salute)), passerebbe indisturbato.
// Qui si fa il contrario: si elenca ciò che PUÒ comparire, e qualunque altro colore —
// letterale (esadecimale, rgb(), nome CSS) o var() con un altro nome — è una
// regressione.
//
// PRIMA questo test filtrava le regole sul selettore ("solo quelle con 'lg-score' nel
// nome"): un elenco di QUALI regole guardare invecchia appena nasce una classe nuova —
// è successo con .lg-radar__voce-nome, .lg-radar__intro, .lg-macro__nota (Task 3): non
// contengono la stringa "lg-score", quindi non venivano MAI controllate, in silenzio.
// Il foglio è interamente prefissato `lg-` per costruzione (verificato dal test sopra):
// non serve scegliere quali regole guardare, si guardano TUTTE, e le eccezioni
// legittime si dichiarano una per una — selettore, valore esatto, motivo — così un
// colore fuori lista entra solo con una decisione visibile, mai con un buco nel filtro.
const css = leggi('src/styles/longevity.css');
const AMMESSI = new Set([
'--c-accent', '--c-accent-dark', // l'accento del sito: l'unica leva per l'intensità
'--c-heading', '--c-text', '--c-text-light', // testo neutro, uguale ovunque nel sito
'--c-bg', '--c-bg-alt', // sfondi neutri
'--lg-line', '--lg-surface', // struttura locale, neutra
'--lg-tratteggio', // il perimetro di ciò che non sappiamo
'--lg-fuori-range', // SOLO una misura sospetta, mai un punteggio
]);
// Eccezioni dichiarate: selettore esatto + valore esatto + perché non è un giudizio sul
// punteggio. Ogni riga qui è una decisione presa a vista, non un buco silenzioso.
const ECCEZIONI: { selettore: string; valore: string; motivo: string }[] = [
{
selettore: '.lg-form__nav button:last-child:not(:only-child)',
valore: '#fff',
motivo: "testo bianco per contrasto sul bottone pieno d'accento — non un colore di punteggio",
},
];
const NON_COLORE = /^(transparent|inherit|initial|unset|currentcolor|none)$/i;
const PROP_COLORE = /^(color|background|background-color|border(-\w+)?-color|outline-color|fill|stroke)$/i;
const violazioni: string[] = [];
for (const regola of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selettore = regola[1].trim();
for (const dich of regola[2].split(';')) {
const due = dich.indexOf(':');
if (due < 0) continue;
const prop = dich.slice(0, due).trim().toLowerCase();
const val = dich.slice(due + 1).trim();
if (!PROP_COLORE.test(prop) || val === '' || NON_COLORE.test(val)) continue;
const varMatch = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,.*)?\)$/i);
if (varMatch && AMMESSI.has(varMatch[1])) continue;
if (ECCEZIONI.some((e) => e.selettore === selettore && e.valore === val)) continue;
violazioni.push(`${selettore} { ${prop}: ${val} }`);
}
}
expect(violazioni).toEqual([]);
});
it('la rotta del cliente e sotto /longevity, e rimanda davvero al referto', () => {
const p = join(process.cwd(), 'src/pages/longevity/index.astro');
expect(existsSync(p)).toBe(true);
const src = readFileSync(p, 'utf8');
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
});
});
@@ -0,0 +1,66 @@
// La prima apertura del referto (revisione del 22/08): con i dati di oggi il cliente vede
// molti assi tratteggiati. È corretto, ma la pagina deve SPIEGARLO, non lasciarlo sembrare
// un guasto. Questi controlli leggono il sorgente delle pagine/componenti — come già fa
// referto.test.ts per "nessuna pagina interroga il database da sola" — perché non c'è un
// harness di rendering per Astro/React in questo repo (nessuna dipendenza nuova ammessa).
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
describe('la Fitness Age, quando manca, spiega perché (come le altre carte)', () => {
it('io.astro mostra una nota di copertura anche per la Fitness Age, non solo un trattino nudo', () => {
const src = leggi('src/pages/longevity/io.astro');
// Deve leggere la copertura dedicata (vista.ts, fitnessAgeCopertura), non inventarsi
// un numero: la sola presenza del trattino '—' non basta più.
expect(src).toMatch(/fitnessAgeCopertura/);
expect(src).toMatch(/Copertura dati/);
});
it('il numero della Fitness Age, quando c\'è, è formattato all\'italiana', () => {
const src = leggi('src/pages/longevity/io.astro');
expect(src).toMatch(/formattaNumero\(referto\.fitnessAge\)/);
});
});
describe('il radar spiega prima di mostrare', () => {
it('la spiegazione del radar precede il grafico, non lo segue', () => {
const src = leggi('src/components/longevity/Radar.tsx');
const idxIntro = src.indexOf("L'area piena");
const idxGrafico = src.indexOf('<ResponsiveContainer');
expect(idxIntro).toBeGreaterThan(-1);
expect(idxGrafico).toBeGreaterThan(-1);
expect(idxIntro).toBeLessThan(idxGrafico);
});
it("l'elenco di ciò che manca invita a fare qualcosa, non solo a leggere nomi di test", () => {
const src = leggi('src/components/longevity/Radar.tsx');
expect(src).toMatch(/trainer/i);
});
});
describe('la copertura si mostra sempre, non solo quando il numero manca', () => {
it('MacroScore.astro non nasconde più la copertura quando il punteggio c\'è', () => {
const src = leggi('src/components/longevity/MacroScore.astro');
// PRIMA: `{valore === null && (<p ...>Copertura dati...`. La nota deve poter comparire
// anche quando valore !== null: la vecchia guardia esatta non deve più esserci.
expect(src).not.toMatch(/\{valore === null && \(\s*<p class="lg-muted lg-macro__nota">/);
expect(src).toMatch(/Copertura dati/);
});
it('il punteggio di MacroScore è formattato all\'italiana', () => {
const src = leggi('src/components/longevity/MacroScore.astro');
expect(src).toMatch(/formattaNumero\(valore\)/);
});
});
describe('chi ha già compilato può ricompilare (monitoraggio nel tempo)', () => {
it('io.astro offre il collegamento al questionario anche quando il referto esiste già', () => {
const src = leggi('src/pages/longevity/io.astro');
const idxReferto = src.indexOf('{referto && (');
expect(idxReferto).toBeGreaterThan(-1);
const blocco = src.slice(idxReferto);
expect(blocco).toMatch(/href=["'`]\/longevity\/questionario["'`]/);
});
});
@@ -0,0 +1,182 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
import { creaCliente } from '../../src/lib/longevity/anagrafica';
import { CONSENT_VERSION, QUEST_VERSION } from '../../src/lib/longevity/questionario';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
describe('i campi del questionario vengono dal registro', () => {
it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
const db = dbPronto();
const campi = campiDelQuestionario(db, '2026-08-22');
expect(campi.length).toBe(20);
const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
expect(alcol.etichetta).toMatch(/alcoliche/i);
expect(alcol.max).toBe(30);
});
it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
const db = dbPronto();
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
const campi = campiDelQuestionario(db, '2026-08-22');
expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
expect(campi.length).toBe(19);
});
});
describe('il salvataggio, e il consenso', () => {
const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };
it('salva una compilazione quando il consenso e dato', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'Si', liberi: {},
});
expect(esito.ok).toBe(true);
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
expect(n.n).toBe(1);
});
it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'No', liberi: {},
});
expect(esito.ok).toBe(false);
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
expect(n.n).toBe(0); // niente sessione, niente misure
});
it('NON salva se il consenso manca del tutto', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: '', liberi: {},
});
expect(esito.ok).toBe(false);
});
it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
const db = dbPronto();
salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'No', liberi: {},
});
const note = db.prepare(
`SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
).all() as { campo_id: string; testo: string }[];
expect(note.length).toBeGreaterThan(0);
expect(note[0].testo).toMatch(/No/);
});
// PRIMA di questa correzione il ramo del consenso DATO non era coperto da nessun test:
// cancellando la riga che registra il consenso accettato (registraConsenso dentro il ramo
// affermativo di salvaDalForm) la suite restava tutta verde — provato. Il consenso dato è
// l'unico documento che un domani si esibisce a chi chiede conto del trattamento, tanto
// quanto il rifiuto (già coperto sopra).
it('registra il consenso anche quando e dato: e il documento che un domani va esibito', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'Si', liberi: {},
});
expect(esito.ok).toBe(true);
const note = db.prepare(
`SELECT campo_id, testo, sessione_id FROM profilo_note WHERE campo_id = 'consenso_sanitario'`
).all() as { campo_id: string; testo: string; sessione_id: number | null }[];
expect(note.length).toBe(1);
expect(note[0].testo).toMatch(/Si/);
if (esito.ok) expect(note[0].sessione_id).toBe(esito.sessioneId);
});
// Il record del consenso portava la versione delle DOMANDE (QUEST_VERSION): se cambia il
// testo del consenso senza che cambi una domanda, il record non lo diceva. Ora porta una
// versione propria (CONSENT_VERSION, questionario.ts), dichiarata accanto a QUEST_VERSION.
it('il record del consenso porta CONSENT_VERSION, non QUEST_VERSION', () => {
const db = dbPronto();
salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte, consensoSanitario: 'Si', liberi: {},
});
const nota = db.prepare(
`SELECT testo FROM profilo_note WHERE campo_id = 'consenso_sanitario'`
).get() as { testo: string };
expect(nota.testo).toContain(`consenso ${CONSENT_VERSION}`);
expect(nota.testo).not.toMatch(/questionario\s+v/i);
// Le due costanti oggi coincidono di valore ('v1.0'): la prova che conta è che sono
// DUE COSTANTI DISTINTE (non lo stesso import rietichettato) — non che i valori
// divergano oggi.
expect(CONSENT_VERSION).not.toBe(undefined);
expect(QUEST_VERSION).not.toBe(undefined);
});
it('il codice cliente si risolve dalla sessione (codicePerUtenteLoggato)', () => {
const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
expect(src).toMatch(/codicePerUtente/);
});
// PRIMA questo test cercava, nel sorgente, le stringhe letterali "body.client_code" e
// "corpo.client_code" — ma il file chiama la variabile del corpo `data`, non `body` né
// `corpo`: la lista di divieti non nominava nessuna variabile che il file usasse
// davvero, quindi era vacua per costruzione. Provato: aggiungendo all'endpoint
// `if (typeof data.client_code === 'string') codice = data.client_code;` (la vulnerabilità
// vera — un cliente scrive nel fascicolo di un altro) la suite restava tutta verde.
//
// Il fix e' strutturale (il tipo del corpo, in questionario.ts, non ha il campo
// client_code — chi lo leggesse da li' non passerebbe tsc --noEmit) e questo e' il test
// di comportamento che lo dimostra end-to-end: chiama l'endpoint VERO con un client_code
// estraneo nel corpo e verifica dove la sessione finisce scritta.
it('un client_code nel corpo della richiesta viene ignorato: la sessione finisce sul cliente vero, mai su quello iniettato', async () => {
const dir = mkdtempSync(join(tmpdir(), 'lg-api-test-'));
const idPath = join(dir, 'identity.db');
const lgPath = join(dir, 'longevity.db');
const primaId = process.env.IDENTITY_DB_PATH;
const primaLg = process.env.LONGEVITY_DB_PATH;
try {
process.env.IDENTITY_DB_PATH = idPath;
process.env.LONGEVITY_DB_PATH = lgPath;
// Setup con connessioni proprie, sullo stesso file che il singleton dell'endpoint
// aprirà (le due variabili d'ambiente sopra), come in anagrafica.test.ts.
const id = createIdentityDb(idPath);
const lg = createLongevityDb(lgPath);
seedRegistro(lg);
const codiceVero = creaCliente(id, lg, { nome: 'Vera', cognome: 'Utente', sesso: 'F', user_id: 1 });
const codiceAltro = creaCliente(id, lg, { nome: 'Altro', cognome: 'Cliente', sesso: 'M', user_id: 2 });
const { POST } = await import('../../src/pages/api/longevity/questionario');
const richiesta = new Request('http://localhost/api/longevity/questionario', {
method: 'POST',
body: JSON.stringify({
client_code: codiceAltro, // tentativo: scrivere nel fascicolo di un altro cliente
risposte: { q_riposato: 8 },
consensoSanitario: 'Si',
}),
});
const res = await POST({ request: richiesta, locals: { user: { id: 1 } } } as any);
expect(res.status).toBe(201);
const corpo = await res.json();
const riga = lg.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(corpo.sessioneId) as
{ client_code: string };
expect(riga.client_code).toBe(codiceVero);
expect(riga.client_code).not.toBe(codiceAltro);
} finally {
if (primaId === undefined) delete process.env.IDENTITY_DB_PATH; else process.env.IDENTITY_DB_PATH = primaId;
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { salvaCompilazione, QUEST_VERSION } from '../../src/lib/longevity/questionario';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('compilazione del questionario', () => {
it('salva le risposte punteggiate come misure', () => {
const db = dbPronto();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_ore_sonno: 7.5, q_riposato: 8, q_alcol_life: 3 },
});
const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ? AND fonte = 'questionario'`)
.get(s) as { n: number };
expect(n.n).toBe(3);
});
it('marchia la compilazione con la versione del questionario', () => {
const db = dbPronto();
const s = salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } });
const row = db.prepare(`SELECT tipo, quest_version FROM sessioni WHERE id = ?`).get(s) as
{ tipo: string; quest_version: string };
expect(row.tipo).toBe('questionario');
expect(row.quest_version).toBe(QUEST_VERSION);
});
it('i campi liberi vanno in profilo_note, non fra le misure', () => {
const db = dbPronto();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_riposato: 6 },
liberi: { farmaci: 'nessuno', obiettivi: 'dormire meglio' },
});
const note = db.prepare(`SELECT campo_id, testo FROM profilo_note WHERE sessione_id = ? ORDER BY campo_id`)
.all(s) as { campo_id: string; testo: string }[];
expect(note.map((n) => n.campo_id)).toEqual(['farmaci', 'obiettivi']);
const misure = db.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(s) as { test_id: string }[];
expect(misure.map((m) => m.test_id)).toEqual(['q_riposato']);
});
it('ogni compilazione e un record nuovo, non un aggiornamento', () => {
const db = dbPronto();
salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } });
salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-09-21', risposte: { q_riposato: 9 } });
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni WHERE tipo = 'questionario'`).get() as { n: number };
expect(n.n).toBe(2);
});
it('una risposta con id sconosciuto fa fallire la compilazione intera', () => {
const db = dbPronto();
expect(() =>
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_riposato: 6, q_domanda_nuova: 3 },
})
).toThrow(/q_domanda_nuova/);
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
expect(n.n).toBe(0);
});
it('fallimento a metà non lascia traccia: sessione, misure e note sono atomiche', () => {
const db = dbPronto();
// Passa due campi liberi: il primo valido (farmaci), il secondo con null (obiettivi).
// Il primo insert su profilo_note avviene (per farmaci: 'nessuno'), poi fallisce su
// obiettivi quando tenta di eseguire testo.trim() su un null, lanciando un TypeError.
// Il rollback della transazione deve annullare anche il primo insert già avvenuto.
expect(() =>
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_riposato: 6 },
liberi: { farmaci: 'nessuno', obiettivi: null as any }, // null causa TypeError su .trim()
})
).toThrow();
// Verifica che la transazione sia stata rollback: niente sessione, niente misure, niente note.
// Se il rollback non funzionasse, almeno il record di farmaci resterebbe in profilo_note.
const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
const note = db.prepare(`SELECT COUNT(*) n FROM profilo_note`).get() as { n: number };
expect(sessioni.n).toBe(0);
expect(misure.n).toBe(0);
expect(note.n).toBe(0);
});
});
@@ -0,0 +1,124 @@
// tests/longevity/referto.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
import { refertoDi } from '../../src/lib/longevity/vista';
function conCompilazione() {
const db = createLongevityDb(':memory:');
seedRegistro(db); seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10 },
});
salvaScore(db, s, calcolaSessione(db, s));
return { db, s };
}
/**
* Come conCompilazione, con IN PIU' una risposta di questionario_sonno (q_riposato).
* Serve solo alla prova sul congelamento, che ha bisogno di un asse con PIU' sotto-domini
* pesati e copertura parziale non nulla — vedi il commento sopra la mutazione qui sotto per
* il perche' conCompilazione da sola non basta.
*/
function conCoperturaParziale() {
const db = createLongevityDb(':memory:');
seedRegistro(db); seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: {
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
q_riposato: 8,
},
});
salvaScore(db, s, calcolaSessione(db, s));
return { db, s };
}
describe('il referto del cliente', () => {
it('restituisce i sette assi', () => {
const { db, s } = conCompilazione();
expect(refertoDi(db, s).assi.length).toBe(7);
});
it('un asse senza dati NON porta un numero', () => {
const { db, s } = conCompilazione();
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
expect(forza.valore).toBeNull();
});
it('e dice cosa manca per completarlo, invece di lasciare un buco', () => {
const { db, s } = conCompilazione();
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
expect(forza.mancano.length).toBeGreaterThan(0);
});
it('espone la copertura della Fitness Age anche quando è insufficiente (serve alla nota in pagina)', () => {
// conCompilazione() non porta nessuna misura del composito Fitness Age (cardio,
// handgrip, hrv, forza_resto, composizione, stabilita: nessuno di questi sotto-domini
// ha dati in questa sessione, solo il questionario), quindi fitnessAge è null — ma
// PRIMA di questa correzione refertoDi scartava anche la copertura insieme al valore:
// la pagina non aveva modo di dire "41%", solo di dire "manca".
const { db, s } = conCompilazione();
const referto = refertoDi(db, s);
expect(referto.fitnessAge).toBeNull();
expect(referto.fitnessAgeCopertura).toBe(0);
});
it('un asse coperto porta il suo numero', () => {
const { db, s } = conCompilazione();
const stile = refertoDi(db, s).assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
expect(stile.valore).not.toBeNull();
expect(stile.mancano).toEqual([]);
});
it('legge i punteggi congelati, non li ricalcola al volo', () => {
const { db, s } = conCoperturaParziale();
const prima = JSON.stringify(refertoDi(db, s));
// ATTENZIONE alla scelta della mutazione: non una qualunque, una che DISCRIMINA
// davvero un ricalcolo da una lettura congelata.
//
// 'Recupero & Sistema Nervoso' pesa QUATTRO sotto-domini (hrv 0.50, pressione 0.15,
// hrr 0.15, questionario_sonno 0.20); questa sessione ne misura UNO solo
// (questionario_sonno, via q_riposato in conCoperturaParziale) — copertura vera
// 0.20/1.00=0.20, sotto COPERTURA_MINIMA (0.4): l'asse e' 'insufficiente', ma con
// disponibili.length===1, non 0 — la formula del peso viene davvero eseguita, non
// bypassata dalla scorciatoia "nessun dato disponibile" di aggrega() in cascata.ts.
// Azzerando il peso di 'hrv' (un sotto-dominio ASSENTE) il totale scende a 0.50 e la
// copertura vera diventa 0.20/0.50=0.40: attraversa la soglia, l'asse passerebbe da
// insufficiente a ok SE il referto ricalcolasse (provato: vedi task-3-report.md).
//
// Una mutazione come questa NON discrimina, ed e' un errore da non ripetere:
// il peso dell'UNICO sotto-dominio di un asse a un solo elemento (es.
// 'questionario_lifestyle', 1.00 in 'Stile di Vita & Sonno') si semplifica sempre,
// sia al numeratore sia al denominatore della media pesata — cambiarlo non altera
// ne' il valore ne' la copertura, quindi il test resterebbe verde anche se
// refertoDi ricalcolasse davvero (era il bug di questo stesso test, corretto qui).
db.prepare(
`UPDATE pesi SET peso = 0 WHERE model_version = ? AND livello='asse' AND contenitore='Recupero & Sistema Nervoso' AND elemento='hrv'`
).run(MODEL_VERSION);
expect(JSON.stringify(refertoDi(db, s))).toBe(prima);
});
it('nessuna pagina interroga il database da sola', () => {
// Scandisce TUTTE le pagine sotto src/pages/longevity/, non un elenco scritto qui:
// un elenco di due nomi non protegge una pagina nuova, che passerebbe indisturbata
// anche con una query dentro (verificato: una prova-query.astro con un
// db.prepare('SELECT 1') non veniva vista dalla versione precedente di questo test).
const dir = join(process.cwd(), 'src/pages/longevity');
const pagine = (readdirSync(dir, { recursive: true }) as string[])
.filter((f) => f.endsWith('.astro'));
expect(pagine.length).toBeGreaterThan(0); // se la cartella si svuota, il test non deve tacere per errore
for (const f of pagine) {
const percorso = `src/pages/longevity/${f}`;
const src = readFileSync(join(dir, f), 'utf8');
expect(src, `${percorso} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i);
}
});
});
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, testAttivi, esisteTest } from '../../src/lib/longevity/registro';
describe('registro dei test', () => {
it('carica i 20 campi punteggiati del questionario', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const q = db.prepare(`SELECT COUNT(*) n FROM registro_test WHERE test_id LIKE 'q_%'`).get() as { n: number };
expect(q.n).toBe(20);
});
it('alcol_life porta la soglia 7-14 voluta da Nicola', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const row = db.prepare(`SELECT curva, params FROM registro_test WHERE test_id = 'q_alcol_life'`)
.get() as { curva: string; params: string };
expect(row.curva).toBe('lin_dec');
expect(JSON.parse(row.params)).toEqual({ best: 7, worst: 14 });
});
it('calo_pomeridiano usa la curva invertita, la quinta che al Python manca', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const row = db.prepare(`SELECT curva FROM registro_test WHERE test_id = 'q_calo_pomeridiano'`)
.get() as { curva: string };
expect(row.curva).toBe('x10_inv');
});
it('un test disattivato non e piu attivo dopo la sua data', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`UPDATE registro_test SET attivo_a = '2026-08-01' WHERE test_id = 'q_sigarette'`).run();
const attivi = testAttivi(db, '2026-08-21').map((t) => t.test_id);
expect(attivi).not.toContain('q_sigarette');
expect(testAttivi(db, '2026-07-01').map((t) => t.test_id)).toContain('q_sigarette');
});
it('esisteTest distingue un test vero da uno inventato', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
expect(esisteTest(db, 'q_ore_sonno')).toBe(true);
expect(esisteTest(db, 'q_inventato')).toBe(false);
});
it('i pesi di ogni asse sommano a 1', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
const righe = db.prepare(
`SELECT contenitore, ROUND(SUM(peso), 6) tot FROM pesi
WHERE model_version = 'v1.0' AND livello = 'asse' GROUP BY contenitore`
).all() as { contenitore: string; tot: number }[];
expect(righe.length).toBe(7);
for (const r of righe) expect(r.tot).toBe(1);
});
it('i pesi sono legati alla versione del modello', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
seedPesi(db, 'v2.0');
const n = db.prepare(`SELECT COUNT(DISTINCT model_version) n FROM pesi`).get() as { n: number };
expect(n.n).toBe(2);
});
it('il plank pesa su due assi: va visto, non nascosto', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
const righe = db.prepare(
`SELECT contenitore FROM pesi WHERE model_version='v1.0' AND livello='asse' AND elemento IN ('core','plank')`
).all() as { contenitore: string }[];
expect(righe.map((r) => r.contenitore).sort())
.toEqual(['Forza & Struttura', 'Stabilità & Mobilità Funzionale']);
});
});
@@ -0,0 +1,22 @@
# L'oracolo del motore di calcolo
`isl_scoring_engine.py` è il motore di punteggio **scritto dal cliente** (In-Sanity Lab),
ricevuto il 2026-08-21. Non è codice nostro e non va modificato: sta qui perché è
l'**oracolo** contro cui verifichiamo il porting in TypeScript.
Il porting delle curve è la parte più delicata della piattaforma: un coefficiente
trascritto male produce un punteggio sanitario sbagliato che sembra plausibile e che
nessun test funzionale intercetterebbe, perché diventerebbe il valore "atteso" di tutto
ciò che viene dopo. La fedeltà non è garantita dalla trascrizione, ma dal confronto
numerico: `genera-riferimento.py` esegue questo motore su una griglia di casi e scrive
`riferimento.json`, che il test TypeScript rilegge per confrontare i due risultati.
Serve `python3` (solo libreria standard: il motore importa unicamente `dataclasses` e
`typing`).
## Rigenerare il riferimento
python3 tests/longevity/riferimento/genera-riferimento.py
Va rifatto solo se il cliente manda una versione nuova del motore. In quel caso il
confronto dirà da sé quali curve sono cambiate.
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Esegue il motore del cliente su una griglia fitta di ingressi e scrive i risultati
in riferimento.json. È il metro contro cui si misura il porting in TypeScript:
non serve a provare che l'oracolo è giusto, ma che il nostro porting gli è fedele.
Uso: python3 tests/longevity/riferimento/genera-riferimento.py
"""
import json
import pathlib
import sys
QUI = pathlib.Path(__file__).parent
sys.path.insert(0, str(QUI))
import isl_scoring_engine as m
casi = []
def prova(fn_nome, *args):
"""Esegue una funzione dell'oracolo e registra ingressi e uscita."""
fn = getattr(m, fn_nome)
casi.append({"fn": fn_nome, "args": list(args), "atteso": fn(*args)})
def griglia(inizio, fine, passo):
"""Valori da inizio a fine compreso, con arrotondamento pulito."""
n, v = [], inizio
while v <= fine + 1e-9:
n.append(round(v, 4))
v += passo
return n
# --- curve generiche del questionario: tutto il dominio, non un campione ---
for v in griglia(0, 14, 0.5):
prova("score_bell_curve", v, 4, 7, 9, 12) # q_ore_sonno
prova("score_decreasing", v, 0, 7) # q_caffeina e gemelle
prova("score_decreasing", v, 7, 14) # q_alcol_life: la soglia voluta
for v in griglia(0, 10, 0.5):
prova("score_direct_x10", v)
for v in griglia(0, 7, 0.5):
prova("score_increasing_plateau", v, 0, 4)
prova("score_increasing_plateau", v, 2, 6) # worst != 0: l'offset e' l'unico parametro a rischio refuso
# --- composizione corporea: attorno ai confini delle bande ---
for sesso in ("M", "F"):
for v in griglia(0, 45, 0.5):
prova("score_fat_percent", v, sesso)
for v in griglia(20, 60, 0.5):
prova("score_muscle_percent", v, sesso)
for v in griglia(0.5, 1.3, 0.01):
prova("score_whr", v, sesso)
# --- cardio ---
for v in griglia(85, 100, 1):
prova("score_spo2", v)
for sbp in griglia(90, 200, 5):
for dbp in griglia(50, 120, 5):
prova("score_blood_pressure", sbp, dbp)
for v in griglia(0, 40, 1):
prova("score_hrr", v)
for sesso in ("M", "F"):
for eta in griglia(20, 75, 5):
for v in griglia(15, 70, 2.5):
prova("score_vo2max", v, eta, sesso)
for v in griglia(120, 200, 5):
prova("vo2max_from_step_test", v, "M")
prova("vo2max_from_step_test", v, "F")
for v in griglia(100, 300, 10):
prova("vo2max_from_mutt", v)
for w in griglia(50, 200, 10):
prova("vo2max_from_milfit", w, 75)
# caso base + variazione di un parametro alla volta: un punto solo non separa i 5 coefficienti
BASE_2KM = (15.5, 130, 40, 24) # tempo_min, hr, eta, bmi
prova("vo2max_from_2km_walk", *BASE_2KM)
for tempo in griglia(10, 25, 2.5):
prova("vo2max_from_2km_walk", tempo, BASE_2KM[1], BASE_2KM[2], BASE_2KM[3])
for hr in griglia(100, 160, 10):
prova("vo2max_from_2km_walk", BASE_2KM[0], hr, BASE_2KM[2], BASE_2KM[3])
for eta in griglia(20, 75, 5):
prova("vo2max_from_2km_walk", BASE_2KM[0], BASE_2KM[1], eta, BASE_2KM[3])
for bmi in griglia(18, 32, 2):
prova("vo2max_from_2km_walk", BASE_2KM[0], BASE_2KM[1], BASE_2KM[2], bmi)
# --- forza ---
for sesso in ("M", "F"):
for eta in griglia(25, 75, 5):
for v in griglia(10, 70, 2.5):
prova("score_handgrip", v, eta, sesso)
for v in griglia(0, 60, 2):
prova("score_pushup", v, eta, sesso)
for v in griglia(0, 250, 5):
prova("score_plank", v, sesso)
confini_plank = [79, 97, 122, 157, 201] if sesso == "M" else [35, 63, 84, 108, 142]
for v in confini_plank: # confini esatti delle bande: costano zero, tolgono ogni dubbio
prova("score_plank", v, sesso)
for v in griglia(0, 120, 5):
prova("score_flexed_arm_hang", v, sesso)
for v in griglia(0, 60, 2):
prova("score_sit_to_stand_1min", v, 40, sesso)
for v in griglia(-30, 30, 1):
prova("score_sit_and_reach", v, sesso)
for eta in griglia(25, 75, 5):
for v in griglia(-30, 20, 2):
prova("score_back_scratch", v, eta, sesso)
# --- sollevamenti sui rapporti col peso corporeo ---
for sesso in ("M", "F"):
for carico in griglia(20, 200, 10):
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_BENCH_M, m.TIERS_BENCH_F, sesso)
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_SQUAT_M, m.TIERS_SQUAT_F, sesso)
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_ROW_M, m.TIERS_ROW_F, sesso)
# --- stabilità ---
for v in griglia(0, 30, 1):
prova("score_flamingo", v)
for a in griglia(0, 180, 10):
prova("score_shoulder_mobility_wt", a, a)
uscita = QUI / "riferimento.json"
uscita.write_text(json.dumps(
{"generato_da": "isl_scoring_engine.py", "casi": casi},
indent=1, ensure_ascii=False,
))
print(f"{len(casi)} casi scritti in {uscita}")
@@ -0,0 +1,537 @@
"""
IN-SANITY LAB — Motore di calcolo Longevity Score
===================================================
Implementa la cascata a 4 livelli definita nel foglio "Pesi e Formule" del
Registro Test:
1. Sotto-metrica grezza -> punteggio 0-100 (normalizzazione test-specifica)
2. Punteggi sotto-metrica -> punteggio Asse (media pesata, rinormalizzata
se mancano dati)
3. Punteggi Asse -> 3 Macro-score (Performance / Energy / Recovery)
4. Punteggi Asse -> Fitness Age
Regola di copertura (uguale a ogni livello): se un elemento manca, il suo
peso si ridistribuisce sugli elementi disponibili. Se la copertura di peso
disponibile scende sotto il 40%, l'elemento calcolato viene marcato
'insufficiente' invece che pieno.
Questo modulo NON include l'interfaccia (dashboard) - produce solo i numeri.
Si integra con:
- parse_wellness_tower.py (dati automatici Composizione/Cardio/Recupero)
- analyze_calibre.py (VO2max diretto, gia' esistente)
- inserimento manuale / futura web app (resto dei test)
Uso rapido: vedi la funzione demo() in fondo al file.
"""
from dataclasses import dataclass, field
from typing import Optional
MIN_COVERAGE = 0.40 # sotto questa soglia di peso disponibile, l'elemento e' 'insufficiente'
# =========================================================================
# LIVELLO 1 — Normalizzazione sotto-metrica -> 0-100
# =========================================================================
def clamp(x, lo=0, hi=100):
return max(lo, min(hi, x))
def _lerp(x, x0, x1, y0, y1):
if x1 == x0:
return y0
t = (x - x0) / (x1 - x0)
t = max(0, min(1, t))
return y0 + t * (y1 - y0)
# --- Composizione Corporea -------------------------------------------------
def score_fat_percent(fat_pct, sex):
"""Curva a campana ACE. Il picco e' su Atleti/Fitness, non sul grasso piu basso."""
m = sex.strip().lower().startswith("m")
bands = [
(0, 2, 40), (2, 5, 65), (5, 13, 100), (13, 17, 90), (17, 24, 65), (24, 35, 30), (35, 200, 10),
] if m else [
(0, 10, 40), (10, 13, 65), (13, 20, 100), (20, 24, 90), (24, 31, 65), (31, 40, 30), (40, 200, 10),
]
for lo, hi, val in bands:
if lo <= fat_pct < hi:
return val
return 50
def score_muscle_percent(muscle_pct, sex):
"""Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device)."""
lo, hi = (42.9, 52.4) if sex.strip().lower().startswith("m") else (37.8, 46.2)
if muscle_pct >= hi:
return 100
if muscle_pct <= lo - 10:
return 20
return round(_lerp(muscle_pct, lo - 10, hi, 20, 100), 1)
def score_whr(whr, sex):
threshold = 0.90 if sex.strip().lower().startswith("m") else 0.85
if whr <= threshold - 0.10:
return 100
if whr <= threshold:
return round(_lerp(whr, threshold - 0.10, threshold, 100, 60), 1)
return clamp(round(60 - (whr - threshold) * 300, 1))
# --- Cardio-Respiratorio ----------------------------------------------------
def vo2max_from_step_test(hr_recovery, sex):
"""Queen's College Step Test (McArdle et al. 1972)."""
if sex.strip().lower().startswith("m"):
return 111.33 - 0.42 * hr_recovery
return 65.81 - 0.1847 * hr_recovery
def vo2max_from_2km_walk(tempo_min, hr, eta, bmi):
"""UKK 2km Walking Test (Laukkanen/Oja)."""
return 116.2 - 2.98 * tempo_min - 0.11 * hr - 0.14 * eta - 0.39 * bmi
def vo2max_from_mutt(vam_m_min):
"""Formula ACSM su VAM raggiunta al tapis. Etichettare come 'teorico/stimato'."""
return 0.2 * vam_m_min + 3.5
def vo2max_from_milfit(watt, peso_kg):
"""Formula ACSM cicloergometro. Etichettare come 'teorico/stimato'. Valida 50-200W."""
return (10.8 * watt) / peso_kg + 7
def score_vo2max(vo2max, eta, sex):
"""ACSM/FRIEND Registry, bande approssimate per fascia d'eta e sesso."""
m = sex.strip().lower().startswith("m")
# soglie 'buono' per decade (uomini), interpolate da FRIEND Registry
good_thresholds_m = {20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30}
good_thresholds_f = {20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23}
table = good_thresholds_m if m else good_thresholds_f
decade = min(70, max(20, (eta // 10) * 10))
good = table[decade]
poor = good * 0.65
superior = good * 1.35
if vo2max <= poor:
return clamp(round(_lerp(vo2max, 0, poor, 10, 40), 1))
if vo2max <= good:
return round(_lerp(vo2max, poor, good, 40, 70), 1)
return clamp(round(_lerp(vo2max, good, superior, 70, 100), 1))
def score_spo2(spo2_pct):
"""Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico."""
if spo2_pct >= 97:
return 100
if spo2_pct >= 95:
return 85
if spo2_pct >= 90:
return 50
return 15
# --- Recupero & Sistema Nervoso --------------------------------------------
def score_blood_pressure(sbp, dbp):
"""ESC/ESH 2018. Vale la categoria piu alta tra sistolica e diastolica."""
if sbp >= 180 or dbp >= 110:
return 5
if sbp >= 160 or dbp >= 100:
return 25
if sbp >= 140 or dbp >= 90:
return 45
if sbp >= 130 or dbp >= 85:
return 70
if sbp >= 120 or dbp >= 80:
return 90
return 100
def score_hrr(calo_bpm_1min):
"""Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999)."""
if calo_bpm_1min >= 12:
return clamp(round(_lerp(calo_bpm_1min, 12, 30, 70, 100), 1))
return clamp(round(_lerp(calo_bpm_1min, 0, 12, 20, 70), 1))
# --- Forza & Struttura -------------------------------------------------------
def score_handgrip(kg, eta, sex):
"""Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'eta."""
m = sex.strip().lower().startswith("m")
# ancore (eta, valore medio kg) approssimate dalla letteratura citata
anchors_m = [(25, 49.7), (40, 46), (60, 38), (75, 30)]
anchors_f = [(25, 30), (40, 28), (60, 24), (75, 18.7)]
anchors = anchors_m if m else anchors_f
eta_c = clamp(eta, 25, 75)
for i in range(len(anchors) - 1):
a_eta, a_val = anchors[i]
b_eta, b_val = anchors[i + 1]
if a_eta <= eta_c <= b_eta:
mean_val = _lerp(eta_c, a_eta, b_eta, a_val, b_val)
break
else:
mean_val = anchors[-1][1]
# media = punteggio 70; +/- 40% della media copre la banda 20-100
ratio = kg / mean_val if mean_val else 1
return clamp(round(_lerp(ratio, 0.5, 1.5, 20, 100), 1))
def score_pushup(reps, eta, sex):
"""ACSM/CSEP, bande approssimate per decade."""
m = sex.strip().lower().startswith("m")
base_good_m, base_good_f = 22, 15 # 35-39 anni, 'buono' minimo
decade_offset = max(0, (eta - 35) // 10) * 2.5 # calo ~2.5 rip/decade dopo i 35
good = (base_good_m if m else base_good_f) - decade_offset
superior = good * 1.6
poor = good * 0.5
if reps <= poor:
return clamp(round(_lerp(reps, 0, poor, 10, 40), 1))
if reps <= good:
return round(_lerp(reps, poor, good, 40, 70), 1)
return clamp(round(_lerp(reps, good, superior, 70, 100), 1))
def score_bw_ratio_lift(carico_kg, peso_corporeo_kg, rip, tiers_m, tiers_f, sex):
"""Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite)."""
rm1 = carico_kg * 36 / (37 - rip) # Brzycki
ratio = rm1 / peso_corporeo_kg
tiers = tiers_m if sex.strip().lower().startswith("m") else tiers_f
# tiers = [(soglia_ratio, punteggio), ...] crescente
prev_r, prev_s = 0, 10
for r, s in tiers:
if ratio <= r:
return round(_lerp(ratio, prev_r, r, prev_s, s), 1)
prev_r, prev_s = r, s
return 100
TIERS_BENCH_M = [(0.5, 30), (1.0, 60), (1.25, 80), (1.5, 100)]
TIERS_BENCH_F = [(0.3, 30), (0.6, 60), (0.75, 80), (1.0, 100)]
TIERS_SQUAT_M = [(0.75, 30), (1.5, 60), (1.75, 80), (2.0, 100)]
TIERS_SQUAT_F = [(0.5, 30), (1.1, 60), (1.3, 80), (1.5, 100)]
TIERS_ROW_M = [(0.45, 30), (0.70, 60), (0.95, 80), (1.20, 100)]
TIERS_ROW_F = [(0.30, 30), (0.45, 60), (0.60, 80), (0.80, 100)]
def score_flexed_arm_hang(sec, sex):
good, superior = (45, 70) if sex.strip().lower().startswith("m") else (25, 50)
if sec <= good * 0.4:
return clamp(round(_lerp(sec, 0, good * 0.4, 10, 40), 1))
if sec <= good:
return round(_lerp(sec, good * 0.4, good, 40, 70), 1)
return clamp(round(_lerp(sec, good, superior, 70, 100), 1))
def score_sit_to_stand_1min(reps, eta, sex, bmi=24):
"""Reference equation adulti 18-95 (Zalewski et al.-style)."""
sex_code = 0 if sex.strip().lower().startswith("m") else 1
predicted = 61.53 - 0.34 * eta - 3.57 * sex_code - 0.33 * bmi
ratio = reps / predicted if predicted else 1
return clamp(round(_lerp(ratio, 0.5, 1.3, 20, 100), 1))
def score_plank(sec, sex):
bands_m = [(79, 20), (97, 40), (122, 55), (157, 75), (201, 90)]
bands_f = [(35, 20), (63, 40), (84, 55), (108, 75), (142, 90)]
bands = bands_m if sex.strip().lower().startswith("m") else bands_f
prev_t, prev_s = 0, 10
for t, s in bands:
if sec <= t:
return round(_lerp(sec, prev_t, t, prev_s, s), 1)
prev_t, prev_s = t, s
return 100
# --- Stabilità & Mobilità ----------------------------------------------------
def score_back_scratch(cm, eta, sex):
"""Rikli & Jones (Senior Fitness Test) + studio norvegese per fascia under 60.
Positivo=sovrapposizione dita, negativo=distanza. Anchor a 62 anni (dato solido),
estrapolato linearmente (~4cm/5 anni) per le altre eta. Approssimazione da
dichiarare, non tabella completa."""
m = sex.strip().lower().startswith("m")
anchor_eta, anchor_cm = 62, (-8.6 if m else -1.8)
decline_per_year = 0.8 # cm peggioramento per anno di eta in piu
expected = anchor_cm - (eta - anchor_eta) * decline_per_year
# punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo
diff = cm - expected
return clamp(round(_lerp(diff, -15, 15, 20, 100), 1))
def score_shoulder_mobility_wt(outreach_deg, buckling_deg):
"""Wellness Tower, riferimento 180 gradi (outreach/buckling)."""
avg = (outreach_deg + buckling_deg) / 2
return clamp(round(avg / 180 * 100, 1))
def score_flamingo(cadute):
"""Decrescente: meno cadute = meglio."""
if cadute <= 3:
return 100
if cadute <= 7:
return round(_lerp(cadute, 3, 7, 100, 80), 1)
if cadute <= 15:
return round(_lerp(cadute, 7, 15, 80, 50), 1)
return clamp(round(_lerp(cadute, 15, 30, 50, 10), 1))
def score_sit_and_reach(cm, sex):
median = 24 if sex.strip().lower().startswith("m") else 31
return clamp(round(_lerp(cm, median - 20, median + 10, 20, 100), 1))
def score_agility_ms(ms, eta, sex):
"""Blomkvist et al. 2017, decrescente (meno ms = meglio)."""
base = 250 if sex.strip().lower().startswith("m") else 265
decade_add = max(0, (eta - 30) // 10) * 35
expected = base + decade_add
ratio = expected / ms if ms else 1
return clamp(round(_lerp(ratio, 0.7, 1.3, 20, 100), 1))
# --- Questionario: curve generiche -----------------------------------------
def score_bell_curve(value, low, peak_low, peak_high, high):
"""Curva a campana: picco tra peak_low e peak_high, decresce ai lati."""
if peak_low <= value <= peak_high:
return 100
if value < peak_low:
return clamp(round(_lerp(value, low, peak_low, 10, 100), 1))
return clamp(round(_lerp(value, peak_high, high, 100, 10), 1))
def score_decreasing(value, best, worst):
return clamp(round(_lerp(value, best, worst, 100, 0), 1))
def score_increasing_plateau(value, worst, plateau_start):
return clamp(round(_lerp(value, worst, plateau_start, 20, 100), 1))
def score_direct_x10(value_0_10):
return clamp(round(value_0_10 * 10, 1))
# =========================================================================
# LIVELLO 2 — Sotto-metriche -> punteggio Asse
# =========================================================================
@dataclass
class WeightedScore:
score: Optional[float] # None se dato mancante
weight: float # peso nominale (dalla config)
def aggregate(items: list[WeightedScore]):
"""Rinormalizza sui pesi disponibili. Ritorna (punteggio, copertura, stato)."""
total_weight = sum(i.weight for i in items)
available = [i for i in items if i.score is not None]
available_weight = sum(i.weight for i in available)
coverage = available_weight / total_weight if total_weight else 0
if not available:
return None, 0.0, "insufficiente"
weighted_sum = sum(i.score * i.weight for i in available)
score = weighted_sum / available_weight
status = "insufficiente" if coverage < MIN_COVERAGE else "ok"
return round(score, 1), round(coverage, 2), status
# Config pesi sotto-dominio -> asse (dal foglio "Pesi e Formule")
AXIS_SUBDOMAIN_WEIGHTS = {
"Forza & Struttura": {
"handgrip": 0.25, "spinta": 0.20, "trazione": 0.20, "arti_inferiori": 0.20, "core": 0.15,
},
"Composizione Corporea": {
"grasso": 0.40, "muscolo": 0.35, "whr": 0.25,
},
"Cardio-Respiratorio": {
"vo2max": 0.60, "spirometria": 0.20, "wellness_tower_cardio": 0.20,
},
"Recupero & Sistema Nervoso": {
"hrv": 0.50, "pressione": 0.15, "hrr": 0.15, "questionario_sonno": 0.20,
},
"Energia & Regolazione Stress": {
"hrv": 0.50, "questionario_energia_stress": 0.50,
},
"Stabilità & Mobilità Funzionale": {
"flamingo": 0.35, "sit_and_reach": 0.25, "plank": 0.20, "back_scratch": 0.14, "wellness_tower_shoulder": 0.06,
},
"Stile di Vita & Sonno": {
"questionario_lifestyle": 1.00,
},
}
# Config pesi asse -> macro-score
MACRO_SCORE_WEIGHTS = {
"PERFORMANCE": {
"Forza & Struttura": 0.25, "Cardio-Respiratorio": 0.25, "Composizione Corporea": 0.15,
"Stabilità & Mobilità Funzionale": 0.20, "Recupero & Sistema Nervoso": 0.10,
"Energia & Regolazione Stress": 0.05, "Stile di Vita & Sonno": 0.0,
},
"ENERGY": {
"Energia & Regolazione Stress": 0.30, "Stile di Vita & Sonno": 0.20,
"Recupero & Sistema Nervoso": 0.25, "Cardio-Respiratorio": 0.15,
"Composizione Corporea": 0.10, "Forza & Struttura": 0.0, "Stabilità & Mobilità Funzionale": 0.0,
},
"RECOVERY": {
"Recupero & Sistema Nervoso": 0.45, "Energia & Regolazione Stress": 0.15,
"Stile di Vita & Sonno": 0.20, "Stabilità & Mobilità Funzionale": 0.15,
"Forza & Struttura": 0.05, "Cardio-Respiratorio": 0.0, "Composizione Corporea": 0.0,
},
}
# Config pesi Fitness Age (nota: Handgrip ed HRV entrano isolati, non tramite l'intero asse)
FITNESS_AGE_WEIGHTS = {
"cardio": 0.30, "handgrip_isolato": 0.20, "hrv_isolato": 0.20,
"forza_resto": 0.15, "composizione": 0.10, "stabilita": 0.05,
}
def compute_axis(axis_name, subdomain_scores: dict):
"""subdomain_scores: {nome_sottodominio: score_0_100 or None}"""
weights = AXIS_SUBDOMAIN_WEIGHTS[axis_name]
items = [WeightedScore(subdomain_scores.get(k), w) for k, w in weights.items()]
score, coverage, status = aggregate(items)
return {"score": score, "coverage": coverage, "status": status}
# =========================================================================
# LIVELLO 3 — 7 Assi -> 3 Macro-score
# =========================================================================
def compute_macro_scores(axis_scores: dict):
"""axis_scores: {nome_asse: {'score':..,'status':..}}"""
results = {}
for macro_name, weights in MACRO_SCORE_WEIGHTS.items():
items = [
WeightedScore(axis_scores[axis]["score"] if axis_scores[axis]["status"] != "insufficiente" else None, w)
for axis, w in weights.items() if w > 0
]
score, coverage, status = aggregate(items)
results[macro_name] = {"score": score, "coverage": coverage, "status": status}
return results
# =========================================================================
# LIVELLO 4 — Fitness Age
# =========================================================================
def compute_fitness_age(eta_anagrafica, cardio_score, handgrip_score, hrv_score,
forza_resto_score, composizione_score, stabilita_score):
items = [
WeightedScore(cardio_score, FITNESS_AGE_WEIGHTS["cardio"]),
WeightedScore(handgrip_score, FITNESS_AGE_WEIGHTS["handgrip_isolato"]),
WeightedScore(hrv_score, FITNESS_AGE_WEIGHTS["hrv_isolato"]),
WeightedScore(forza_resto_score, FITNESS_AGE_WEIGHTS["forza_resto"]),
WeightedScore(composizione_score, FITNESS_AGE_WEIGHTS["composizione"]),
WeightedScore(stabilita_score, FITNESS_AGE_WEIGHTS["stabilita"]),
]
composite, coverage, status = aggregate(items)
if composite is None:
return {"fitness_age": None, "coverage": coverage, "status": "insufficiente"}
fitness_age = eta_anagrafica - (composite - 50) * 0.4
return {"fitness_age": round(fitness_age, 1), "composite": composite, "coverage": coverage, "status": status}
# =========================================================================
# DEMO END-TO-END
# =========================================================================
def demo():
"""Esempio completo su un cliente fittizio (M, 38 anni, dati plausibili)."""
eta, sex, peso = 38, "M", 90.3
# --- Livello 1: normalizzazione sotto-metriche ---
forza = {
"handgrip": score_handgrip(kg=48, eta=eta, sex=sex),
"spinta": score_pushup(reps=24, eta=eta, sex=sex),
"trazione": score_bw_ratio_lift(80, peso, 5, TIERS_ROW_M, TIERS_ROW_F, sex),
"arti_inferiori": score_bw_ratio_lift(140, peso, 5, TIERS_SQUAT_M, TIERS_SQUAT_F, sex),
"core": score_plank(sec=110, sex=sex),
}
composizione = {
"grasso": score_fat_percent(12.8, sex),
"muscolo": score_muscle_percent(48.8, sex),
"whr": score_whr(0.71, sex),
}
vo2 = vo2max_from_mutt(vam_m_min=230)
cardio = {
"vo2max": score_vo2max(vo2, eta, sex),
"spirometria": 75, # placeholder: da calcolatore GLI dedicato
"wellness_tower_cardio": score_spo2(98),
}
recupero = {
"hrv": 68, # placeholder: score gia' normalizzato da ISL HRV Monitor
"pressione": score_blood_pressure(114, 81),
"hrr": score_hrr(calo_bpm_1min=15),
"questionario_sonno": 72, # placeholder aggregato risposte questionario
}
energia = {
"hrv": 65,
"questionario_energia_stress": 60,
}
stabilita = {
"flamingo": score_flamingo(cadute=5),
"sit_and_reach": score_sit_and_reach(cm=20, sex=sex),
"plank": score_plank(sec=110, sex=sex),
"back_scratch": score_back_scratch(cm=-5, eta=eta, sex=sex),
"wellness_tower_shoulder": score_shoulder_mobility_wt(180, 180),
}
stile_vita = {
"questionario_lifestyle": 70,
}
# --- Livello 2: sotto-metriche -> assi ---
axis_scores = {
"Forza & Struttura": compute_axis("Forza & Struttura", forza),
"Composizione Corporea": compute_axis("Composizione Corporea", composizione),
"Cardio-Respiratorio": compute_axis("Cardio-Respiratorio", cardio),
"Recupero & Sistema Nervoso": compute_axis("Recupero & Sistema Nervoso", recupero),
"Energia & Regolazione Stress": compute_axis("Energia & Regolazione Stress", energia),
"Stabilità & Mobilità Funzionale": compute_axis("Stabilità & Mobilità Funzionale", stabilita),
"Stile di Vita & Sonno": compute_axis("Stile di Vita & Sonno", stile_vita),
}
print("=== RADAR — 7 ASSI ===")
for name, res in axis_scores.items():
print(f" {name:35s} {res['score']:>6} (copertura {res['coverage']*100:.0f}%, {res['status']})")
# --- Livello 3: assi -> 3 macro-score ---
macro = compute_macro_scores(axis_scores)
print("\n=== MACRO-SCORE ===")
for name, res in macro.items():
print(f" {name:12s} {res['score']:>6} (copertura {res['coverage']*100:.0f}%, {res['status']})")
# --- Livello 4: Fitness Age ---
fa = compute_fitness_age(
eta_anagrafica=eta,
cardio_score=axis_scores["Cardio-Respiratorio"]["score"],
handgrip_score=forza["handgrip"],
hrv_score=recupero["hrv"],
forza_resto_score=axis_scores["Forza & Struttura"]["score"],
composizione_score=axis_scores["Composizione Corporea"]["score"],
stabilita_score=axis_scores["Stabilità & Mobilità Funzionale"]["score"],
)
print(f"\n=== FITNESS AGE ===")
print(f" Eta anagrafica: {eta} -> Fitness Age: {fa['fitness_age']} (composite {fa['composite']}, copertura {fa['coverage']*100:.0f}%)")
def score_generic_range_local(value, low, high):
if high == low:
return 50
pct = (value - low) / (high - low)
return clamp(round(pct * 100, 1))
if __name__ == "__main__":
demo()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
// Il blocco scoperto nella revisione finale del 22/08: il salvataggio del questionario e la
// lettura dei punteggi esistono entrambi, ma fra i due non calcolava niente nessuno.
// I test esistenti (referto.test.ts) chiamavano calcolaSessione/salvaScore dentro la propria
// fixture — cioè facevano a mano ciò che in produzione non fa nessuno — quindi restavano
// verdi anche con l'endpoint reale del tutto scollegato dal motore.
//
// Questo file parte dal modulo compilato (salvaDalForm, la stessa funzione che chiama
// l'endpoint POST /api/longevity/questionario) e arriva al referto (refertoDi): NON chiama
// mai calcolaSessione o salvaScore a mano. Se un domani qualcuno stacca di nuovo il
// calcolo dal salvataggio, questo è il test che deve diventare rosso.
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, refertoDi } from '../../src/lib/longevity/vista';
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;
}
describe('salvaDalForm calcola e congela i punteggi da solo', () => {
it('venti risposte compilate producono un referto con assi e macro-score valorizzati, senza ricalcolo a mano', () => {
const db = dbPronto();
const esito = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: {
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,
},
consensoSanitario: 'Si', liberi: {},
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
// Riprova diretta sulla tabella score: prima di questa correzione restava vuota,
// perché nessuno chiamava calcolaSessione/salvaScore fra salvataggio e lettura.
const righeScore = db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(esito.sessioneId) as
{ n: number };
expect(righeScore.n).toBeGreaterThan(0);
const referto = refertoDi(db, esito.sessioneId);
// 'Stile di Vita & Sonno' ha UN solo sotto-dominio (questionario_lifestyle, peso 1.00):
// con tutte le risposte compilate la copertura è piena.
const stile = referto.assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
expect(stile.valore).not.toBeNull();
// 'Energia & Regolazione Stress' pesa hrv 0.50 + questionario_energia_stress 0.50: con
// solo il questionario la copertura vera è 0.50, sopra la soglia del 40%.
const energia = referto.assi.find((a) => a.nome === 'Energia & Regolazione Stress')!;
expect(energia.valore).not.toBeNull();
const almenoUnMacro = referto.macro.some((m) => m.valore !== null);
expect(almenoUnMacro).toBe(true);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { safeNext } from '../src/lib/safe-next';
describe('safeNext', () => {
it('accetta path interni', () => {
expect(safeNext('/about')).toBe('/about');
expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1');
});
it('rifiuta esterni, protocol-relative, api e vuoti', () => {
expect(safeNext(null)).toBe('/');
expect(safeNext('')).toBe('/');
expect(safeNext('//evil.com')).toBe('/');
expect(safeNext('https://evil.com')).toBe('/');
expect(safeNext('/api/admin/posts')).toBe('/');
expect(safeNext('javascript:alert(1)')).toBe('/');
expect(safeNext('not-a-path')).toBe('/');
expect(safeNext('/\\evil.com')).toBe('/');
expect(safeNext('/\\/evil.com')).toBe('/');
});
});
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import {
fascia, misurazioniNelPeriodo, kpis, distribuzione, classificaRecupero, confrontoSegmenti,
} from '../src/lib/stress-index/analytics';
import type { Alert, Client, Measurement } from '../src/lib/stress-index/data';
const PERIODO = { from: '2026-07-01', to: '2026-07-31' };
const misura = (date: string, stress: number, recovery: number): Measurement =>
({ date, stress, recovery, balance: 50, energy: 50, type: 'Riposo', durationMin: 20 });
const cliente = (id: string, name: string, sex: 'M' | 'F', age: number): Client =>
({ id, name, initials: 'XX', age, sex, tags: ['test'], email: `${id}@esempio.it`,
since: '2024-01-01', lastMeasurement: '2026-07-10', stress: 50, alerts: 0 });
const clients = [cliente('a', 'A. Uno', 'M', 30), cliente('b', 'B. Due', 'F', 55), cliente('c', 'C. Tre', 'F', 40)];
const byClient: Record<string, Measurement[]> = {
// dentro il periodo
a: [misura('2026-07-05', 10, 90), misura('2026-07-12', 30, 70)],
// una dentro, una fuori
b: [misura('2026-06-20', 80, 20), misura('2026-07-20', 90, 30)],
// tutte fuori periodo
c: [misura('2026-05-01', 50, 50)],
};
const alerts: Alert[] = [
{ clientId: 'a', clientName: 'A. Uno', text: 'x', severity: 'warn', when: '2026-07-03' },
{ clientId: 'b', clientName: 'B. Due', text: 'y', severity: 'error', when: '2026-06-01' },
];
describe('fasce di score', () => {
it('assegna gli estremi alla fascia giusta', () => {
expect(fascia(0)).toBe('0-20');
expect(fascia(20)).toBe('0-20');
expect(fascia(21)).toBe('21-40');
expect(fascia(60)).toBe('41-60');
expect(fascia(81)).toBe('81-100');
expect(fascia(100)).toBe('81-100');
});
});
describe('filtro sul periodo', () => {
it('tiene solo le misurazioni comprese fra le due date, estremi inclusi', () => {
const dentro = misurazioniNelPeriodo(byClient, PERIODO);
expect(dentro.a.map((m) => m.date)).toEqual(['2026-07-05', '2026-07-12']);
expect(dentro.b.map((m) => m.date)).toEqual(['2026-07-20']);
expect(dentro.c).toEqual([]);
});
});
describe('kpi', () => {
it('conta clienti attivi, misurazioni, alert del periodo e aderenza', () => {
expect(kpis(clients, byClient, alerts, PERIODO)).toEqual([
{ label: 'Clienti attivi', value: 2, note: '/ 3 totali' },
{ label: 'Misurazioni', value: 3, note: 'nel periodo' },
{ label: 'Alert attivi', value: 1, note: 'nel periodo' },
{ label: 'Aderenza', value: '67%', note: 'hanno misurato' },
]);
});
});
describe('distribuzione per fascia', () => {
it('conta ogni metrica nella propria fascia', () => {
const righe = distribuzione(byClient, PERIODO);
const perFascia = Object.fromEntries(righe.map((r) => [r.range, r]));
// stress: 10 → 0-20, 30 → 21-40, 90 → 81-100
expect(perFascia['0-20'].stress).toBe(1);
expect(perFascia['21-40'].stress).toBe(1);
expect(perFascia['81-100'].stress).toBe(1);
// recovery: 90 → 81-100, 70 → 61-80, 30 → 21-40
expect(perFascia['81-100'].recovery).toBe(1);
expect(perFascia['61-80'].recovery).toBe(1);
expect(perFascia['21-40'].recovery).toBe(1);
// le tre misurazioni hanno tutte balance ed energy a 50
expect(perFascia['41-60'].balance).toBe(3);
expect(perFascia['41-60'].energy).toBe(3);
});
});
describe('classifica per recupero', () => {
it('ordina dal recupero medio più alto e ignora chi non ha misurato', () => {
expect(classificaRecupero(clients, byClient, PERIODO)).toEqual([
{ clientId: 'a', name: 'A. Uno', recovery: 80, count: 2 },
{ clientId: 'b', name: 'B. Due', recovery: 30, count: 1 },
]);
});
});
describe('confronto segmenti', () => {
it('media stress e recupero per sesso', () => {
expect(confrontoSegmenti(clients, byClient, PERIODO, 'sesso')).toEqual([
{ gruppo: 'Uomini', stress: 20, recovery: 80 },
{ gruppo: 'Donne', stress: 90, recovery: 30 },
]);
});
it('raggruppa per fascia di età', () => {
expect(confrontoSegmenti(clients, byClient, PERIODO, 'eta')).toEqual([
{ gruppo: 'Fino a 34', stress: 20, recovery: 80 },
{ gruppo: '50 e oltre', stress: 90, recovery: 30 },
]);
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { durata, dataBreve, dataOra, ultimi, kpiSport, perSettimana, riepilogoAtleti } from '../src/lib/stress-index/sport';
import type { Athlete, Session } from '../src/lib/stress-index/data';
const RIF = '2026-07-12';
const sess = (id: string, athleteId: string, startedAt: string, trimp: number): Session =>
({ id, athleteId, startedAt, durationSec: 1800, sport: null, hrAvg: 120, hrMax: 150, trimp, dfaA1: 1.2, rpe: 5 });
const athletes: Athlete[] = [
{ clientId: 'a', name: 'A. Uno', sport: 'Corsa' },
{ clientId: 'b', name: 'B. Due', sport: null },
];
// Ordinate dalla più recente, come le espone data.ts
const sessions: Session[] = [
sess('s1', 'a', '2026-07-12T09:00', 40),
sess('s2', 'a', '2026-07-08T09:00', 30),
sess('s3', 'a', '2026-07-02T09:00', 60), // settimana precedente
sess('s4', 'b', '2026-06-20T09:00', 20), // oltre i 30 giorni? no: 22 giorni prima
sess('s5', 'b', '2026-04-01T09:00', 15), // fuori da tutte le finestre
];
describe('formattazioni', () => {
it('durata: ore, minuti e secondi', () => {
expect(durata(4860)).toBe('1h 21m');
expect(durata(3275)).toBe('54m 35s');
expect(durata(5)).toBe('5s');
});
it('date in forma breve ed estesa', () => {
expect(dataBreve('2026-07-30T11:27')).toBe('30 lug 2026');
expect(dataOra('2026-07-30T11:27')).toBe('30 lug 2026 alle 11:27');
});
});
describe('finestre temporali', () => {
it('gli ultimi 7 giorni includono il giorno di riferimento ed escludono il settimo indietro', () => {
expect(ultimi(sessions, 7, RIF).map((s) => s.id)).toEqual(['s1', 's2']);
});
it('gli ultimi 30 giorni allargano a chi ha misurato nel mese', () => {
expect(ultimi(sessions, 30, RIF).map((s) => s.id)).toEqual(['s1', 's2', 's3', 's4']);
});
});
describe('kpi', () => {
it('conta sessioni, carico settimanale e atleti attivi nel mese', () => {
expect(kpiSport(sessions, RIF)).toEqual([
{ label: 'Sessioni totali', value: 5 },
{ label: 'Questa settimana', value: 2, note: 'ultimi 7gg' },
{ label: 'TRIMP medio', value: 70, note: 'settimanale' },
{ label: 'Atleti attivi', value: 2, note: 'ultimi 30gg' },
]);
});
});
describe('sessioni per settimana', () => {
const righe = perSettimana(sessions, 4, RIF);
it('produce una riga per settimana, la più recente per ultima', () => {
expect(righe).toHaveLength(4);
// 2026-07-12 è una domenica: la sua settimana parte lunedì 06/07
expect(righe[righe.length - 1].settimana).toBe('06/07');
});
it('conta le sessioni nella settimana giusta', () => {
const perEtichetta = Object.fromEntries(righe.map((r) => [r.settimana, r.sessioni]));
expect(perEtichetta['06/07']).toBe(2); // s1 e s2
expect(perEtichetta['29/06']).toBe(1); // s3
});
});
describe('riepilogo atleti', () => {
const riepilogo = riepilogoAtleti(sessions, athletes, RIF);
it('somma il carico dei 7 giorni e riporta ultima sessione e conteggio mensile', () => {
expect(riepilogo[0]).toEqual({
clientId: 'a', name: 'A. Uno', sport: 'Corsa',
sessioni30: 3, trimp7: 70, ultimoTrimp: 40,
ultimaSessione: '2026-07-12T09:00', trend: 'stabile',
});
});
it('resta stabile quando lo scarto fra le due settimane è minimo', () => {
// b non ha nulla negli ultimi 7 giorni né nei 7 precedenti → nessuno scarto, resta stabile
expect(riepilogo[1].trend).toBe('stabile');
expect(riepilogo[1].sessioni30).toBe(1);
});
});