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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user