Struttura: il sito esistente si sposta sotto apps/sito

Primo passo della separazione sito / piattaforme decisa da Adriano il 03/09:
due applicazioni autosufficienti, senza workspace di root, cosi' che ognuna
resti un progetto Astro completo ed estraibile.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
AdrianoDev
2026-09-03 11:05:54 +00:00
parent c03edccd8e
commit 510d7ca4ec
551 changed files with 4 additions and 4 deletions
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDb } from '../src/lib/db';
import { createUser, login, getSessionUser, canAccessAdminPath, landingFor, isRole } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('ruolo piattaforme', () => {
it('createUser accetta piattaforme e getSessionUser lo ritorna', () => {
createUser(db, 'c', 'segreta123', 'piattaforme');
expect(getSessionUser(db, login(db, 'c', 'segreta123')!)).toMatchObject({ role: 'piattaforme' });
});
it('isRole valida piattaforme e non più il vecchio campus', () => {
expect(isRole('piattaforme')).toBe(true);
expect(isRole('campus')).toBe(false);
});
it("landingFor porta piattaforme all'indice delle piattaforme", () => {
expect(landingFor('piattaforme')).toBe('/piattaforme');
});
});
describe('canAccessAdminPath con piattaforme', () => {
it('piattaforme: sezioni riservate e logout sì, resto del pannello no', () => {
expect(canAccessAdminPath('piattaforme', '/campus')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/campus/')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/campus/corso-base/lezione-01')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/campus/assets/slide/x.png')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/piattaforme')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/piattaforme/stress-index')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/piattaforme/stress-index/clienti')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/admin/logout')).toBe(true);
expect(canAccessAdminPath('piattaforme', '/admin')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/admin/new')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/admin/content')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/admin/users')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/api/admin/posts')).toBe(false);
expect(canAccessAdminPath('piattaforme', '/api/admin/upload')).toBe(false);
});
it('admin accede a tutte le piattaforme', () => {
expect(canAccessAdminPath('admin', '/campus')).toBe(true);
expect(canAccessAdminPath('admin', '/campus/corso-base')).toBe(true);
expect(canAccessAdminPath('admin', '/piattaforme')).toBe(true);
expect(canAccessAdminPath('admin', '/piattaforme/stress-index')).toBe(true);
});
it('superuser e user non accedono alle piattaforme', () => {
expect(canAccessAdminPath('superuser', '/campus')).toBe(false);
expect(canAccessAdminPath('superuser', '/campus/corso-base')).toBe(false);
expect(canAccessAdminPath('superuser', '/piattaforme')).toBe(false);
expect(canAccessAdminPath('superuser', '/piattaforme/stress-index')).toBe(false);
expect(canAccessAdminPath('user', '/campus')).toBe(false);
expect(canAccessAdminPath('user', '/campus/assets/slide/x.png')).toBe(false);
expect(canAccessAdminPath('user', '/piattaforme')).toBe(false);
});
});
// La rinomina del ruolo va verificata su un DB su file: le migrazioni girano all'apertura,
// quindi serve chiudere e riaprire lo stesso database con dentro una riga al vecchio valore.
describe('migrazione del ruolo campus → piattaforme', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'ilab-db-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('rinomina gli utenti rimasti al vecchio ruolo, lasciando stare gli altri', () => {
const file = join(dir, 'test.db');
const first = createDb(file);
createUser(first, 'vecchio', 'segreta123', 'campus' as never);
createUser(first, 'redattore', 'segreta123', 'superuser');
first.close();
const second = createDb(file);
const roles = second.prepare('SELECT username, role FROM users ORDER BY username').all() as
{ username: string; role: string }[];
second.close();
expect(roles).toEqual([
{ username: 'redattore', role: 'superuser' },
{ username: 'vecchio', role: 'piattaforme' },
]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('auth', () => {
it('hash e verifica password', () => {
const h = hashPassword('segreta123');
expect(h).not.toBe('segreta123');
expect(verifyPassword('segreta123', h)).toBe(true);
expect(verifyPassword('sbagliata', h)).toBe(false);
});
it('login corretto crea sessione recuperabile', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123');
expect(token).toBeTruthy();
const user = getSessionUser(db, token!);
expect(user).toMatchObject({ username: 'adriano' });
});
it('login errato ritorna null', () => {
createUser(db, 'adriano', 'segreta123');
expect(login(db, 'adriano', 'sbagliata')).toBeNull();
expect(login(db, 'inesistente', 'x')).toBeNull();
});
it('sessione scaduta viene rifiutata ed eliminata', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123')!;
db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run();
expect(getSessionUser(db, token)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 });
});
it('logout elimina la sessione', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123')!;
logout(db, token);
expect(getSessionUser(db, token)).toBeNull();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import { avvisi, avvisiAttivi, calendari, calendariDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
const d = (iso: string) => new Date(iso);
describe('calendariDaMostrare', () => {
const idDi = (quando: string) => calendariDaMostrare(d(quando)).map((m) => `${m.cal.id}:${m.stato}`);
// Si vedono tutti insieme, non solo quello del momento: chi guarda ad agosto vuole sapere
// com'è settembre per intero, e una griglia sola gli farebbe credere che valga per sempre.
it('prima dell\'inizio: entrambi, marcati futuri', () => {
expect(idDi('2026-08-17T10:00:00+02:00'))
.toEqual(['settembre-1-18:futuro', 'settembre-21-in-poi:futuro']);
});
it('durante il primo periodo: il primo è in corso, il secondo resta annunciato', () => {
expect(idDi('2026-09-05T10:00:00+02:00'))
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
});
// Un orario scaduto non deve restare in pagina: farebbe cercare lezioni che non ci sono più.
it('finito il primo periodo, il primo orario sparisce', () => {
expect(idDi('2026-09-25T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
expect(idDi('2027-03-01T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
});
// 19 e 20 settembre non stanno in nessuna griglia perché quei due giorni non hanno class:
// il 19 c'è la presentazione della nuova apertura, il 20 si sta chiusi (Adriano, 17/08).
it('nei due giorni fuori griglia resta il solo orario del 21, annunciato', () => {
expect(idDi('2026-09-19T10:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
expect(idDi('2026-09-20T23:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
});
it('confini al secondo, in ora italiana', () => {
expect(idDi('2026-09-18T23:59:59+02:00'))
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
expect(idDi('2026-09-19T00:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
expect(idDi('2026-09-21T00:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
});
it('l\'ordine dell\'elenco di partenza non cambia il risultato', () => {
const rovescio = [...calendari].reverse();
expect(calendariDaMostrare(d('2026-09-05T10:00:00+02:00'), rovescio).map((m) => m.cal.id))
.toEqual(['settembre-1-18', 'settembre-21-in-poi']);
});
});
describe('giornate fuori griglia', () => {
it('la presentazione e la chiusura si vedono prima di arrivarci', () => {
const oggi = avvisiAttivi(d('2026-08-17T12:00:00+02:00')).map((a) => a.id);
expect(oggi).toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
});
// Un avviso passato è peggio di nessun avviso: dice che il centro è chiuso un giorno che è
// già trascorso, e chi legge non sa più se valga per quest'anno o per il prossimo.
it('ogni avviso sparisce da solo quando è passato', () => {
expect(avvisiAttivi(d('2026-09-19T23:59:59+02:00')).map((a) => a.id))
.toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
expect(avvisiAttivi(d('2026-09-20T00:00:00+02:00')).map((a) => a.id))
.toEqual(['chiusura-20-settembre']);
expect(avvisiAttivi(d('2026-09-21T00:00:00+02:00'))).toEqual([]);
});
it('ogni avviso ha una scadenza leggibile e un testo', () => {
for (const a of avvisi) {
expect(Number.isNaN(Date.parse(a.to)), `${a.id}: scadenza illeggibile`).toBe(false);
if (a.from !== null) expect(Date.parse(a.to)).toBeGreaterThanOrEqual(Date.parse(a.from));
expect(a.quando.length).toBeGreaterThan(0);
expect(a.testo.length).toBeGreaterThan(0);
}
});
});
describe('coerenza dei calendari trascritti', () => {
for (const c of calendari) {
describe(c.id, () => {
it('la finestra è leggibile e ha un verso', () => {
expect(Number.isNaN(Date.parse(c.from))).toBe(false);
if (c.to !== null) {
expect(Number.isNaN(Date.parse(c.to))).toBe(false);
expect(Date.parse(c.to)).toBeGreaterThan(Date.parse(c.from));
}
});
it('le fasce orarie sono uniche e in ordine crescente', () => {
const ore = c.righe.map((r) => r.ora);
expect(new Set(ore).size, 'fascia ripetuta').toBe(ore.length);
const minuti = ore.map((o) => {
const [h, m] = o.split('.');
return Number(h) * 60 + Number(m);
});
expect(minuti.every((v, i) => i === 0 || v > minuti[i - 1]), `fasce fuori ordine: ${ore.join(' ')}`).toBe(true);
});
it('ogni cella sta su un giorno esistente e porta una disciplina nota', () => {
for (const r of c.righe) {
for (const [giorno, disciplina] of Object.entries(r.celle)) {
expect(GIORNI, `${r.ora}: giorno ${giorno} inesistente`).toContain(giorno as Giorno);
expect(DISCIPLINE, `${r.ora} ${giorno}: disciplina ${disciplina} sconosciuta`).toContain(disciplina!);
}
}
});
// Una lezione in un giorno di chiusura è la contraddizione che nessuno nota leggendo la
// griglia: la colonna è barrata e la cella ci finisce dentro lo stesso.
it('nessuna lezione cade in un giorno di chiusura', () => {
for (const r of c.righe) {
for (const g of c.chiusi) {
expect(r.celle[g], `${r.ora}: lezione di ${g}, che è chiuso`).toBeUndefined();
}
}
});
it('le note dei giorni si riferiscono a giorni aperti', () => {
for (const g of Object.keys(c.noteGiorno ?? {}) as Giorno[]) {
expect(GIORNI).toContain(g);
expect(c.chiusi, `nota su ${g}, che è dichiarato chiuso`).not.toContain(g);
}
});
});
}
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { avvisi, calendari } from '../src/data/calendari';
import { generaCalendariPdf, nomeFilePdf } from '../src/lib/calendario-pdf';
describe('PDF del calendario', () => {
for (const cal of calendari) {
it(`${cal.id}: produce un PDF valido`, async () => {
const pdf = await generaCalendariPdf([cal]);
// Un PDF comincia sempre con %PDF- e finisce con %%EOF: se il documento non venisse
// chiuso (`doc.end()` mancante, o un errore a metà) il file arriverebbe monco al
// browser, che mostrerebbe una pagina bianca invece di un errore.
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
expect(pdf.subarray(-8).toString()).toContain('%%EOF');
expect(pdf.length).toBeGreaterThan(2000);
});
}
// La griglia deve stare in una pagina sola: l'incrocio giorno/ora è proprio ciò che si va a
// cercare, e spezzarlo a metà rende il documento inutile. Il conteggio si legge dall'oggetto
// /Type /Pages, che dichiara quante pagine contiene.
it('sta in una pagina sola, anche con le giornate fuori griglia in fondo', async () => {
for (const cal of calendari) {
const pdf = await generaCalendariPdf([cal], avvisi);
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
expect(conteggio, `${cal.id}: numero di pagine non dichiarato`).not.toBeNull();
expect(Number(conteggio![1]), `${cal.id}: il calendario occupa più di una pagina`).toBe(1);
}
});
it('il nome del file distingue i due periodi', () => {
const nomi = calendari.map((c) => nomeFilePdf([c]));
expect(new Set(nomi).size).toBe(nomi.length);
for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/);
// Più orari insieme: un nome che nominasse solo il primo farebbe credere che dentro ci
// sia quello e basta.
expect(nomeFilePdf(calendari)).toBe('insanitylab-calendario-class.pdf');
});
// Un orario per pagina: due griglie sullo stesso foglio si confonderebbero fra loro, e
// spezzarne una a metà toglie proprio ciò che si va a cercare, l'incrocio giorno/ora.
it('con più orari fa una pagina per ciascuno', async () => {
const pdf = await generaCalendariPdf(calendari, avvisi);
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
expect(conteggio).not.toBeNull();
expect(Number(conteggio![1])).toBe(calendari.length);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { getNav, findArea, findLesson, chapterOf, readLessonHtml, resolveAsset } from '../src/lib/campus';
// I test girano sui contenuti reali generati da `npm run sync-campus`.
const nav = getNav();
describe('getNav', () => {
it('carica aree e capitoli dai contenuti generati', () => {
expect(nav).not.toBeNull();
expect(nav!.count).toBeGreaterThan(100);
expect(nav!.areas.map((a) => a.slug)).toEqual(
['corso-base', 'slide', 'live-e-ospiti', 'consulenze-masterclass', 'qa']);
for (const a of nav!.areas) expect(a.chapters.length).toBeGreaterThan(0);
});
});
describe('lookup', () => {
it('trova area e guida, e il capitolo della guida', () => {
const area = findArea(nav!, 'corso-base');
expect(area?.title).toBe('Corso base');
const l = findLesson(nav!, 'lezione-01-ottimizzazione-del-sonno');
expect(l).toMatchObject({ area: 'corso-base', chapter: 1, label: 'L1' });
expect(chapterOf(nav!, l!)?.title).toBe('Sonno e ritmi');
});
it('ritorna null per slug inesistenti', () => {
expect(findArea(nav!, 'inventata')).toBeNull();
expect(findLesson(nav!, 'lezione-99-inventata')).toBeNull();
});
it('la catena prev/next collega tutte le guide in ordine', () => {
const all = nav!.areas.flatMap((a) => a.chapters.flatMap((c) => c.lessons));
expect(all[0].prev).toBeNull();
expect(all.at(-1)!.next).toBeNull();
for (let i = 0; i < all.length - 1; i++) expect(all[i].next?.slug).toBe(all[i + 1].slug);
});
});
describe('readLessonHtml', () => {
it('legge il frammento di una guida esistente', () => {
const html = readLessonHtml('lezione-01-ottimizzazione-del-sonno');
expect(html).toContain('<h2');
expect(html).not.toContain('Guida allo studio</h1>');
});
it('ritorna null per una guida inesistente', () => {
expect(readLessonHtml('non-esiste')).toBeNull();
});
it('le immagini puntano alla route protetta', () => {
const html = readLessonHtml('slide-hrv')!;
expect(html).toContain('src="/campus/assets/');
expect(html).not.toContain('src="assets/');
});
});
describe('resolveAsset', () => {
it('risolve un asset esistente', () => {
expect(resolveAsset('slide/slide-hrv')).toMatch(/campus-content\/assets\/slide\/slide-hrv$/);
});
it('rifiuta i path traversal e i path assoluti', () => {
expect(resolveAsset('../pages/lezione-01-ottimizzazione-del-sonno.html')).toBeNull();
expect(resolveAsset('../../package.json')).toBeNull();
expect(resolveAsset('/etc/passwd')).toBeNull();
});
it('ritorna null per un asset inesistente', () => {
expect(resolveAsset('slide/non-esiste.png')).toBeNull();
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { parseGuide, kindOf, rewriteAssets } from '../scripts/campus/parse.mjs';
import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs';
const body = `
> Sintesi di studio basata sulla trascrizione della lezione del corso.
## In sintesi
Testo di prova.
## Concetti chiave
- **Il sonno è il pilastro.** Spiegazione.
- **Ritmi circadiani.** Altra spiegazione.
`;
describe('kindOf / sourceDirFor', () => {
it('riconosce il tipo dallo slug', () => {
expect(kindOf('lezione-01-ottimizzazione-del-sonno')).toBe('lezione');
expect(kindOf('lezione-live-1-2026-02-24')).toBe('live');
expect(kindOf('accelerator-1-2025-11-11')).toBe('accelerator');
expect(kindOf('slide-hrv')).toBe('slide');
expect(kindOf('speaker-resta-marketing')).toBe('speaker');
expect(kindOf('consulenza-2025-02-04-cristina-ruggeri')).toBe('consulenza');
expect(kindOf('masterclass-2025-10-08-luca-ruggeri')).toBe('masterclass');
expect(kindOf('qa-01-tony-04')).toBe('qa');
});
it('mappa lo slug alla cartella sorgente', () => {
expect(sourceDirFor('lezione-01-ottimizzazione-del-sonno')).toBe('studio');
expect(sourceDirFor('lezione-live-1-2026-02-24')).toBe('lezioni-live');
expect(sourceDirFor('qa-01-tony-04')).toBe('qa');
expect(sourceDirFor('consulenza-2025-02-04-cristina-ruggeri')).toBe('consulenze');
});
});
describe('parseGuide', () => {
it('lezione numerata con parte e relatore', () => {
const raw = '# Lezione 2 — Movimento e fitness (Parte 1) (Tony Manfron) — Guida allo studio' + body;
const g = parseGuide(raw, 'lezione-02-movimento-fitness-parte1', 1);
expect(g).toMatchObject({
kind: 'lezione', num: 2, relatore: 'Tony Manfron', parte: 'Parte 1',
titleFull: 'Movimento e fitness (Parte 1)', titleShort: 'Movimento e fitness',
label: 'L2 · Parte 1',
});
});
it('lezione live e accelerator usano la data come titolo', () => {
const live = parseGuide(
'# Lezione live 1 — 24 febbraio 2026 (Massimo Filippi) — Guida allo studio' + body,
'lezione-live-1-2026-02-24', 1);
expect(live).toMatchObject({
kind: 'live', num: 1, titleFull: 'Lezione live 1 — 24 febbraio 2026',
titleShort: 'Lezione live 1', relatore: 'Massimo Filippi · 24 febbraio 2026', label: 'LV1',
});
const acc = parseGuide(
'# Accelerator 3 — 28 gennaio 2026 (Massimo Filippi) — Guida allo studio' + body,
'accelerator-3-2026-01-28', 3);
expect(acc).toMatchObject({ kind: 'accelerator', num: 3, titleShort: 'Accelerator 3', label: 'A3' });
});
it('slide, speaker, consulenza, masterclass e Q&A', () => {
expect(parseGuide('# Slide — HRV: Variabilità della frequenza cardiaca — Guida allo studio' + body, 'slide-hrv', 1))
.toMatchObject({ kind: 'slide', num: 1, titleFull: 'HRV: Variabilità della frequenza cardiaca', label: 'SL1' });
expect(parseGuide('# Intervento — Marketing nel BioHacking (Roberto Resta) — Guida allo studio' + body, 'speaker-resta-marketing', 5))
.toMatchObject({ kind: 'speaker', titleFull: 'Marketing nel BioHacking', relatore: 'Roberto Resta', label: 'SP5' });
expect(parseGuide('# Consulenza — Cristina Ruggeri (04/02/2025) — Guida allo studio' + body, 'consulenza-2025-02-04-cristina-ruggeri', 1))
.toMatchObject({ kind: 'consulenza', titleFull: 'Cristina Ruggeri', relatore: '04/02/2025', label: 'C1' });
expect(parseGuide('# Masterclass — Luca Ruggeri (08/10/2025) — Guida allo studio' + body, 'masterclass-2025-10-08-luca-ruggeri', 1))
.toMatchObject({ kind: 'masterclass', titleFull: 'Luca Ruggeri', relatore: '08/10/2025', label: 'MC1' });
expect(parseGuide('# Q&A Tony n°4 — Guida allo studio' + body, 'qa-01-tony-04', 1))
.toMatchObject({ kind: 'qa', titleFull: 'Q&A Tony n°4', label: 'QA1' });
});
it('estrae i concetti chiave in grassetto e rimuove H1 e blockquote', () => {
const g = parseGuide('# Lezione 1 — Sonno (Tony Manfron) — Guida allo studio' + body,
'lezione-01-ottimizzazione-del-sonno', 1);
expect(g.concetti).toEqual(['Il sonno è il pilastro', 'Ritmi circadiani']);
expect(g.html).not.toContain('Guida allo studio');
expect(g.html).not.toContain('Sintesi di studio basata');
expect(g.html).toContain('<h2');
expect(g.html).toContain('Testo di prova');
});
it('rifiuta un H1 non riconosciuto', () => {
expect(() => parseGuide('# Titolo qualunque\n\ntesto', 'lezione-99-x', 1)).toThrow(/H1 non riconosciuto/);
});
});
describe('rewriteAssets', () => {
it('riscrive i path relativi delle immagini verso la route protetta', () => {
const html = '<img src="assets/slide/slide-hrv/fig-01.png" alt="x"><img src="/altro.png">';
expect(rewriteAssets(html)).toBe(
'<img src="/campus/assets/slide/slide-hrv/fig-01.png" alt="x"><img src="/altro.png">');
});
});
describe('struttura del corso', () => {
const chapterNums = CHAPTERS.map((c) => c[0] as number);
it('ogni capitolo ha un colore e ogni area capitoli esistenti', () => {
expect(CH_COLORS.length).toBe(CHAPTERS.length);
for (const area of AREAS)
for (const n of area[4] as number[]) expect(chapterNums).toContain(n);
});
it('nessuno slug duplicato e ogni capitolo è in una sola area', () => {
const slugs = CHAPTERS.flatMap((c) => c[3] as string[]);
expect(new Set(slugs).size).toBe(slugs.length);
const assigned = AREAS.flatMap((a) => a[4] as number[]);
expect(new Set(assigned).size).toBe(assigned.length);
expect(assigned.sort((a, b) => a - b)).toEqual(chapterNums);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { validateContact } from '../src/lib/contact';
describe('validateContact', () => {
const good = { firstName: 'Mario', lastName: 'Rossi', phone: '3331234567', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' };
it('accetta payload valido', () => {
const r = validateContact(good);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value.lastName).toBe('Rossi');
expect(r.value.email).toBe('mario@example.com');
}
});
it('rifiuta email non valida', () => {
expect(validateContact({ ...good, email: 'non-email' }).ok).toBe(false);
});
it('rifiuta campi obbligatori mancanti o vuoti', () => {
expect(validateContact({ ...good, firstName: ' ' }).ok).toBe(false);
expect(validateContact({ ...good, lastName: ' ' }).ok).toBe(false);
expect(validateContact({ ...good, phone: '' }).ok).toBe(false);
expect(validateContact({ ...good, message: '' }).ok).toBe(false);
expect(validateContact(null).ok).toBe(false);
});
it('rifiuta honeypot compilato', () => {
expect(validateContact({ ...good, hp_field: 'spam.com' }).ok).toBe(false);
});
it('rifiuta messaggi oltre 5000 caratteri', () => {
expect(validateContact({ ...good, message: 'x'.repeat(5001) }).ok).toBe(false);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { contentSeed } from '../src/data/content-seed';
import { applyContentUpdate } from '../src/lib/content-update';
let db: Database.Database;
const textTag = contentSeed.find((e) => e.type === 'text')!.tag;
const htmlTag = contentSeed.find((e) => e.type === 'html')?.tag;
beforeEach(() => { db = createDb(':memory:'); });
describe('applyContentUpdate', () => {
it('aggiorna value e styles validi', () => {
const r = applyContentUpdate(db, textTag, { value: 'Nuovo testo', styles: { size: 'l' } }, 'admin');
expect(r).toEqual({ ok: true });
const row = db.prepare('SELECT value, styles, updated_by FROM content_blocks WHERE tag = ?').get(textTag) as any;
expect(row.value).toBe('Nuovo testo');
expect(JSON.parse(row.styles)).toEqual({ size: 'l' });
expect(row.updated_by).toBe('admin');
});
it('404 su tag inesistente, 400 su styles fuori whitelist', () => {
expect(applyContentUpdate(db, 'no.tag', { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 404 });
expect(applyContentUpdate(db, textTag, { styles: { size: 'xxxl' } }, 'a')).toMatchObject({ ok: false, status: 400 });
});
it('sanifica i tag html e rifiuta value su tag image', () => {
if (htmlTag) {
applyContentUpdate(db, htmlTag, { value: 'ciao <script>alert(1)</script><strong>ok</strong>' }, 'a');
const row = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(htmlTag) as any;
expect(row.value).not.toContain('<script>');
expect(row.value).toContain('<strong>ok</strong>');
}
const imgTag = contentSeed.find((e) => e.type === 'image')!.tag;
expect(applyContentUpdate(db, imgTag, { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 400 });
});
});
describe('GET /api/admin/content/[tag]', () => {
it('GET /api/admin/content/[tag] ritorna tag, guid, type, value, styleOptions', async () => {
const { GET } = await import('../src/pages/api/admin/content/[tag]');
const res = await GET({ params: { tag: 'home.hero.cta' } } as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.tag).toBe('home.hero.cta');
expect(typeof body.guid).toBe('string');
expect(body.type).toBeDefined();
expect(Array.isArray(body.styleOptions)).toBe(true);
});
});
+199
View File
@@ -0,0 +1,199 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDb } from '../src/lib/db';
import { createUser } from '../src/lib/auth';
describe('schema content_blocks e ruoli', () => {
it('crea la tabella content_blocks', () => {
const db = createDb(':memory:');
const cols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
expect(cols.map((c) => c.name)).toEqual(
expect.arrayContaining(['tag', 'type', 'value', 'styles', 'updated_at', 'updated_by'])
);
});
it('rifiuta type non valido', () => {
const db = createDb(':memory:');
expect(() =>
db.prepare(`INSERT INTO content_blocks (tag, type, value) VALUES ('x.y', 'video', '')`).run()
).toThrow();
});
it('users ha colonna role con default admin', () => {
const db = createDb(':memory:');
db.prepare(`INSERT INTO users (username, password_hash) VALUES ('a', 'h')`).run();
const row = db.prepare(`SELECT role FROM users WHERE username = 'a'`).get() as { role: string };
expect(row.role).toBe('admin');
});
it('migra i tag programmi → servizi preservando i valori modificati dal pannello', () => {
const dir = mkdtempSync(join(tmpdir(), 'il-db-test-'));
const path = join(dir, 'test.db');
try {
// Simula un DB di produzione pre-rinomina: tag vecchi con valori modificati dal pannello.
const db1 = createDb(path);
const old = db1.transaction(() => {
db1.prepare("DELETE FROM content_blocks WHERE tag IN ('home.services.title', 'service.rehab.title', 'services.hero.title', 'services.meta.title', 'footer.services.1.label', 'footer.col.services-title')").run();
const ins = db1.prepare("INSERT INTO content_blocks (tag, type, value, updated_by) VALUES (?, 'text', ?, 'editor')");
ins.run('home.programs.title', 'Titolo modificato');
ins.run('program.rehab.title', 'Rehab modificato');
ins.run('programs.hero.title', 'Hero modificato');
ins.run('footer.programs.1.label', 'Label modificata');
ins.run('footer.col.programs-title', 'Colonna modificata');
// Riga mai toccata dal pannello: valore identico al vecchio seed → deve prendere il nuovo default.
db1.prepare("INSERT INTO content_blocks (tag, type, value) VALUES ('programs.meta.title', 'text', 'Programmi')").run();
db1.prepare("UPDATE content_blocks SET value = 'Programmi' WHERE tag = 'global.nav.2.label'").run();
});
old();
db1.close();
// Riapertura: la migrazione rinomina i tag preservando valore e updated_by.
const db2 = createDb(path);
const val = (tag: string) =>
db2.prepare('SELECT value, updated_by FROM content_blocks WHERE tag = ?').get(tag) as { value: string; updated_by: string | null } | undefined;
expect(val('home.services.title')).toMatchObject({ value: 'Titolo modificato', updated_by: 'editor' });
expect(val('service.rehab.title')).toMatchObject({ value: 'Rehab modificato', updated_by: 'editor' });
expect(val('services.hero.title')).toMatchObject({ value: 'Hero modificato', updated_by: 'editor' });
expect(val('footer.services.1.label')).toMatchObject({ value: 'Label modificata', updated_by: 'editor' });
expect(val('footer.col.services-title')).toMatchObject({ value: 'Colonna modificata', updated_by: 'editor' });
// Le righe rimaste al vecchio valore di default prendono il nuovo ("Servizi").
// Dopo il riordino del menu (commit 5ff4771) la voce "Servizi" è la 2ª: la migrazione
// aggiorna global.nav.2.label da 'Programmi' al seed corrente 'Servizi'.
expect(val('services.meta.title')).toMatchObject({ value: 'Servizi' });
expect(val('global.nav.2.label')).toMatchObject({ value: 'Servizi' });
// La riga rinominata ma modificata dal pannello resta invariata (già verificata sopra):
// home.services.title = 'Titolo modificato'.
// Nessun tag vecchio residuo.
const residui = db2.prepare(
"SELECT count(*) AS c FROM content_blocks WHERE tag LIKE 'program%' OR tag LIKE 'home.programs%' OR tag LIKE 'footer.programs%' OR tag = 'footer.col.programs-title'"
).get() as { c: number };
expect(residui.c).toBe(0);
// Idempotente: una terza apertura non altera i valori.
db2.close();
const db3 = createDb(path);
const again = db3.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('home.services.title') as { value: string };
expect(again.value).toBe('Titolo modificato');
db3.close();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('allinea i bottoni promo col vecchio testo-freccia al seed senza freccia', () => {
const dir = mkdtempSync(join(tmpdir(), 'il-db-promo-'));
const path = join(dir, 'test.db');
try {
// DB pre-refactor: valori con freccia letterale e maiuscolo (poi spostati nel CSS).
const db1 = createDb(path);
db1.prepare("UPDATE content_blocks SET value = 'LE NOSTRE PROPOSTE →' WHERE tag = 'promo.hero.cta'").run();
db1.prepare("UPDATE content_blocks SET value = 'ACQUISTA →' WHERE tag = 'promo.buy-label'").run();
// Riga modificata dal pannello: NON deve essere toccata.
db1.prepare("UPDATE content_blocks SET value = 'Compra ora', updated_by = 'editor' WHERE tag = 'promo.buy-label'").run();
db1.close();
// Riapertura: la migrazione allinea solo le righe rimaste al vecchio default con freccia.
const db2 = createDb(path);
const val = (tag: string) => (db2.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
expect(val('promo.hero.cta')).toBe('Le nostre proposte');
// promo.buy-label era stato editato dal pannello → resta invariato.
expect(val('promo.buy-label')).toBe('Compra ora');
db2.close();
// Idempotente: terza apertura non altera.
const db3 = createDb(path);
expect((db3.prepare("SELECT value FROM content_blocks WHERE tag = 'promo.hero.cta'").get() as { value: string }).value).toBe('Le nostre proposte');
db3.close();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('riporta il titolo del servizio a "Class" (inverte Performance Class)', () => {
const dir = mkdtempSync(join(tmpdir(), 'il-db-pc-'));
const path = join(dir, 'test.db');
try {
// DB con il vecchio valore normalizzato "Performance Class".
const db1 = createDb(path);
db1.prepare("UPDATE content_blocks SET value = 'Performance Class', updated_by = NULL WHERE tag = 'service.performance-class.title'").run();
db1.close();
const db2 = createDb(path);
const val = (tag: string) => (db2.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
expect(val('service.performance-class.title')).toBe('Class');
db2.close();
// Non deve toccare un valore modificato dal pannello.
const db3 = createDb(path);
db3.prepare("UPDATE content_blocks SET value = 'Titolo mio', updated_by = 'admin' WHERE tag = 'service.performance-class.title'").run();
db3.close();
const db4 = createDb(path);
expect((db4.prepare("SELECT value FROM content_blocks WHERE tag = 'service.performance-class.title'").get() as { value: string }).value).toBe('Titolo mio');
db4.close();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('rimuove i tag orfani training.performance-class.* (riquadro rimosso)', () => {
const dir = mkdtempSync(join(tmpdir(), 'il-db-orf-'));
const path = join(dir, 'test.db');
try {
// DB con i tag orfani del vecchio riquadro performance-class.
const db1 = createDb(path);
const ins = db1.prepare("INSERT OR REPLACE INTO content_blocks (tag, type, value, guid) VALUES (?, 'text', ?, ?)");
ins.run('training.performance-class.title', 'Holistic Class', 'g1');
ins.run('training.performance-class.caption', 'Classi fino a 8 persone', 'g2');
db1.close();
const db2 = createDb(path);
const count = (db2.prepare("SELECT count(*) c FROM content_blocks WHERE tag LIKE 'training.performance-class.%'").get() as { c: number }).c;
expect(count).toBe(0);
// I riquadri validi restano.
expect((db2.prepare("SELECT count(*) c FROM content_blocks WHERE tag = 'training.holistic-class.title'").get() as { c: number }).c).toBe(1);
db2.close();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('migra un DB esistente senza colonna role', () => {
// simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile,
// quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia)
const db = createDb(':memory:');
expect(() => createDb(':memory:')).not.toThrow();
expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4);
});
});
describe('migrazioni ruoli/guid/autore', () => {
it('ogni content_block ha un guid non vuoto e univoco', () => {
const db = createDb(':memory:');
const rows = db.prepare('SELECT guid FROM content_blocks').all() as { guid: string | null }[];
expect(rows.length).toBeGreaterThan(100);
expect(rows.every((r) => typeof r.guid === 'string' && r.guid!.length >= 10)).toBe(true);
const guids = new Set(rows.map((r) => r.guid));
expect(guids.size).toBe(rows.length);
});
it('lo statement di migrazione unifica editor in superuser', () => {
// La migrazione gira dentro createDb, ma :memory: non persiste tra due boot: qui
// verifichiamo l'invariante SQL applicata dalla migrazione (editor → superuser).
// L'end-to-end su DB pre-esistente è coperto dallo smoke di produzione (Task 15).
const db = createDb(':memory:');
db.prepare("INSERT INTO users (username, password_hash, role) VALUES ('vecchio', 'x', 'editor')").run();
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
const row = db.prepare("SELECT role FROM users WHERE username = 'vecchio'").get() as { role: string };
expect(row.role).toBe('superuser');
});
it('posts ha la colonna author_id', () => {
const db = createDb(':memory:');
const cols = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
expect(cols.some((c) => c.name === 'author_id')).toBe(true);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { contentSeed } from '../src/data/content-seed';
import { makeContentStore, syncSeed, resolveContent } from '../src/lib/content';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('seed', () => {
it('tag univoci, validi e valori non vuoti', () => {
const tags = contentSeed.map((e) => e.tag);
expect(new Set(tags).size).toBe(tags.length);
for (const e of contentSeed) {
expect(e.tag).toMatch(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/);
if (e.type !== 'image') expect(e.value.length).toBeGreaterThan(0);
}
});
it('createDb sincronizza il seed senza sovrascrivere modifiche', () => {
const n = (db.prepare('SELECT COUNT(*) AS n FROM content_blocks').get() as { n: number }).n;
expect(n).toBe(contentSeed.length);
db.prepare(`UPDATE content_blocks SET value = 'MODIFICATO' WHERE tag = ?`).run(contentSeed[0].tag);
// ri-sync (come a un nuovo avvio) non deve toccare la modifica
syncSeed(db);
const v = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(contentSeed[0].tag) as { value: string };
expect(v.value).toBe('MODIFICATO');
});
});
describe('content store', () => {
it('legge dal DB con classi preset e cache invalidabile', () => {
const store = makeContentStore(() => db);
const tag = contentSeed[0].tag;
db.prepare(`UPDATE content_blocks SET value = 'Nuovo', styles = '{"size":"l"}' WHERE tag = ?`).run(tag);
store.invalidate();
const r = store.resolve(tag);
expect(r.value).toBe('Nuovo');
expect(r.classes).toBe('cs-size-l');
// senza invalidate la cache resta
db.prepare(`UPDATE content_blocks SET value = 'Altro' WHERE tag = ?`).run(tag);
expect(store.resolve(tag).value).toBe('Nuovo');
store.invalidate();
expect(store.resolve(tag).value).toBe('Altro');
});
it('fallback: tag assente nel DB usa il seed, sconosciuto stringa vuota', () => {
const store = makeContentStore(() => db);
db.prepare('DELETE FROM content_blocks WHERE tag = ?').run(contentSeed[0].tag);
store.invalidate();
expect(store.resolve(contentSeed[0].tag).value).toBe(contentSeed[0].value);
expect(store.resolve('tag.inesistente').value).toBe('');
});
it('styles corrotto nel DB viene ignorato', () => {
const store = makeContentStore(() => db);
db.prepare(`UPDATE content_blocks SET styles = 'non-json' WHERE tag = ?`).run(contentSeed[0].tag);
store.invalidate();
expect(store.resolve(contentSeed[0].tag).classes).toBe('');
});
it('resolveContent restituisce il guid dal DB', () => {
const r = resolveContent('home.hero.cta');
expect(typeof r.guid).toBe('string');
expect(r.guid.length).toBeGreaterThan(10);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { existsSync, readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
// L'indirizzo della pagina evento e' stampato dentro il QR code delle locandine affisse
// in giro per Bologna: una volta in strada non si corregge piu'. Questi test tengono
// insieme le tre cose che devono restare d'accordo — la rotta servita dal sito, l'URL
// cucito nel QR e il file che la pagina offre in download.
const ROTTA = 'src/pages/promo-eventi/eventi.astro';
const URL_STAMPATO = 'https://insanitylab.it/promo-eventi/eventi';
describe('locandina In-Sanity Hour', () => {
it('la rotta che il QR indica esiste', () => {
expect(existsSync(ROTTA)).toBe(true);
});
it('il generatore della locandina punta a quella rotta', () => {
const script = readFileSync('scripts/locandina-evento.py', 'utf-8');
expect(script).toContain(`URL_EVENTO = '${URL_STAMPATO}'`);
// La rotta del file deve corrispondere alla coda dell'URL, altrimenti il QR
// porterebbe altrove: /promo-eventi/eventi <- src/pages/promo-eventi/eventi.astro
const daFile = '/' + ROTTA.replace('src/pages/', '').replace('.astro', '');
expect(URL_STAMPATO.endsWith(daFile)).toBe(true);
});
it('la locandina che la pagina offre in download esiste', () => {
const pagina = readFileSync(ROTTA, 'utf-8');
const link = pagina.match(/href="(\/locandina-[^"]+\.pdf)"/);
expect(link).not.toBeNull();
expect(existsSync(`public${link![1]}`)).toBe(true);
});
});
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
import { creaCliente, codicePerUtente, etaAllaData } from '../../src/lib/longevity/anagrafica';
describe('anagrafica pseudonimizzata', () => {
it('il nome sta in identity, il sesso in longevity, e i due non si mescolano', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'Mario', cognome: 'Rossi', sesso: 'M', data_nascita: '1988-03-04' });
const inIdentity = id.prepare(`SELECT nome, cognome FROM clienti WHERE client_code = ?`).get(code) as
{ nome: string; cognome: string };
expect(inIdentity.nome).toBe('Mario');
const inLongevity = lg.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(code) as { sesso: string };
expect(inLongevity.sesso).toBe('M');
// in longevity non deve esistere nessuna colonna che contenga il nome
const dump = JSON.stringify(lg.prepare(`SELECT * FROM soggetti`).all());
expect(dump).not.toContain('Mario');
expect(dump).not.toContain('Rossi');
expect(dump).not.toContain('1988-03-04');
});
it('assegna codici progressivi e non riusa i vecchi', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' });
const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
expect(a).toMatch(/^ISL-\d{4}$/);
expect(b).not.toBe(a);
});
it('ritrova il codice a partire dall utente del sito', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F', user_id: 42 });
expect(codicePerUtente(id, 42)).toBe(code);
expect(codicePerUtente(id, 99)).toBeNull();
});
it('calcola l eta alla data della sessione', () => {
expect(etaAllaData('1988-03-04', '2026-08-21')).toBe(38);
expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato
});
it('un codice cancellato dall anagrafica non torna mai disponibile: le misure del vecchio non si attribuiscono al nuovo', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' });
const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
// Il secondo cliente viene cancellato dall'anagrafica (disdetta, richiesta di
// cancellazione, errore): le sue misure restano in longevity, pseudonimizzate.
id.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(b);
lg.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES (?, '2026-08-01', 'checkup')`).run(b);
const c = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F' });
// Il terzo cliente riceve un codice nuovo, mai il codice riciclato del secondo.
expect(c).not.toBe(b);
expect(c).not.toBe(a);
// Le sessioni del vecchio cliente (b) restano leggibili sotto il suo codice...
const sessioniDiB = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(b);
expect(sessioniDiB.length).toBe(1);
// ...e non risultano in nessun modo attribuite al nuovo cliente (c).
const sessioniDiC = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(c);
expect(sessioniDiC.length).toBe(0);
});
it('se la scrittura su longevity fallisce, quella gia fatta su identity viene annullata', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const primaDelTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
// 'Z' non è un sesso valido (CHECK IN ('M','F') su longevity.soggetti): la scrittura
// su longevity fallisce per costruzione, dopo che quella su identity è già avvenuta.
expect(() =>
creaCliente(id, lg, { nome: 'Errato', cognome: 'Errato', sesso: 'Z' as unknown as 'M' | 'F' })
).toThrow();
const dopoIlTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
expect(dopoIlTentativo).toBe(primaDelTentativo); // nessun cliente orfano rimasto in identity
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
});
it('rete secondaria: nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
// ⚠️ QUESTO TEST NON È LA GARANZIA. La garanzia vera è che getIdentityDb non è più
// esportata da db.ts (vive privata dentro anagrafica.ts): non esiste, da nessuna
// parte del codice fuori da lì, un nome da importare per ottenere quella connessione.
// Un controllo testuale come questo non può bastare da solo — è stato dimostrato:
// tre file, uno che riesporta la connessione a identity, uno che riesporta quella a
// longevity, un terzo che importa da entrambi e le usa insieme, e NESSUNO dei tre
// contiene singolarmente le due parole. Un controllo file-per-file non vede una
// giunzione che attraversa più file, e non c'è formulazione di questo test che lo
// possa vedere: la risposta era rendere la cosa impossibile, non sorvegliarla meglio.
// Questo test resta come rete secondaria — cattura il caso comune (una giunzione
// dentro un solo file) e protegge contro un domani in cui getIdentityDb tornasse
// esportata per errore — non come prova che la giunzione non possa esistere.
// Cerca i due riferimenti letterali "Identity"/"Longevity" (con l'iniziale maiuscola,
// come compaiono in qualunque accessore camelCase) invece di un elenco chiuso di nomi:
// un elenco di nomi vietati invecchia (è già successo qui: getIdentityDb/getLongevityDb
// sono nati dopo la prima versione di questo test e sono passati indisturbati).
// ⚠️ VERIFICATO IL 22/08, UN TERZO BUCO, STAVOLTA IN UN FILE SOLO: `createIdentityDb`
// (a differenza del singleton privato getIdentityDb) È esportata da db.ts — serve a
// creaCliente, che riceve due connessioni già aperte da fuori. Un file che chiama
// `createIdentityDb()` direttamente e apre longevity con una connessione propria (es.
// `new Database(process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db')`, mai col nome
// `getLongevityDb`/`createLongevityDb`) contiene "Identity" ma MAI "Longevity" con
// l'iniziale maiuscola — LONGEVITY_DB_PATH è tutto maiuscolo, 'longevity.db' tutto
// minuscolo — e questo test lo lascia passare. Provato con un file del genere in
// src/lib/longevity/: zero test rossi. Non è una falla da chiudere con un controllo
// più furbo (già tentato e aggirato due volte prima di questa): la garanzia resta solo
// quella architetturale sopra, e su `createIdentityDb` non c'è — è pensata per essere
// chiamata da fuori anagrafica.ts (creaCliente la riceve come parametro), quindi non si
// può chiuderla come si è chiusa getIdentityDb senza cambiare quella funzione.
const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = [];
const files = readdirSync(srcDir, { recursive: true, withFileTypes: false }) as string[];
for (const f of files) {
const path = join(srcDir, f);
// Salta node_modules e dist
if (f.includes('node_modules') || f.includes('dist')) continue;
// Accetta solo .ts e .astro
if (!f.endsWith('.ts') && !f.endsWith('.astro')) continue;
// Esclude i moduli autorizzati
if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue;
const src = readFileSync(path, 'utf8');
if (src.includes('Identity') && src.includes('Longevity')) {
colpevoli.push(f);
}
}
expect(colpevoli).toEqual([]);
});
});
@@ -0,0 +1,52 @@
// Il consenso veniva scritto DOPO che salvaCompilazione aveva già chiuso la propria
// transazione: se il passo successivo falliva, restavano i dati sanitari SENZA il record
// di consenso che li giustifica — l'esatto contrario dell'invariante ("o entra tutto nel
// fascicolo, o non entra niente"). Questo file forza un fallimento nel calcolo dei
// punteggi (mockando calcolaSessione) DOPO che la sessione e le misure sono già scritte, e
// verifica che l'intera transazione — sessione, misure, punteggi, consenso — vada
// indietro insieme. Il mock vive in un file a parte perché vi.mock è per-file: gli altri
// test su calcolaSessione (referto.test.ts, salvataggio-e2e.test.ts) devono restare reali.
import { describe, it, expect, vi } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
vi.mock('../../src/lib/longevity/motore', async (importOriginal) => {
const reale = await importOriginal<typeof import('../../src/lib/longevity/motore')>();
return {
...reale,
calcolaSessione: () => { throw new Error('rottura simulata nel calcolo dei punteggi'); },
};
});
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
describe('salvaDalForm — atomicità di dati, punteggi e consenso', () => {
it('se il calcolo dei punteggi fallisce DOPO che sessione e misure sono state scritte, tutto torna indietro: niente sessione, niente misure, niente nota di consenso', async () => {
const { salvaDalForm } = await import('../../src/lib/longevity/vista');
const db = dbPronto();
expect(() =>
salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_riposato: 8 }, consensoSanitario: 'Si', liberi: {},
})
).toThrow(/rottura simulata/);
const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
const score = db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number };
const consenso = db.prepare(`SELECT COUNT(*) n FROM profilo_note WHERE campo_id = 'consenso_sanitario'`)
.get() as { n: number };
expect(sessioni.n).toBe(0);
expect(misure.n).toBe(0);
expect(score.n).toBe(0);
expect(consenso.n).toBe(0);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { createLongevityDb, createIdentityDb, COPERTURA_MINIMA } from '../../src/lib/longevity/db';
describe('schema longevity', () => {
it('crea le tabelle previste dalla spec', () => {
const db = createLongevityDb(':memory:');
const nomi = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
.map((r) => r.name);
expect(nomi).toEqual(
expect.arrayContaining(['soggetti', 'sessioni', 'misure', 'registro_test', 'pesi', 'profilo_note', 'score'])
);
});
it('rifiuta un sesso non previsto', () => {
const db = createLongevityDb(':memory:');
expect(() =>
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'X')`).run()
).toThrow();
});
it('rifiuta una misura con test_id non nel registro', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte)
VALUES (1, 'ISL-0001', 'test_inventato', 10, 'manuale')`
).run()
).toThrow();
});
it('uno score insufficiente puo non avere valore', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')`
).run();
const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string };
expect(row.valore).toBeNull();
expect(row.stato).toBe('insufficiente');
});
it('la tabella rifiuta uno score insufficiente CON un valore scritto', () => {
// Il tipo Punteggio protegge il codice, ma l'interfaccia legge dalla tabella,
// dove il tipo non c'e' piu': senza questo vincolo si potrebbe scrivere un
// numero dichiarato "insufficiente" e chi legge crud dalla tabella lo prenderebbe.
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', 42, 0.2, 'insufficiente', 'v1.0')`
).run()
).toThrow();
});
it('la tabella rifiuta uno score ok SENZA valore', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 1, 'ok', 'v1.0')`
).run()
).toThrow();
});
it('identity tiene la persona, longevity non la conosce', () => {
const id = createIdentityDb(':memory:');
const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name);
expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita']));
const lg = createLongevityDb(':memory:');
const tutte = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
.flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name));
expect(tutte).not.toContain('nome');
expect(tutte).not.toContain('cognome');
expect(tutte).not.toContain('data_nascita');
});
it('la soglia di copertura e 0.40', () => {
expect(COPERTURA_MINIMA).toBe(0.4);
});
});
+85
View File
@@ -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();
});
});
+16
View File
@@ -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,87 @@
// 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();
});
it('una rotta pubblica non passa da nessun controllo: next() viene chiamata', async () => {
const ctx = contesto('/blog');
const n = next();
const res = await onRequest(ctx, n.fn);
expect(n.fuChiamata()).toBe(true);
expect(await (res as Response).text()).toBe('PAGINA-SERVITA');
});
});
+64
View File
@@ -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();
});
});
+305
View File
@@ -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);
}
});
});
+96
View File
@@ -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);
});
});
+124
View File
@@ -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
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { canAccessAdminPath, isProtectedPath, landingFor, isRole, ROLES } from '../../src/lib/auth';
describe('accesso alle rotte longevity', () => {
it('il cliente entra nel proprio spazio', () => {
expect(canAccessAdminPath('cliente', '/longevity/io')).toBe(true);
});
it('il cliente NON entra nel gestionale', () => {
expect(canAccessAdminPath('cliente', '/longevity/gestionale')).toBe(false);
});
it('il trainer entra in entrambi', () => {
expect(canAccessAdminPath('trainer', '/longevity/io')).toBe(true);
expect(canAccessAdminPath('trainer', '/longevity/gestionale')).toBe(true);
});
it('il cliente non entra nelle altre piattaforme ne nel blog', () => {
expect(canAccessAdminPath('cliente', '/campus')).toBe(false);
expect(canAccessAdminPath('cliente', '/admin/content')).toBe(false);
expect(canAccessAdminPath('cliente', '/admin/posts')).toBe(false);
});
it('il trainer non entra nelle altre piattaforme ne nel blog', () => {
expect(canAccessAdminPath('trainer', '/campus')).toBe(false);
expect(canAccessAdminPath('trainer', '/admin/content')).toBe(false);
expect(canAccessAdminPath('trainer', '/admin/posts')).toBe(false);
});
it('il ruolo piattaforme non eredita longevity', () => {
expect(canAccessAdminPath('piattaforme', '/longevity/gestionale')).toBe(false);
});
it('admin passa sempre', () => {
expect(canAccessAdminPath('admin', '/longevity/gestionale')).toBe(true);
});
});
describe('isProtectedPath — quali rotte sono protette dal middleware', () => {
it('longevity è protetto', () => {
expect(isProtectedPath('/longevity/io')).toBe(true);
expect(isProtectedPath('/longevity/gestionale')).toBe(true);
expect(isProtectedPath('/api/longevity/qualcosa')).toBe(true);
});
it('i percorsi già esistenti restano protetti', () => {
expect(isProtectedPath('/admin')).toBe(true);
expect(isProtectedPath('/admin/content')).toBe(true);
expect(isProtectedPath('/api/admin/content')).toBe(true);
expect(isProtectedPath('/campus')).toBe(true);
expect(isProtectedPath('/piattaforme')).toBe(true);
});
it('il login e le pagine pubbliche non sono protetti', () => {
expect(isProtectedPath('/admin/login')).toBe(false);
expect(isProtectedPath('/blog')).toBe(false);
expect(isProtectedPath('/')).toBe(false);
});
});
describe('landingFor — indirizzo di atterraggio per ruolo', () => {
it('cliente atterra su longevity/io', () => {
expect(landingFor('cliente')).toBe('/longevity/io');
});
it('trainer atterra su longevity/gestionale', () => {
expect(landingFor('trainer')).toBe('/longevity/gestionale');
});
it('i ruoli già esistenti restano uguali', () => {
expect(landingFor('superuser')).toBe('/admin/content');
expect(landingFor('piattaforme')).toBe('/piattaforme');
expect(landingFor('admin')).toBe('/admin');
expect(landingFor('user')).toBe('/admin');
});
});
describe('isRole — validazione dei ruoli', () => {
it('cliente e trainer sono ruoli validi', () => {
expect(isRole('cliente')).toBe(true);
expect(isRole('trainer')).toBe(true);
});
it('i ruoli già esistenti restano validi', () => {
expect(isRole('admin')).toBe(true);
expect(isRole('superuser')).toBe(true);
expect(isRole('user')).toBe(true);
expect(isRole('piattaforme')).toBe(true);
});
it('campus e stringhe inventate non sono ruoli', () => {
expect(isRole('campus')).toBe(false);
expect(isRole('qualsiasi-cosa')).toBe(false);
});
});
describe('ROLES — fonte unica dei ruoli, usata dal pannello utenti', () => {
it('contiene tutti e sei i ruoli, cliente e trainer inclusi', () => {
expect(ROLES).toContain('cliente');
expect(ROLES).toContain('trainer');
expect(ROLES).toContain('admin');
expect(ROLES).toContain('superuser');
expect(ROLES).toContain('user');
expect(ROLES).toContain('piattaforme');
expect(ROLES.length).toBe(6);
});
it('isRole accetta esattamente i ruoli di ROLES, nessuno in piu o in meno', () => {
for (const r of ROLES) expect(isRole(r)).toBe(true);
expect(isRole('non-un-ruolo')).toBe(false);
});
});
describe('pannello utenti — la tendina dei ruoli non e piu scritta a mano', () => {
it('users.astro pesca i ruoli da ROLES, non da una lista propria che ne dimentica due', () => {
const src = readFileSync(join(process.cwd(), 'src/pages/admin/users.astro'), 'utf8');
expect(src).toContain('ROLES');
// La vecchia lista a quattro (dimenticava cliente e trainer) non deve piu comparire.
expect(src).not.toContain("['user', 'superuser', 'piattaforme', 'admin']");
});
});
@@ -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);
});
});
+197
View File
@@ -0,0 +1,197 @@
import { describe, it, expect } from 'vitest';
import { createDb } from '../src/lib/db';
import { services, getService } from '../src/data/services';
import { trainings, trainingDaMostrare, VISTE_TRAINING } from '../src/data/trainings';
import { contentSeed } from '../src/data/content-seed';
import { site } from '../src/data/site';
import { PAGINE_CON_CALENDARIO, CARD_CON_CALENDARIO } from '../src/data/calendari';
import { rinominaSmallGroup, calcola, applica, TESTI } from '../scripts/testi-17-agosto.mjs';
// Le modifiche chieste dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf).
describe('Small Group è diventato Personalized Training Group', () => {
it('non resta traccia del vecchio nome nei dati né nel seed', () => {
const testi = [
...services.flatMap((s) => [s.title, s.subtitle, s.excerpt, s.longDescription, ...s.features, ...s.faq.flatMap((f) => [f.q, f.a])]),
...trainings.flatMap((t) => [t.title, t.caption, t.description]),
...site.footerTraining.map((v) => v.label),
...contentSeed.map((e) => e.value),
];
expect(testi.filter((v) => /small group/i.test(v))).toEqual([]);
});
// Lo slug resta `small-group`: è l'indirizzo della scheda, e cambiarlo romperebbe i link
// già girati su WhatsApp senza che nessuno se ne accorga.
it('lo slug della scheda non cambia', () => {
expect(getService('small-group')?.title).toBe('Personalized Training Group');
});
it('gli articoli restano corretti, e passarci due volte non cambia niente', () => {
const casi: [string, string][] = [
['Lo Small Group di InsanityLab è un allenamento', 'Il Personalized Training Group di InsanityLab è un allenamento'],
['nuovi format come gli small group e una', 'nuovi format come i Personalized Training Group e una'],
['One to One / Small Group / Rehab', 'One to One / Personalized Training Group / Rehab'],
['Small Groups', 'Personalized Training Group'],
['in formula small group (massimo 4 persone)', 'in formula Personalized Training Group (massimo 4 persone)'],
];
for (const [prima, dopo] of casi) {
expect(rinominaSmallGroup(prima)).toBe(dopo);
expect(rinominaSmallGroup(dopo)).toBe(dopo);
}
});
});
describe('lo script porta i testi nuovi in un database già scritto', () => {
it('aggiorna le righe vecchie e al secondo giro non ha più niente da fare', () => {
const db = createDb(':memory:');
const scrivi = db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
scrivi.run('service.one-to-one.subtitle', 'text', 'One to One / Small Group / Rehab');
scrivi.run('service.profilazione.subtitle', 'text', 'Profilazione e valutazione fisica');
scrivi.run('footer.services.4.label', 'text', 'Profilazione');
const cambi = applica(db);
const dopo = (tag: string) => (db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
expect(dopo('service.one-to-one.subtitle')).toBe('One to One / Personalized Training Group / Rehab');
expect(dopo('service.profilazione.subtitle')).toBe('Base e avanzato');
expect(dopo('footer.services.4.label')).toBe('Check-up');
expect(cambi.length).toBeGreaterThanOrEqual(3);
expect(calcola(db)).toEqual([]);
});
// Un `--dry-run` che scrivesse renderebbe inutile guardare prima di toccare la produzione.
it('con --dry-run non tocca niente', () => {
const db = createDb(':memory:');
db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)')
.run('promo.card.2.title', 'text', 'Small Group');
expect(applica(db, { dryRun: true }).length).toBe(1);
expect((db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('promo.card.2.title') as { value: string }).value)
.toBe('Small Group');
});
it('i testi riscritti a mano corrispondono a quelli del seed', () => {
const seed = new Map(contentSeed.map((e) => [e.tag, e.value]));
for (const [tag, valore] of Object.entries(TESTI)) expect(seed.get(tag)).toBe(valore);
});
});
describe('viste di /training', () => {
it('senza vista si vedono tutti i training', () => {
expect(trainingDaMostrare()?.map((t) => t.id)).toEqual(trainings.map((t) => t.id));
});
it('One to One porta a One to One, Personalized Training Group e Rehab', () => {
expect(trainingDaMostrare('one-to-one')?.map((t) => t.id)).toEqual(['one-to-one', 'small-groups', 'rehab']);
});
it('Class porta a Holistic e Reformer', () => {
expect(trainingDaMostrare('class')?.map((t) => t.id)).toEqual(['holistic-class', 'reformer-class']);
});
// Un filtro sbagliato che mostra tutto farebbe passare per buono un indirizzo rotto.
it('una vista inventata è 404, non l\'elenco intero', () => {
expect(trainingDaMostrare('inesistente')).toBeNull();
});
it('ogni vista nomina solo training che esistono', () => {
for (const v of Object.values(VISTE_TRAINING)) {
for (const id of v.blocchi) expect(trainings.some((t) => t.id === id)).toBe(true);
}
});
});
describe('instradamento delle card di /services', () => {
const href = (slug: string) => { const s = getService(slug)!; return s.href ?? `/services/${s.slug}`; };
it('One to One e Class portano alle viste di training', () => {
expect(href('one-to-one')).toBe('/training/one-to-one');
expect(href('performance-class')).toBe('/training/class');
});
it('Check-up porta alla propria scheda', () => {
expect(href('profilazione')).toBe('/services/profilazione');
});
// La Performance Class non si eroga più: la card resta, la scheda che la spiegava no.
it('la Performance Class non ha più una scheda ma resta in griglia', () => {
expect(getService('performance-class')?.page).toBe(false);
expect(getService('performance-class')?.grid).not.toBe(false);
});
it('nessun link del sito manda a una scheda che non esiste', () => {
const schede = new Set(services.filter((s) => s.page !== false).map((s) => `/services/${s.slug}`));
const interni = [...site.footerServices, ...site.footerTraining, ...site.navigation]
.map((v) => v.href)
.filter((h) => h.startsWith('/services/'));
for (const h of interni) expect(schede.has(h)).toBe(true);
});
});
describe('Check-up', () => {
const checkup = getService('profilazione')!;
it('il trafiletto della card racconta Base e Avanzato', () => {
expect(checkup.title).toBe('Check-up');
expect(checkup.subtitle).toBe('Base e avanzato');
expect(checkup.excerpt).toContain('Check-up Base');
expect(checkup.excerpt).toContain('Check-up Avanzato');
});
it('la scheda porta la sezione avanzata con i suoi cinque punti', () => {
expect(checkup.extra?.title).toBe('Check-up Avanzato');
expect(checkup.extra?.body).toContain('Wellness Tower');
expect(checkup.extra?.items).toHaveLength(5);
});
// Tolta il 19/08 su richiesta del cliente: restava in fondo alla scheda e diceva cose che
// la sezione Avanzato racconta meglio.
it('la domanda «Che cosa comprende?» non c\'è più', () => {
expect(checkup.faq).toHaveLength(1);
expect(checkup.faq.map((f) => f.q)).not.toContain('Che cosa comprende?');
});
it('i testi della sezione avanzata sono modificabili dal pannello', () => {
const tags = new Set(contentSeed.map((e) => e.tag));
expect(tags.has('service.profilazione.extra.title')).toBe(true);
expect(tags.has('service.profilazione.extra.body')).toBe(true);
for (let i = 1; i <= 5; i++) expect(tags.has(`service.profilazione.extra.item.${i}`)).toBe(true);
});
});
describe('calendario delle class', () => {
// Il calendario si apre da due punti diversi: le schede delle class che si fanno, e la
// card «Class» della griglia servizi, che ha slug `performance-class`.
it('le schede sono Holistic e Reformer, la Performance Class è fuori', () => {
expect(PAGINE_CON_CALENDARIO).toEqual(['holistic-class', 'reformer-class']);
});
it('in griglia il bottone sta sulla card Class', () => {
expect(CARD_CON_CALENDARIO).toEqual(['performance-class']);
});
it('ogni slug nominato esiste davvero', () => {
for (const slug of [...PAGINE_CON_CALENDARIO, ...CARD_CON_CALENDARIO]) expect(getService(slug)).toBeDefined();
});
});
describe('promo ed eventi', () => {
it('i testi della pagina di scelta e degli eventi sono nel seed', () => {
const tags = new Set(contentSeed.map((e) => e.tag));
for (const tag of ['promo-eventi.hero.title', 'promo-eventi.promo.label', 'promo-eventi.eventi.label',
'eventi.hero.title', 'eventi.inaugurazione.title', 'eventi.inaugurazione.body']) {
expect(tags.has(tag)).toBe(true);
}
});
// I tag `eventi.*` restano nel seed — in produzione le righe esistono già — ma dal
// commit della landing dell'In-Sanity Hour non alimentano piu' nessuna pagina: la data
// vive nel suo tag, non nel titolo, da quando i due sono stati separati.
it('la data dell\'inaugurazione sta nel tag della data', () => {
expect(contentSeed.find((e) => e.tag === 'eventi.inaugurazione.date')!.value).toContain('19 settembre 2026');
});
// La voce di menu continua a puntare qui: è l'indirizzo già girato ai clienti.
it('il menu punta alla pagina di scelta', () => {
expect(site.navigation.some((v) => v.href === '/promo-eventi')).toBe(true);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import {
createPost, trashPost, restorePost, deletePost, getPostById, listAllPosts, listByAuthor,
listTrashed, listPublished, listRecent, listArchiveMonths, getPublishedBySlug,
countPublishedByCategory,
} from '../src/lib/posts';
let db: Database.Database;
let id: number;
const articolo = (slug: string, authorId: number | null = 1) => ({
title: `Titolo ${slug}`, slug, category: 'training', excerpt: 'x',
body_html: '<p>x</p>', draft: false, author_id: authorId,
});
beforeEach(() => {
db = createDb(':memory:');
id = createPost(db, articolo('uno'));
createPost(db, articolo('due', 2));
});
describe('cestino', () => {
it('cestinare non cancella: l\'articolo resta leggibile per id', () => {
trashPost(db, id);
const p = getPostById(db, id);
expect(p).toBeDefined();
expect(p!.deleted_at).not.toBeNull();
});
it('lo toglie da tutti gli elenchi del pannello', () => {
trashPost(db, id);
expect(listAllPosts(db).map((p) => p.slug)).toEqual(['due']);
expect(listByAuthor(db, 1)).toEqual([]);
expect(listTrashed(db).map((p) => p.slug)).toEqual(['uno']);
});
it('lo toglie dal sito pubblico: elenco, recenti, archivio e pagina singola', () => {
trashPost(db, id);
expect(listPublished(db).items.map((p) => p.slug)).toEqual(['due']);
expect(listPublished(db).total).toBe(1);
expect(listPublished(db, { category: 'training' }).total).toBe(1);
expect(listRecent(db).map((p) => p.slug)).toEqual(['due']);
expect(listArchiveMonths(db).reduce((n, m) => n + m.count, 0)).toBe(1);
expect(getPublishedBySlug(db, 'uno')).toBeUndefined();
});
it('il cestino di un autore contiene solo i suoi articoli', () => {
trashPost(db, id);
expect(listTrashed(db, 1).map((p) => p.slug)).toEqual(['uno']);
expect(listTrashed(db, 2)).toEqual([]);
});
it('il ripristino lo rimette dov\'era', () => {
trashPost(db, id);
restorePost(db, id);
expect(getPostById(db, id)!.deleted_at).toBeNull();
expect(listTrashed(db)).toEqual([]);
expect(listAllPosts(db).map((p) => p.slug)).toContain('uno');
expect(getPublishedBySlug(db, 'uno')).toBeDefined();
});
it('i conteggi per categoria escludono cestino e bozze', () => {
expect(countPublishedByCategory(db)).toEqual({ training: 2 });
trashPost(db, id);
expect(countPublishedByCategory(db)).toEqual({ training: 1 });
restorePost(db, id);
expect(countPublishedByCategory(db)).toEqual({ training: 2 });
});
it('la cancellazione definitiva rimuove davvero la riga', () => {
trashPost(db, id);
deletePost(db, id);
expect(getPostById(db, id)).toBeUndefined();
expect(listTrashed(db)).toEqual([]);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { canModifyPost } from '../src/lib/posts';
describe('canModifyPost', () => {
const own = { author_id: 7 };
const other = { author_id: 8 };
const orphan = { author_id: null };
it('admin e superuser modificano qualsiasi post', () => {
for (const role of ['admin', 'superuser']) {
expect(canModifyPost(role, 7, other)).toBe(true);
expect(canModifyPost(role, 7, orphan)).toBe(true);
}
});
it('user modifica solo i propri', () => {
expect(canModifyPost('user', 7, own)).toBe(true);
expect(canModifyPost('user', 7, other)).toBe(false);
expect(canModifyPost('user', 7, orphan)).toBe(false);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import {
createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
listPublished, listAllPosts, listRecent, listArchiveMonths, listByAuthor, type PostInput,
} from '../src/lib/posts';
import { createUser, getUsernameById, listUsers } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
const base: PostInput = {
title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi',
excerpt: 'estratto', body_html: '<p>corpo</p>', cover: null, draft: false,
};
describe('posts repository', () => {
it('crea e rilegge un post pubblicato', () => {
const id = createPost(db, base);
const post = getPostById(db, id)!;
expect(post.title).toBe('Titolo');
expect(post.draft).toBe(0);
expect(post.published_at).not.toBeNull();
});
it('bozza non ha published_at e non appare nelle liste pubbliche', () => {
const id = createPost(db, { ...base, slug: 'bozza', draft: true });
expect(getPostById(db, id)!.published_at).toBeNull();
expect(getPublishedBySlug(db, 'bozza')).toBeUndefined();
expect(listPublished(db).total).toBe(0);
expect(listAllPosts(db)).toHaveLength(1);
});
it('slug duplicato lancia', () => {
createPost(db, base);
expect(() => createPost(db, base)).toThrow();
});
it('update imposta published_at alla prima pubblicazione e lo conserva', () => {
const id = createPost(db, { ...base, draft: true });
updatePost(db, id, { ...base, draft: false });
const first = getPostById(db, id)!.published_at;
expect(first).not.toBeNull();
updatePost(db, id, { ...base, title: 'Nuovo', draft: false });
expect(getPostById(db, id)!.published_at).toBe(first);
expect(getPostById(db, id)!.title).toBe('Nuovo');
});
it('listPublished filtra per categoria e pagina', () => {
for (let i = 0; i < 12; i++) {
createPost(db, { ...base, slug: `post-${i}`, category: i % 2 ? 'Contenuti educativi' : 'Eventi & Presidi' });
}
const all = listPublished(db, { page: 1, perPage: 9 });
expect(all.total).toBe(12);
expect(all.items).toHaveLength(9);
const edu = listPublished(db, { category: 'Contenuti educativi' });
expect(edu.total).toBe(6);
});
it('delete rimuove', () => {
const id = createPost(db, base);
deletePost(db, id);
expect(getPostById(db, id)).toBeUndefined();
});
it('listRecent e archivio mesi', () => {
createPost(db, base);
expect(listRecent(db, 4)).toHaveLength(1);
const months = listArchiveMonths(db);
expect(months).toHaveLength(1);
expect(months[0].count).toBe(1);
expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
});
});
describe('autore articoli', () => {
it('createPost salva author_id e listByAuthor filtra', () => {
const aliceId = createUser(db, 'alice', 'segreta123', 'user');
const bobId = createUser(db, 'bob', 'segreta123', 'user');
createPost(db, { ...base, slug: 'a1', author_id: aliceId });
createPost(db, { ...base, slug: 'a2', author_id: aliceId });
createPost(db, { ...base, slug: 'b1', author_id: bobId });
expect(listByAuthor(db, aliceId).length).toBe(2);
expect(listByAuthor(db, bobId).length).toBe(1);
});
it('post senza author_id ha author_id null', () => {
const id = createPost(db, { ...base, slug: 'orfano' });
expect(getPostById(db, id)!.author_id).toBeNull();
});
it('getUsernameById e listUsers', () => {
const id = createUser(db, 'carla', 'segreta123', 'superuser');
expect(getUsernameById(db, id)).toBe('carla');
expect(getUsernameById(db, 99999)).toBeNull();
expect(listUsers(db).some((u) => u.username === 'carla' && u.role === 'superuser')).toBe(true);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { edizioni, edizioneAttiva, tagEdizione, type Edizione } from '../src/data/promo-edizioni';
import { seedByTag } from '../src/data/content-seed';
const d = (iso: string) => new Date(iso);
describe('edizioneAttiva', () => {
it('sceglie l\'edizione la cui finestra contiene l\'istante', () => {
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'))?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura');
});
it('fuori da ogni finestra non c\'è edizione attiva', () => {
expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull();
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))).toBeNull();
});
// Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora
// italiana. Il server gira in UTC, quindi un confronto fatto sull'orologio locale della
// macchina anticiperebbe (o ritarderebbe) lo scambio di due ore.
it('scambia le edizioni al secondo giusto, in ora italiana', () => {
expect(edizioneAttiva(d('2026-08-19T23:59:59+02:00'))?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-20T00:00:00+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-04T23:59:59+02:00'))?.id).toBe('bundle-apertura');
expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))).toBeNull();
});
it('l\'ordine dell\'elenco non cambia il risultato', () => {
const rovescio = [...edizioni].reverse();
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'), rovescio)?.id).toBe('agosto-2026');
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'), rovescio)?.id).toBe('bundle-apertura');
});
});
describe('coerenza delle edizioni configurate', () => {
const ordinate = [...edizioni].sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
it('ogni finestra è valida e ha un verso', () => {
for (const e of edizioni) {
expect(Number.isNaN(Date.parse(e.from)), `${e.id}: from illeggibile`).toBe(false);
expect(Number.isNaN(Date.parse(e.to)), `${e.id}: to illeggibile`).toBe(false);
expect(Date.parse(e.to), `${e.id}: finisce prima di cominciare`).toBeGreaterThan(Date.parse(e.from));
}
});
// Due promozioni insieme sul sito è il difetto peggiore: prezzi diversi per la stessa cosa,
// e nessuno sa quale vale. Nessun buco, invece, evita il giorno in cui la pagina non offre
// niente senza che nessuno se ne accorga: se un buco è voluto va scritto qui, non subìto.
it('le finestre non si sovrappongono e non lasciano buchi', () => {
for (let i = 1; i < ordinate.length; i++) {
const prima = ordinate[i - 1];
const dopo = ordinate[i];
const stacco = Date.parse(dopo.from) - Date.parse(prima.to);
expect(stacco, `${prima.id} e ${dopo.id} si sovrappongono`).toBeGreaterThan(0);
expect(stacco, `fra ${prima.id} e ${dopo.id} c'è un buco`).toBeLessThanOrEqual(1000);
}
});
// Il countdown è la promessa fatta al visitatore. Se dice una data e lo scambio ne usa
// un'altra, il sito si contraddice da solo: o cambia mentre il contatore corre ancora, o
// resta lì a zero. Sono lo stesso numero e devono restare lo stesso numero.
it('il countdown scade quando finisce l\'edizione', () => {
for (const e of edizioni) {
const deadline = seedByTag.get(tagEdizione(e, 'countdown.deadline'))?.value;
expect(deadline, `${e.id}: manca il tag della deadline`).toBeTruthy();
expect(Date.parse(deadline!), `${e.id}: countdown e fine edizione non coincidono`)
.toBe(Date.parse(e.to));
}
});
});
describe('i testi di ogni edizione esistono', () => {
// Un tag che manca non rompe la pagina: `resolve` restituisce stringa vuota e logga un warn
// che nessuno legge. In vetrina diventa una card senza prezzo, o un titolo sparito. Meglio
// scoprirlo qui che dal cliente.
const richiesti = (e: Edizione): string[] => {
const base = ['hero.badge', 'hero.title1', 'hero.accent', 'hero.title2', 'hero.desc', 'hero.cta',
'buy-label', 'validity-note', 'faq.title',
'countdown.label', 'countdown.deadline',
'countdown.unit.days', 'countdown.unit.hours', 'countdown.unit.minutes', 'countdown.unit.seconds'];
if (e.launch) base.push('launch.badge', 'launch.title', 'launch.accent', 'launch.desc', 'why5.text');
for (const c of e.cards) {
base.push(`card.${c.id}.eyebrow`, `card.${c.id}.title`, `card.${c.id}.price`, `card.${c.id}.price-old`);
if (c.featured) base.push(`card.${c.id}.badge`);
if (c.unit) base.push(`card.${c.id}.unit`);
if (c.question) base.push(`card.${c.id}.question`);
if (c.desc) base.push(`card.${c.id}.desc`);
if (c.note) base.push(`card.${c.id}.note-label`, `card.${c.id}.note`);
for (let i = 1; i <= c.features; i++) base.push(`card.${c.id}.feature.${i}`);
}
for (const n of e.faq) base.push(`faq.${n}.q`, `faq.${n}.a`);
return base.map((s) => tagEdizione(e, s));
};
for (const e of edizioni) {
it(`${e.id}: tutti i tag sono nel seed`, () => {
const mancanti = richiesti(e).filter((tag) => !seedByTag.has(tag));
expect(mancanti, `tag mancanti: ${mancanti.join(', ')}`).toEqual([]);
});
}
it('lo stato "nessuna promozione" ha i suoi testi', () => {
for (const tag of ['promo.chiusa.title', 'promo.chiusa.desc', 'promo.chiusa.cta']) {
expect(seedByTag.has(tag), `manca ${tag}`).toBe(true);
}
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { rateLimit, _resetBuckets } from '../src/lib/rate-limit';
afterEach(() => { _resetBuckets(); vi.useRealTimers(); });
describe('rateLimit', () => {
it('consente fino a max richieste', () => {
expect(rateLimit('a', 2, 1000)).toBe(true);
expect(rateLimit('a', 2, 1000)).toBe(true);
expect(rateLimit('a', 2, 1000)).toBe(false);
});
it('chiavi indipendenti', () => {
expect(rateLimit('a', 1, 1000)).toBe(true);
expect(rateLimit('b', 1, 1000)).toBe(true);
});
it('la finestra scade', () => {
vi.useFakeTimers();
expect(rateLimit('a', 1, 1000)).toBe(true);
expect(rateLimit('a', 1, 1000)).toBe(false);
vi.advanceTimersByTime(1100);
expect(rateLimit('a', 1, 1000)).toBe(true);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { createUser, login, getSessionUser, canAccessAdminPath, landingFor } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('ruoli', () => {
it('createUser accetta i tre ruoli e getSessionUser li ritorna', () => {
createUser(db, 'u', 'segreta123', 'user');
createUser(db, 's', 'segreta123', 'superuser');
expect(getSessionUser(db, login(db, 'u', 'segreta123')!)).toMatchObject({ role: 'user' });
expect(getSessionUser(db, login(db, 's', 'segreta123')!)).toMatchObject({ role: 'superuser' });
});
it('createUser senza ruolo crea admin', () => {
createUser(db, 'adriano', 'segreta123');
expect(getSessionUser(db, login(db, 'adriano', 'segreta123')!)).toMatchObject({ role: 'admin' });
});
});
describe('canAccessAdminPath', () => {
it('admin accede a tutto', () => {
for (const p of ['/admin', '/admin/users', '/admin/content', '/api/admin/posts', '/api/admin/users/1'])
expect(canAccessAdminPath('admin', p)).toBe(true);
});
it('superuser: contenuti + blog sì, utenti no', () => {
expect(canAccessAdminPath('superuser', '/admin/content')).toBe(true);
expect(canAccessAdminPath('superuser', '/admin/content/')).toBe(true);
expect(canAccessAdminPath('superuser', '/api/admin/content/home.hero.title')).toBe(true);
expect(canAccessAdminPath('superuser', '/admin')).toBe(true);
expect(canAccessAdminPath('superuser', '/admin/new')).toBe(true);
expect(canAccessAdminPath('superuser', '/api/admin/posts')).toBe(true);
expect(canAccessAdminPath('superuser', '/admin/users')).toBe(false);
expect(canAccessAdminPath('superuser', '/api/admin/users/1')).toBe(false);
});
it('user: blog sì, contenuti e utenti no', () => {
expect(canAccessAdminPath('user', '/admin')).toBe(true);
expect(canAccessAdminPath('user', '/admin/new')).toBe(true);
expect(canAccessAdminPath('user', '/api/admin/posts')).toBe(true);
expect(canAccessAdminPath('user', '/api/admin/upload')).toBe(true);
expect(canAccessAdminPath('user', '/admin/logout')).toBe(true);
expect(canAccessAdminPath('user', '/admin/content')).toBe(false);
expect(canAccessAdminPath('user', '/api/admin/content/x')).toBe(false);
expect(canAccessAdminPath('user', '/admin/users')).toBe(false);
});
it('la matrice non confonde prefissi simili', () => {
expect(canAccessAdminPath('user', '/admin/contentious')).toBe(true); // NON è /admin/content
expect(canAccessAdminPath('superuser', '/admin/users-x')).toBe(true); // NON è /admin/users
});
});
describe('landingFor', () => {
it('instrada ogni ruolo alla sua home', () => {
expect(landingFor('superuser')).toBe('/admin/content');
expect(landingFor('user')).toBe('/admin');
expect(landingFor('admin')).toBe('/admin');
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { safeNext } from '../src/lib/safe-next';
describe('safeNext', () => {
it('accetta path interni', () => {
expect(safeNext('/about')).toBe('/about');
expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1');
});
it('rifiuta esterni, protocol-relative, api e vuoti', () => {
expect(safeNext(null)).toBe('/');
expect(safeNext('')).toBe('/');
expect(safeNext('//evil.com')).toBe('/');
expect(safeNext('https://evil.com')).toBe('/');
expect(safeNext('/api/admin/posts')).toBe('/');
expect(safeNext('javascript:alert(1)')).toBe('/');
expect(safeNext('not-a-path')).toBe('/');
expect(safeNext('/\\evil.com')).toBe('/');
expect(safeNext('/\\/evil.com')).toBe('/');
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { sanitizeHtml } from '../src/lib/sanitize';
describe('sanitizeHtml', () => {
it('mantiene la formattazione consentita', () => {
const html = '<h2>Titolo</h2><p>Testo <strong>forte</strong> ed <em>enfasi</em></p><ul><li>voce</li></ul><blockquote>citazione</blockquote>';
expect(sanitizeHtml(html)).toBe(html);
});
it('rimuove script e handler', () => {
expect(sanitizeHtml('<p onclick="x()">ciao</p><script>alert(1)</script>')).toBe('<p>ciao</p>');
});
it('consente immagini locali e https, blocca javascript:', () => {
expect(sanitizeHtml('<img src="/uploads/a.jpg">')).toContain('/uploads/a.jpg');
expect(sanitizeHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { slugify } from '../src/lib/slug';
describe('slugify', () => {
it('minuscole e trattini', () => {
expect(slugify('Il Metodo InsanityLab')).toBe('il-metodo-insanitylab');
});
it('rimuove accenti', () => {
expect(slugify('Attività fisica è già qui')).toBe('attivita-fisica-e-gia-qui');
});
it('comprime simboli e spazi multipli', () => {
expect(slugify('Blog & Edugo -- news!!')).toBe('blog-edugo-news');
});
it('niente trattini ai bordi', () => {
expect(slugify(' ciao ')).toBe('ciao');
});
it('stringa vuota resta vuota', () => {
expect(slugify('')).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);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { validateStyles, stylesToClasses, STYLE_GROUPS } from '../src/lib/style-presets';
describe('style presets', () => {
it('accetta chiavi e valori in whitelist', () => {
const r = validateStyles({ size: 'l', color: 'accent', align: 'center' });
expect(r).toEqual({ ok: true, styles: { size: 'l', color: 'accent', align: 'center' } });
});
it('rifiuta gruppo sconosciuto e valore fuori lista', () => {
expect(validateStyles({ font: 'comic' }).ok).toBe(false);
expect(validateStyles({ size: 'xxxl' }).ok).toBe(false);
expect(validateStyles('non-oggetto').ok).toBe(false);
});
it('mappa stili in classi cs-*', () => {
expect(stylesToClasses({ size: 's', weight: 'bold' })).toBe('cs-size-s cs-weight-bold');
expect(stylesToClasses({})).toBe('');
});
it('espone i gruppi attesi', () => {
expect(Object.keys(STYLE_GROUPS)).toEqual(['size', 'color', 'weight', 'style', 'align']);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import {
createUser, countAdmins, getUserById, updateUserRole,
updateUserPassword, deleteUser, randomPassword, isRole, hashPassword, verifyPassword,
} from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('gestione utenti', () => {
it('countAdmins conta i soli admin', () => {
createUser(db, 'a1', 'segreta123', 'admin');
createUser(db, 's1', 'segreta123', 'superuser');
expect(countAdmins(db)).toBe(1);
});
it('updateUserRole e getUserById', () => {
const id = createUser(db, 'u1', 'segreta123', 'user');
updateUserRole(db, id, 'superuser');
expect(getUserById(db, id)!.role).toBe('superuser');
});
it('updateUserPassword cambia lhash', () => {
const id = createUser(db, 'u2', 'vecchia123', 'user');
updateUserPassword(db, id, hashPassword('nuova12345'));
const hash = (db.prepare('SELECT password_hash AS h FROM users WHERE id = ?').get(id) as { h: string }).h;
expect(verifyPassword('nuova12345', hash)).toBe(true);
});
it('deleteUser rimuove', () => {
const id = createUser(db, 'u3', 'segreta123', 'user');
deleteUser(db, id);
expect(getUserById(db, id)).toBeNull();
});
it('randomPassword genera almeno 12 caratteri', () => {
expect(randomPassword().length).toBeGreaterThanOrEqual(12);
});
it('isRole valida i ruoli', () => {
expect(isRole('admin')).toBe(true);
expect(isRole('superuser')).toBe(true);
expect(isRole('user')).toBe(true);
expect(isRole('editor')).toBe(false);
expect(isRole(3)).toBe(false);
});
});