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:
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
|
||||
describe('auth', () => {
|
||||
it('hash e verifica password', () => {
|
||||
const h = hashPassword('segreta123');
|
||||
expect(h).not.toBe('segreta123');
|
||||
expect(verifyPassword('segreta123', h)).toBe(true);
|
||||
expect(verifyPassword('sbagliata', h)).toBe(false);
|
||||
});
|
||||
|
||||
it('login corretto crea sessione recuperabile', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
const token = login(db, 'adriano', 'segreta123');
|
||||
expect(token).toBeTruthy();
|
||||
const user = getSessionUser(db, token!);
|
||||
expect(user).toMatchObject({ username: 'adriano' });
|
||||
});
|
||||
|
||||
it('login errato ritorna null', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
expect(login(db, 'adriano', 'sbagliata')).toBeNull();
|
||||
expect(login(db, 'inesistente', 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('sessione scaduta viene rifiutata ed eliminata', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
const token = login(db, 'adriano', 'segreta123')!;
|
||||
db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run();
|
||||
expect(getSessionUser(db, token)).toBeNull();
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 });
|
||||
});
|
||||
|
||||
it('logout elimina la sessione', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
const token = login(db, 'adriano', 'segreta123')!;
|
||||
logout(db, token);
|
||||
expect(getSessionUser(db, token)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getNav, findArea, findLesson, chapterOf, readLessonHtml, resolveAsset } from '../src/lib/campus';
|
||||
|
||||
// I test girano sui contenuti reali generati da `npm run sync-campus`.
|
||||
const nav = getNav();
|
||||
|
||||
describe('getNav', () => {
|
||||
it('carica aree e capitoli dai contenuti generati', () => {
|
||||
expect(nav).not.toBeNull();
|
||||
expect(nav!.count).toBeGreaterThan(100);
|
||||
expect(nav!.areas.map((a) => a.slug)).toEqual(
|
||||
['corso-base', 'slide', 'live-e-ospiti', 'consulenze-masterclass', 'qa']);
|
||||
for (const a of nav!.areas) expect(a.chapters.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookup', () => {
|
||||
it('trova area e guida, e il capitolo della guida', () => {
|
||||
const area = findArea(nav!, 'corso-base');
|
||||
expect(area?.title).toBe('Corso base');
|
||||
const l = findLesson(nav!, 'lezione-01-ottimizzazione-del-sonno');
|
||||
expect(l).toMatchObject({ area: 'corso-base', chapter: 1, label: 'L1' });
|
||||
expect(chapterOf(nav!, l!)?.title).toBe('Sonno e ritmi');
|
||||
});
|
||||
|
||||
it('ritorna null per slug inesistenti', () => {
|
||||
expect(findArea(nav!, 'inventata')).toBeNull();
|
||||
expect(findLesson(nav!, 'lezione-99-inventata')).toBeNull();
|
||||
});
|
||||
|
||||
it('la catena prev/next collega tutte le guide in ordine', () => {
|
||||
const all = nav!.areas.flatMap((a) => a.chapters.flatMap((c) => c.lessons));
|
||||
expect(all[0].prev).toBeNull();
|
||||
expect(all.at(-1)!.next).toBeNull();
|
||||
for (let i = 0; i < all.length - 1; i++) expect(all[i].next?.slug).toBe(all[i + 1].slug);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLessonHtml', () => {
|
||||
it('legge il frammento di una guida esistente', () => {
|
||||
const html = readLessonHtml('lezione-01-ottimizzazione-del-sonno');
|
||||
expect(html).toContain('<h2');
|
||||
expect(html).not.toContain('Guida allo studio</h1>');
|
||||
});
|
||||
|
||||
it('ritorna null per una guida inesistente', () => {
|
||||
expect(readLessonHtml('non-esiste')).toBeNull();
|
||||
});
|
||||
|
||||
it('le immagini puntano alla route protetta', () => {
|
||||
const html = readLessonHtml('slide-hrv')!;
|
||||
expect(html).toContain('src="/campus/assets/');
|
||||
expect(html).not.toContain('src="assets/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAsset', () => {
|
||||
it('risolve un asset esistente', () => {
|
||||
expect(resolveAsset('slide/slide-hrv')).toMatch(/campus-content\/assets\/slide\/slide-hrv$/);
|
||||
});
|
||||
|
||||
it('rifiuta i path traversal e i path assoluti', () => {
|
||||
expect(resolveAsset('../pages/lezione-01-ottimizzazione-del-sonno.html')).toBeNull();
|
||||
expect(resolveAsset('../../package.json')).toBeNull();
|
||||
expect(resolveAsset('/etc/passwd')).toBeNull();
|
||||
});
|
||||
|
||||
it('ritorna null per un asset inesistente', () => {
|
||||
expect(resolveAsset('slide/non-esiste.png')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseGuide, kindOf, rewriteAssets } from '../scripts/campus/parse.mjs';
|
||||
import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs';
|
||||
|
||||
const body = `
|
||||
> Sintesi di studio basata sulla trascrizione della lezione del corso.
|
||||
|
||||
## In sintesi
|
||||
|
||||
Testo di prova.
|
||||
|
||||
## Concetti chiave
|
||||
|
||||
- **Il sonno è il pilastro.** Spiegazione.
|
||||
- **Ritmi circadiani.** Altra spiegazione.
|
||||
`;
|
||||
|
||||
describe('kindOf / sourceDirFor', () => {
|
||||
it('riconosce il tipo dallo slug', () => {
|
||||
expect(kindOf('lezione-01-ottimizzazione-del-sonno')).toBe('lezione');
|
||||
expect(kindOf('lezione-live-1-2026-02-24')).toBe('live');
|
||||
expect(kindOf('accelerator-1-2025-11-11')).toBe('accelerator');
|
||||
expect(kindOf('slide-hrv')).toBe('slide');
|
||||
expect(kindOf('speaker-resta-marketing')).toBe('speaker');
|
||||
expect(kindOf('consulenza-2025-02-04-cristina-ruggeri')).toBe('consulenza');
|
||||
expect(kindOf('masterclass-2025-10-08-luca-ruggeri')).toBe('masterclass');
|
||||
expect(kindOf('qa-01-tony-04')).toBe('qa');
|
||||
});
|
||||
|
||||
it('mappa lo slug alla cartella sorgente', () => {
|
||||
expect(sourceDirFor('lezione-01-ottimizzazione-del-sonno')).toBe('studio');
|
||||
expect(sourceDirFor('lezione-live-1-2026-02-24')).toBe('lezioni-live');
|
||||
expect(sourceDirFor('qa-01-tony-04')).toBe('qa');
|
||||
expect(sourceDirFor('consulenza-2025-02-04-cristina-ruggeri')).toBe('consulenze');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGuide', () => {
|
||||
it('lezione numerata con parte e relatore', () => {
|
||||
const raw = '# Lezione 2 — Movimento e fitness (Parte 1) (Tony Manfron) — Guida allo studio' + body;
|
||||
const g = parseGuide(raw, 'lezione-02-movimento-fitness-parte1', 1);
|
||||
expect(g).toMatchObject({
|
||||
kind: 'lezione', num: 2, relatore: 'Tony Manfron', parte: 'Parte 1',
|
||||
titleFull: 'Movimento e fitness (Parte 1)', titleShort: 'Movimento e fitness',
|
||||
label: 'L2 · Parte 1',
|
||||
});
|
||||
});
|
||||
|
||||
it('lezione live e accelerator usano la data come titolo', () => {
|
||||
const live = parseGuide(
|
||||
'# Lezione live 1 — 24 febbraio 2026 (Massimo Filippi) — Guida allo studio' + body,
|
||||
'lezione-live-1-2026-02-24', 1);
|
||||
expect(live).toMatchObject({
|
||||
kind: 'live', num: 1, titleFull: 'Lezione live 1 — 24 febbraio 2026',
|
||||
titleShort: 'Lezione live 1', relatore: 'Massimo Filippi · 24 febbraio 2026', label: 'LV1',
|
||||
});
|
||||
|
||||
const acc = parseGuide(
|
||||
'# Accelerator 3 — 28 gennaio 2026 (Massimo Filippi) — Guida allo studio' + body,
|
||||
'accelerator-3-2026-01-28', 3);
|
||||
expect(acc).toMatchObject({ kind: 'accelerator', num: 3, titleShort: 'Accelerator 3', label: 'A3' });
|
||||
});
|
||||
|
||||
it('slide, speaker, consulenza, masterclass e Q&A', () => {
|
||||
expect(parseGuide('# Slide — HRV: Variabilità della frequenza cardiaca — Guida allo studio' + body, 'slide-hrv', 1))
|
||||
.toMatchObject({ kind: 'slide', num: 1, titleFull: 'HRV: Variabilità della frequenza cardiaca', label: 'SL1' });
|
||||
|
||||
expect(parseGuide('# Intervento — Marketing nel BioHacking (Roberto Resta) — Guida allo studio' + body, 'speaker-resta-marketing', 5))
|
||||
.toMatchObject({ kind: 'speaker', titleFull: 'Marketing nel BioHacking', relatore: 'Roberto Resta', label: 'SP5' });
|
||||
|
||||
expect(parseGuide('# Consulenza — Cristina Ruggeri (04/02/2025) — Guida allo studio' + body, 'consulenza-2025-02-04-cristina-ruggeri', 1))
|
||||
.toMatchObject({ kind: 'consulenza', titleFull: 'Cristina Ruggeri', relatore: '04/02/2025', label: 'C1' });
|
||||
|
||||
expect(parseGuide('# Masterclass — Luca Ruggeri (08/10/2025) — Guida allo studio' + body, 'masterclass-2025-10-08-luca-ruggeri', 1))
|
||||
.toMatchObject({ kind: 'masterclass', titleFull: 'Luca Ruggeri', relatore: '08/10/2025', label: 'MC1' });
|
||||
|
||||
expect(parseGuide('# Q&A Tony n°4 — Guida allo studio' + body, 'qa-01-tony-04', 1))
|
||||
.toMatchObject({ kind: 'qa', titleFull: 'Q&A Tony n°4', label: 'QA1' });
|
||||
});
|
||||
|
||||
it('estrae i concetti chiave in grassetto e rimuove H1 e blockquote', () => {
|
||||
const g = parseGuide('# Lezione 1 — Sonno (Tony Manfron) — Guida allo studio' + body,
|
||||
'lezione-01-ottimizzazione-del-sonno', 1);
|
||||
expect(g.concetti).toEqual(['Il sonno è il pilastro', 'Ritmi circadiani']);
|
||||
expect(g.html).not.toContain('Guida allo studio');
|
||||
expect(g.html).not.toContain('Sintesi di studio basata');
|
||||
expect(g.html).toContain('<h2');
|
||||
expect(g.html).toContain('Testo di prova');
|
||||
});
|
||||
|
||||
it('rifiuta un H1 non riconosciuto', () => {
|
||||
expect(() => parseGuide('# Titolo qualunque\n\ntesto', 'lezione-99-x', 1)).toThrow(/H1 non riconosciuto/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteAssets', () => {
|
||||
it('riscrive i path relativi delle immagini verso la route protetta', () => {
|
||||
const html = '<img src="assets/slide/slide-hrv/fig-01.png" alt="x"><img src="/altro.png">';
|
||||
expect(rewriteAssets(html)).toBe(
|
||||
'<img src="/campus/assets/slide/slide-hrv/fig-01.png" alt="x"><img src="/altro.png">');
|
||||
});
|
||||
});
|
||||
|
||||
describe('struttura del corso', () => {
|
||||
const chapterNums = CHAPTERS.map((c) => c[0] as number);
|
||||
|
||||
it('ogni capitolo ha un colore e ogni area capitoli esistenti', () => {
|
||||
expect(CH_COLORS.length).toBe(CHAPTERS.length);
|
||||
for (const area of AREAS)
|
||||
for (const n of area[4] as number[]) expect(chapterNums).toContain(n);
|
||||
});
|
||||
|
||||
it('nessuno slug duplicato e ogni capitolo è in una sola area', () => {
|
||||
const slugs = CHAPTERS.flatMap((c) => c[3] as string[]);
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
const assigned = AREAS.flatMap((a) => a[4] as number[]);
|
||||
expect(new Set(assigned).size).toBe(assigned.length);
|
||||
expect(assigned.sort((a, b) => a - b)).toEqual(chapterNums);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb, createIdentityDb, COPERTURA_MINIMA } from '../../src/lib/longevity/db';
|
||||
|
||||
describe('schema longevity', () => {
|
||||
it('crea le tabelle previste dalla spec', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
const nomi = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
|
||||
.map((r) => r.name);
|
||||
expect(nomi).toEqual(
|
||||
expect.arrayContaining(['soggetti', 'sessioni', 'misure', 'registro_test', 'pesi', 'profilo_note', 'score'])
|
||||
);
|
||||
});
|
||||
|
||||
it('rifiuta un sesso non previsto', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
expect(() =>
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'X')`).run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rifiuta una misura con test_id non nel registro', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
|
||||
expect(() =>
|
||||
db.prepare(
|
||||
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte)
|
||||
VALUES (1, 'ISL-0001', 'test_inventato', 10, 'manuale')`
|
||||
).run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('uno score insufficiente puo non avere valore', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
|
||||
db.prepare(
|
||||
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
|
||||
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')`
|
||||
).run();
|
||||
const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string };
|
||||
expect(row.valore).toBeNull();
|
||||
expect(row.stato).toBe('insufficiente');
|
||||
});
|
||||
|
||||
it('la tabella rifiuta uno score insufficiente CON un valore scritto', () => {
|
||||
// Il tipo Punteggio protegge il codice, ma l'interfaccia legge dalla tabella,
|
||||
// dove il tipo non c'e' piu': senza questo vincolo si potrebbe scrivere un
|
||||
// numero dichiarato "insufficiente" e chi legge crud dalla tabella lo prenderebbe.
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
|
||||
expect(() =>
|
||||
db.prepare(
|
||||
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
|
||||
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', 42, 0.2, 'insufficiente', 'v1.0')`
|
||||
).run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('la tabella rifiuta uno score ok SENZA valore', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
|
||||
expect(() =>
|
||||
db.prepare(
|
||||
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version)
|
||||
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 1, 'ok', 'v1.0')`
|
||||
).run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('identity tiene la persona, longevity non la conosce', () => {
|
||||
const id = createIdentityDb(':memory:');
|
||||
const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name);
|
||||
expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita']));
|
||||
|
||||
const lg = createLongevityDb(':memory:');
|
||||
const tutte = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
|
||||
.flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name));
|
||||
expect(tutte).not.toContain('nome');
|
||||
expect(tutte).not.toContain('cognome');
|
||||
expect(tutte).not.toContain('data_nascita');
|
||||
});
|
||||
|
||||
it('la soglia di copertura e 0.40', () => {
|
||||
expect(COPERTURA_MINIMA).toBe(0.4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro } from '../../src/lib/longevity/registro';
|
||||
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
||||
import { esportaMisure, esportaCsv } from '../../src/lib/longevity/export';
|
||||
|
||||
function dbConDati() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-21', eta: 35,
|
||||
risposte: { q_ore_sonno: 7, q_riposato: 8 },
|
||||
liberi: { farmaci: 'nessuno' },
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('export per le statistiche', () => {
|
||||
it('esporta una riga per misura con codice, non con nome', () => {
|
||||
const db = dbConDati();
|
||||
const out = esportaMisure(db);
|
||||
expect(out.intestazioni).toEqual(
|
||||
['client_code', 'sesso', 'eta', 'data', 'tipo_sessione', 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range']
|
||||
);
|
||||
expect(out.righe.length).toBe(2);
|
||||
expect(out.righe[0][0]).toBe('ISL-0001');
|
||||
});
|
||||
|
||||
it('l export non contiene campi identificativi', () => {
|
||||
const db = dbConDati();
|
||||
const testo = JSON.stringify(esportaMisure(db));
|
||||
for (const vietato of ['nome', 'cognome', 'email', 'telefono', 'data_nascita']) {
|
||||
expect(testo).not.toContain(vietato);
|
||||
}
|
||||
});
|
||||
|
||||
it('i campi liberi restano fuori dall export statistico', () => {
|
||||
const db = dbConDati();
|
||||
expect(JSON.stringify(esportaMisure(db))).not.toContain('nessuno');
|
||||
});
|
||||
|
||||
it('produce un CSV con intestazione e una riga per misura', () => {
|
||||
const db = dbConDati();
|
||||
const righe = esportaCsv(db).trim().split('\n');
|
||||
expect(righe.length).toBe(3); // intestazione + 2 misure
|
||||
expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range');
|
||||
});
|
||||
|
||||
it('ogni valore sta sotto l intestazione giusta, colonna per colonna (asserzione posizionale)', () => {
|
||||
// Non un indice scritto a mano: l'indice si cerca per NOME nell'intestazione prodotta.
|
||||
// Se un domani la SELECT in export.ts viene riordinata senza riallineare INTESTAZIONI,
|
||||
// il valore letto a quell'indice smette di corrispondere e l'asserzione fallisce -
|
||||
// invece di restare verde con l'età etichettata come sesso.
|
||||
const db = dbConDati();
|
||||
const { intestazioni, righe } = esportaMisure(db);
|
||||
const idx = (col: string) => {
|
||||
const i = intestazioni.indexOf(col);
|
||||
if (i < 0) throw new Error(`intestazione mancante: ${col}`);
|
||||
return i;
|
||||
};
|
||||
|
||||
const rigaSonno = righe.find((r) => r[idx('test_id')] === 'q_ore_sonno')!;
|
||||
expect(rigaSonno).toBeDefined();
|
||||
expect(rigaSonno[idx('client_code')]).toBe('ISL-0001');
|
||||
expect(rigaSonno[idx('sesso')]).toBe('F');
|
||||
expect(rigaSonno[idx('eta')]).toBe(35);
|
||||
expect(rigaSonno[idx('data')]).toBe('2026-08-21');
|
||||
expect(rigaSonno[idx('tipo_sessione')]).toBe('questionario');
|
||||
expect(rigaSonno[idx('quest_version')]).toBe('v1.0');
|
||||
expect(rigaSonno[idx('valore')]).toBe(7);
|
||||
// salvaCompilazione non passa 'unita' per le risposte del questionario: resta null.
|
||||
expect(rigaSonno[idx('unita')]).toBeNull();
|
||||
expect(rigaSonno[idx('fonte')]).toBe('questionario');
|
||||
expect(rigaSonno[idx('fuori_range')]).toBe(0);
|
||||
|
||||
const rigaRiposato = righe.find((r) => r[idx('test_id')] === 'q_riposato')!;
|
||||
expect(rigaRiposato).toBeDefined();
|
||||
expect(rigaRiposato[idx('client_code')]).toBe('ISL-0001');
|
||||
expect(rigaRiposato[idx('sesso')]).toBe('F');
|
||||
expect(rigaRiposato[idx('eta')]).toBe(35);
|
||||
expect(rigaRiposato[idx('valore')]).toBe(8);
|
||||
expect(rigaRiposato[idx('unita')]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formattaNumero } from '../../src/lib/longevity/formato';
|
||||
|
||||
describe('formattaNumero — i numeri del referto in italiano', () => {
|
||||
it('un decimale usa la virgola, non il punto', () => {
|
||||
expect(formattaNumero(72.5)).toBe('72,5');
|
||||
});
|
||||
|
||||
it('un intero resta senza decimali spuri', () => {
|
||||
expect(formattaNumero(80)).toBe('80');
|
||||
});
|
||||
|
||||
it('un negativo (possibile solo per differenze, non per un punteggio 0-100) mantiene il segno', () => {
|
||||
expect(formattaNumero(-3.2)).toBe('-3,2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro } from '../../src/lib/longevity/registro';
|
||||
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
|
||||
|
||||
function dbPronto() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('registrazione delle misure', () => {
|
||||
it('scrive le misure di una sessione', () => {
|
||||
const db = dbPronto();
|
||||
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' });
|
||||
const esito = registraMisure(db, s, 'questionario', [
|
||||
{ test_id: 'q_ore_sonno', valore_num: 7.5 },
|
||||
{ test_id: 'q_riposato', valore_num: 8 },
|
||||
]);
|
||||
expect(esito.scritte).toBe(2);
|
||||
const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ?`).get(s) as { n: number };
|
||||
expect(n.n).toBe(2);
|
||||
});
|
||||
|
||||
it('rifiuta un test sconosciuto e non scrive niente del lotto', () => {
|
||||
const db = dbPronto();
|
||||
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'checkup' });
|
||||
expect(() =>
|
||||
registraMisure(db, s, 'manuale', [
|
||||
{ test_id: 'q_ore_sonno', valore_num: 7 },
|
||||
{ test_id: 'test_inventato', valore_num: 1 },
|
||||
])
|
||||
).toThrow(/test_inventato/);
|
||||
const n = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
|
||||
expect(n.n).toBe(0);
|
||||
});
|
||||
|
||||
it('un valore fuori dal range atteso si scrive e si marca', () => {
|
||||
const db = dbPronto();
|
||||
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario' });
|
||||
const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]);
|
||||
expect(esito.fuoriRange).toEqual(['q_ore_sonno']);
|
||||
const row = db.prepare(`SELECT valore_num, fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { valore_num: number; fuori_range: number };
|
||||
expect(row.fuori_range).toBe(1);
|
||||
expect(row.valore_num).toBe(26);
|
||||
});
|
||||
|
||||
it('la sessione porta la versione del questionario', () => {
|
||||
const db = dbPronto();
|
||||
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' });
|
||||
const row = db.prepare(`SELECT quest_version FROM sessioni WHERE id = ?`).get(s) as { quest_version: string };
|
||||
expect(row.quest_version).toBe('v1.0');
|
||||
});
|
||||
|
||||
it('il client_code della misura viene dalla sessione, non da chi chiama', () => {
|
||||
const db = dbPronto();
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run();
|
||||
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-21', tipo: 'checkup' });
|
||||
registraMisure(db, s, 'manuale', [{ test_id: 'q_riposato', valore_num: 5 }]);
|
||||
const row = db.prepare(`SELECT client_code FROM misure`).get() as { client_code: string };
|
||||
expect(row.client_code).toBe('ISL-0002');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// tests/longevity/motore-cascata.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { COPERTURA_MINIMA } from '../../src/lib/longevity/db';
|
||||
import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata';
|
||||
|
||||
describe('aggregazione e rinormalizzazione', () => {
|
||||
it('con tutti i dati fa la media pesata', () => {
|
||||
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]);
|
||||
expect(r.stato).toBe('ok');
|
||||
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); }
|
||||
});
|
||||
|
||||
it('un dato mancante ridistribuisce il suo peso, non vale zero', () => {
|
||||
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]);
|
||||
expect(r.stato).toBe('ok');
|
||||
// 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55
|
||||
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); }
|
||||
});
|
||||
|
||||
it('sotto la soglia di copertura NON esiste un valore da leggere', () => {
|
||||
const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]);
|
||||
expect(r.stato).toBe('insufficiente');
|
||||
expect(r.copertura).toBeCloseTo(0.2, 2);
|
||||
// il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero
|
||||
expect((r as { valore?: number }).valore).toBeUndefined();
|
||||
});
|
||||
|
||||
it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => {
|
||||
const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]);
|
||||
const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]);
|
||||
expect(pocoSotto.stato).toBe('insufficiente');
|
||||
expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo
|
||||
});
|
||||
|
||||
it('senza nessun dato e insufficiente con copertura zero', () => {
|
||||
const r = aggrega([{ punteggio: null, peso: 1 }]);
|
||||
expect(r.stato).toBe('insufficiente');
|
||||
expect(r.copertura).toBe(0);
|
||||
});
|
||||
|
||||
// Bug trovato in revisione: il confronto con la soglia usava la copertura
|
||||
// GIA' arrotondata a due decimali, non quella grezza. Una copertura grezza
|
||||
// di 0.396 arrotonda a 0.40 - uguale alla soglia - e con l'arrotondamento
|
||||
// fatto PRIMA del confronto passava per 'ok'. Verificato anche contro
|
||||
// l'oracolo Python: aggregate([WeightedScore(90, 0.396), WeightedScore(None,
|
||||
// 0.604)]) -> (90.0, 0.4, 'insufficiente').
|
||||
it('una copertura grezza appena sotto la soglia resta insufficiente anche se arrotonda a 0.40', () => {
|
||||
const r = aggrega([{ punteggio: 90, peso: 0.396 }, { punteggio: null, peso: 0.604 }]);
|
||||
expect(r.stato).toBe('insufficiente');
|
||||
expect(r.copertura).toBeCloseTo(0.40, 2); // il valore esposto arrotonda a 0.40, ma lo stato resta insufficiente
|
||||
});
|
||||
|
||||
// Simmetrico: una copertura grezza appena SOPRA la soglia (0.404, che
|
||||
// arrotonda anch'essa a 0.40) deve restare 'ok' - il fix non deve aver
|
||||
// spostato la soglia dall'altra parte. Oracolo: aggregate([WeightedScore(90,
|
||||
// 0.404), WeightedScore(None, 0.596)]) -> (90.0, 0.4, 'ok').
|
||||
it('una copertura grezza appena sopra la soglia resta ok: il fix non ha spostato la soglia', () => {
|
||||
const r = aggrega([{ punteggio: 90, peso: 0.404 }, { punteggio: null, peso: 0.596 }]);
|
||||
expect(r.stato).toBe('ok');
|
||||
if (r.stato === 'ok') expect(r.copertura).toBeCloseTo(0.40, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assi, macro e Fitness Age', () => {
|
||||
const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 };
|
||||
|
||||
it('un asse si calcola sui suoi sotto-domini', () => {
|
||||
const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 });
|
||||
expect(r.stato).toBe('ok');
|
||||
// 70*.25 + 60*.2 + 65*.2 + 80*.2 + 50*.15 = 66.0 (verificato anche a mano, non 66.25 come
|
||||
// nel brief: la costante li' e' un refuso, corretto qui dopo averlo verificato col conto)
|
||||
if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.0, 1);
|
||||
});
|
||||
|
||||
it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => {
|
||||
const assi = {
|
||||
A: { stato: 'ok', valore: 80, copertura: 1 } as const,
|
||||
B: { stato: 'insufficiente', copertura: 0.1 } as const,
|
||||
};
|
||||
const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi);
|
||||
expect(r.stato).toBe('ok');
|
||||
// se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude
|
||||
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); }
|
||||
});
|
||||
|
||||
it('la Fitness Age scende sotto l eta quando il composito supera 50', () => {
|
||||
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
|
||||
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 });
|
||||
// 40 - (75 - 50) * 0.4 = 30
|
||||
expect(r.fitnessAge).toBeCloseTo(30, 1);
|
||||
});
|
||||
|
||||
it('senza dati sufficienti la Fitness Age non esiste', () => {
|
||||
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
|
||||
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null });
|
||||
expect(r.composito.stato).toBe('insufficiente');
|
||||
expect(r.fitnessAge).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
|
||||
import { calcolaSessione } from '../../src/lib/longevity/motore';
|
||||
|
||||
/**
|
||||
* Riproduce la situazione descritta nella documentazione del cliente: una persona di cui
|
||||
* si conosce solo la composizione corporea, misurata dalla Wellness Tower. Un asse pieno,
|
||||
* tutti gli altri scoperti. È il caso in cui un motore ingenuo mostrerebbe sei zeri.
|
||||
* Dati sintetici: nessuna persona reale.
|
||||
*/
|
||||
describe('un profilo con una sola area misurata', () => {
|
||||
it('mostra l asse coperto e dichiara insufficienti gli altri sei', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
seedPesi(db, MODEL_VERSION);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','M')`).run();
|
||||
|
||||
// i tre test di composizione esistono nel registro solo dopo il piano degli import:
|
||||
// finche non ci sono, questo test dimostra il comportamento con zero misure fisiche
|
||||
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, s, 'questionario', []);
|
||||
|
||||
const r = calcolaSessione(db, s);
|
||||
const insufficienti = Object.values(r.assi).filter((a) => a.stato === 'insufficiente').length;
|
||||
expect(insufficienti).toBe(7);
|
||||
expect(r.fitnessAge).toBeNull();
|
||||
});
|
||||
|
||||
it('nessun asse insufficiente porta con se un valore da mostrare per sbaglio', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
seedPesi(db, MODEL_VERSION);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','F')`).run();
|
||||
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 35 });
|
||||
registraMisure(db, s, 'questionario', []);
|
||||
|
||||
const r = calcolaSessione(db, s);
|
||||
for (const [nome, asse] of Object.entries(r.assi)) {
|
||||
if (asse.stato === 'insufficiente') {
|
||||
expect((asse as { valore?: number }).valore, `${nome} espone un valore`).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Il caso misto: quello che la prima dashboard disegnera' davvero, non "tutto
|
||||
* insufficiente". Realizza per intero la premessa del describe qui sopra — la
|
||||
* composizione corporea davvero misurata dalla Wellness Tower — aggiungendo le
|
||||
* due voci al registro (arrivano col piano degli import; qui bastano due righe,
|
||||
* non serve aspettarle) e registrando le misure end-to-end.
|
||||
*/
|
||||
it('un asse valido (Composizione Corporea) e sei insufficienti, end-to-end', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
seedPesi(db, MODEL_VERSION);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0003','M')`).run();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
|
||||
VALUES ('wt_grasso', 'Grasso % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'grasso', '2026-01-01')`
|
||||
).run(JSON.stringify({ best: 10, worst: 40 }));
|
||||
db.prepare(
|
||||
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
|
||||
VALUES ('wt_muscolo', 'Muscolo % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'muscolo', '2026-01-01')`
|
||||
).run(JSON.stringify({ best: 50, worst: 30 }));
|
||||
|
||||
const s = apriSessione(db, { client_code: 'ISL-0003', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
|
||||
registraMisure(db, s, 'wellness_tower', [
|
||||
{ test_id: 'wt_grasso', valore_num: 15 },
|
||||
{ test_id: 'wt_muscolo', valore_num: 45 },
|
||||
]);
|
||||
|
||||
const r = calcolaSessione(db, s);
|
||||
expect(r.assi['Composizione Corporea'].stato).toBe('ok');
|
||||
const insufficienti = Object.entries(r.assi)
|
||||
.filter(([nome, a]) => nome !== 'Composizione Corporea' && a.stato === 'insufficiente');
|
||||
expect(insufficienti.length).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
clamp, lerp, arrotonda1, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
|
||||
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini,
|
||||
} from '../../src/lib/longevity/motore/curve';
|
||||
|
||||
const RIF = JSON.parse(
|
||||
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
|
||||
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
|
||||
|
||||
const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn);
|
||||
|
||||
describe('curve del questionario, confrontate con l oracolo', () => {
|
||||
it('la campana combacia su tutto il dominio', () => {
|
||||
const casi = casiDi('score_bell_curve');
|
||||
expect(casi.length).toBeGreaterThan(20);
|
||||
for (const c of casi) {
|
||||
const [v, low, pl, ph, high] = c.args as number[];
|
||||
expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => {
|
||||
const casi = casiDi('score_decreasing');
|
||||
expect(casi.length).toBeGreaterThan(40);
|
||||
for (const c of casi) {
|
||||
const [v, best, worst] = c.args as number[];
|
||||
expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('la crescente con plateau combacia', () => {
|
||||
const casi = casiDi('score_increasing_plateau');
|
||||
expect(casi.length).toBeGreaterThan(20);
|
||||
for (const c of casi) {
|
||||
const [v, worst, plateau] = c.args as number[];
|
||||
expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('la diretta per dieci combacia', () => {
|
||||
const casi = casiDi('score_direct_x10');
|
||||
expect(casi.length).toBeGreaterThan(10);
|
||||
for (const c of casi) {
|
||||
expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca.
|
||||
// Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10.
|
||||
it('la diretta invertita e il complemento della diretta', () => {
|
||||
for (const v of [0, 2.5, 5, 7.5, 10]) {
|
||||
expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6);
|
||||
}
|
||||
expect(curvaDirettaX10Invertita(0)).toBe(100);
|
||||
expect(curvaDirettaX10Invertita(10)).toBe(0);
|
||||
});
|
||||
|
||||
it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => {
|
||||
const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]];
|
||||
expect(curvaGradini(0, steps, undefined, true)).toBe(100);
|
||||
expect(curvaGradini(5, steps, undefined, true)).toBe(60);
|
||||
expect(curvaGradini(10, steps, undefined, true)).toBe(40);
|
||||
expect(curvaGradini(20, steps, undefined, true)).toBe(20);
|
||||
expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20);
|
||||
});
|
||||
|
||||
// Il ramo crescente non era esercitato da nessun test: l'ancoraggio (0, 20)
|
||||
// — invece di (0, 100) — poteva essere invertito o sbagliato senza che
|
||||
// niente diventasse rosso. Parametri veri da src/lib/longevity/registro.ts.
|
||||
it('i gradini crescenti riproducono il prototipo: q_attivita e q_luce', () => {
|
||||
// q_attivita: nessun zeroVal, i gradini partono già da (0, 20).
|
||||
const stepsAttivita: [number, number][] = [[0, 20], [2, 50], [4, 80], [999, 100]];
|
||||
expect(curvaGradini(0, stepsAttivita, undefined, false)).toBe(20);
|
||||
expect(curvaGradini(2, stepsAttivita, undefined, false)).toBe(50);
|
||||
expect(curvaGradini(4, stepsAttivita, undefined, false)).toBe(80);
|
||||
expect(curvaGradini(999, stepsAttivita, undefined, false)).toBe(100);
|
||||
expect(curvaGradini(1, stepsAttivita, undefined, false)).toBe(35); // interpolato fra (0,20) e (2,50)
|
||||
expect(curvaGradini(3, stepsAttivita, undefined, false)).toBe(65); // interpolato fra (2,50) e (4,80)
|
||||
|
||||
// q_luce: zeroVal 20, gradini che partono da 0.5 — l'ancoraggio (0, 20)
|
||||
// serve davvero per interpolare fra 0 (escluso, coperto da zeroVal) e 0.5.
|
||||
const stepsLuce: [number, number][] = [[0.5, 60], [1, 90], [999, 100]];
|
||||
expect(curvaGradini(0.5, stepsLuce, 20, false)).toBe(60);
|
||||
expect(curvaGradini(1, stepsLuce, 20, false)).toBe(90);
|
||||
expect(curvaGradini(999, stepsLuce, 20, false)).toBe(100);
|
||||
expect(curvaGradini(0.75, stepsLuce, 20, false)).toBe(75); // interpolato fra (0.5,60) e (1,90)
|
||||
expect(curvaGradini(0.25, stepsLuce, 20, false)).toBe(40); // interpolato fra l'ancoraggio (0,20) e (0.5,60)
|
||||
});
|
||||
|
||||
// Non basta controllare che a zero il risultato sia il valore atteso: se
|
||||
// zeroVal COINCIDE con l'ancoraggio di default (100 per il decrescente,
|
||||
// 20 per il crescente, come in q_schermi e q_luce) il test passa anche
|
||||
// cancellando dal codice la riga che gestisce lo zero. Qui zeroVal è
|
||||
// deliberatamente diverso dall'ancoraggio, in entrambe le direzioni: se
|
||||
// la riga sparisse, l'interpolazione a v=0 darebbe l'ancoraggio (100 o 20),
|
||||
// non questi valori, e il test diventerebbe rosso.
|
||||
it('zeroVal vince sui gradini anche quando NON coincide con l ancoraggio', () => {
|
||||
// q_schermi, il caso reale: qui zeroVal (100) coincide con l'ancoraggio
|
||||
// decrescente (100), quindi da solo NON è discriminante — resta utile
|
||||
// come controllo del valore vero, non come prova dello zero speciale.
|
||||
const stepsSchermi: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]];
|
||||
expect(curvaGradini(0, stepsSchermi, 100, true)).toBe(100);
|
||||
expect(curvaGradini(15, stepsSchermi, 100, true)).toBe(85);
|
||||
|
||||
// Discriminante, decrescente: ancoraggio di default sarebbe 100,
|
||||
// zeroVal è 42. Senza la riga dello zero speciale risulterebbe 100.
|
||||
const stepsDecrescenteDiscriminante: [number, number][] = [[10, 80], [30, 50], [999, 10]];
|
||||
expect(curvaGradini(0, stepsDecrescenteDiscriminante, 42, true)).toBe(42);
|
||||
|
||||
// Discriminante, crescente: ancoraggio di default sarebbe 20,
|
||||
// zeroVal è 55. Senza la riga dello zero speciale risulterebbe 20.
|
||||
const stepsCrescenteDiscriminante: [number, number][] = [[5, 40], [20, 70], [999, 100]];
|
||||
expect(curvaGradini(0, stepsCrescenteDiscriminante, 55, false)).toBe(55);
|
||||
});
|
||||
|
||||
it('clamp e lerp si comportano come nell oracolo', () => {
|
||||
expect(clamp(150)).toBe(100);
|
||||
expect(clamp(-5)).toBe(0);
|
||||
expect(lerp(5, 0, 10, 0, 100)).toBe(50);
|
||||
expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1]
|
||||
expect(lerp(11, 0, 10, 0, 100)).toBe(100);
|
||||
});
|
||||
|
||||
// round(x, 1) di Python arrotonda al pari sui pareggi ESATTI (banker's
|
||||
// rounding), non sempre in su. Senza questi casi un arrotondamento naive
|
||||
// (Math.round(x*10)/10, che su 86.25 darebbe 86.3) sarebbe indistinguibile
|
||||
// dal corretto in tutti gli altri test: qui il pareggio e' deliberato.
|
||||
it('arrotonda1 arrotonda al pari sui pareggi esatti, come round() di Python', () => {
|
||||
expect(arrotonda1(86.25)).toBe(86.2); // 862 e' pari: resta 86.2, non sale a 86.3
|
||||
expect(arrotonda1(86.75)).toBe(86.8); // 867 e' dispari: sale a 86.8, non resta 86.7
|
||||
expect(arrotonda1(86.24)).toBe(86.2); // non un pareggio: arrotondamento normale, per basso
|
||||
expect(arrotonda1(86.26)).toBe(86.3); // non un pareggio: arrotondamento normale, per alto
|
||||
});
|
||||
|
||||
// 86.35 NON e' un pareggio vero: il double che gli sta dietro vale
|
||||
// 86.34999999999999431..., quindi Python arrotonda (senza ambiguita') a
|
||||
// 86.3. Una prima versione di arrotonda1, che moltiplicava per 10 e
|
||||
// testava una tolleranza, ci cadeva: 86.35 * 10 arrotonda ESATTAMENTE a
|
||||
// 863.5 in virgola mobile (un pareggio creato dalla moltiplicazione, non
|
||||
// presente nel valore originale) e restituiva 86.4. Questo test blocca
|
||||
// proprio quella regressione.
|
||||
it('arrotonda1 non inventa un pareggio dove il double non ce l ha (86.35 -> 86.3, non un pareggio)', () => {
|
||||
expect(arrotonda1(86.35)).toBe(86.3);
|
||||
expect(arrotonda1(-86.35)).toBe(-86.3);
|
||||
});
|
||||
|
||||
// Generalizzazione a `decimali` cifre (usata da cascata.ts per la copertura,
|
||||
// a 2 decimali): stesso criterio dei test sopra, un gradino piu' in la'.
|
||||
// 0.125 e 0.375 sono pareggi ESATTI a due decimali (double rappresentabili
|
||||
// in binario senza resto: 1/8 e 3/8), verificati anche a mano con
|
||||
// round(0.125, 2) e round(0.375, 2) in Python.
|
||||
it('arrotonda1(x, 2) arrotonda al pari sui pareggi esatti a due decimali', () => {
|
||||
expect(arrotonda1(0.125, 2)).toBe(0.12); // 12 e' pari: resta 0.12, non sale a 0.13
|
||||
expect(arrotonda1(0.375, 2)).toBe(0.38); // 37 e' dispari: sale a 0.38, non resta 0.37
|
||||
expect(arrotonda1(0.124, 2)).toBe(0.12); // non un pareggio: arrotondamento normale, per basso
|
||||
expect(arrotonda1(0.126, 2)).toBe(0.13); // non un pareggio: arrotondamento normale, per alto
|
||||
});
|
||||
|
||||
// 0.045 e' l'analogo a due decimali di 86.35: il double che gli sta dietro
|
||||
// vale 0.044999999999999998..., quindi Python (e l'oracolo) arrotondano
|
||||
// SENZA ambiguita' a 0.04. Ma 0.045 * 100 arrotonda ESATTAMENTE a 4.5 in
|
||||
// virgola mobile - un pareggio creato dalla moltiplicazione, non presente
|
||||
// nel valore originale - e un arrotondamento naive (Math.round(x*100)/100,
|
||||
// la stessa `arrotonda2` locale rimossa da cascata.ts) ci sarebbe caduto,
|
||||
// restituendo 0.05 invece di 0.04.
|
||||
it('arrotonda1(x, 2) non inventa un pareggio dove il double non ce l ha (0.045 -> 0.04, non un pareggio)', () => {
|
||||
expect(arrotonda1(0.045, 2)).toBe(0.04);
|
||||
expect(arrotonda1(-0.045, 2)).toBe(-0.04);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as F from '../../src/lib/longevity/motore/test-fisici';
|
||||
|
||||
const RIF = JSON.parse(
|
||||
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
|
||||
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
|
||||
|
||||
/** Ogni funzione dell'oracolo con la sua gemella in TypeScript. */
|
||||
const COPPIE: [string, (...a: never[]) => number][] = [
|
||||
['score_fat_percent', F.scoreGrassoPercento as never],
|
||||
['score_muscle_percent', F.scoreMuscoloPercento as never],
|
||||
['score_whr', F.scoreWhr as never],
|
||||
['score_vo2max', F.scoreVo2max as never],
|
||||
['score_spo2', F.scoreSpo2 as never],
|
||||
['score_blood_pressure', F.scorePressione as never],
|
||||
['score_hrr', F.scoreRecuperoCardiaco as never],
|
||||
['score_handgrip', F.scoreHandgrip as never],
|
||||
['score_pushup', F.scorePushup as never],
|
||||
['score_flexed_arm_hang', F.scoreTrazioneIsometrica as never],
|
||||
['score_sit_to_stand_1min', F.scoreSitToStand as never],
|
||||
['score_plank', F.scorePlank as never],
|
||||
['score_back_scratch', F.scoreBackScratch as never],
|
||||
['score_shoulder_mobility_wt', F.scoreMobilitaSpalla as never],
|
||||
['score_flamingo', F.scoreFlamingo as never],
|
||||
['score_sit_and_reach', F.scoreSitAndReach as never],
|
||||
['vo2max_from_step_test', F.vo2maxDaStepTest as never],
|
||||
['vo2max_from_2km_walk', F.vo2maxDa2kmWalk as never],
|
||||
['vo2max_from_mutt', F.vo2maxDaMutt as never],
|
||||
['vo2max_from_milfit', F.vo2maxDaMilfit as never],
|
||||
];
|
||||
|
||||
describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => {
|
||||
for (const [nomePython, fnTs] of COPPIE) {
|
||||
it(`${nomePython} combacia su tutti i casi del riferimento`, () => {
|
||||
const casi = RIF.casi.filter((c) => c.fn === nomePython);
|
||||
expect(casi.length, `nessun caso per ${nomePython}: la griglia non lo copre`).toBeGreaterThan(0);
|
||||
const divergenti: string[] = [];
|
||||
for (const c of casi) {
|
||||
const ottenuto = (fnTs as (...a: unknown[]) => number)(...c.args);
|
||||
if (Math.abs(ottenuto - c.atteso) > 0.05) {
|
||||
divergenti.push(`${nomePython}(${c.args.join(', ')}): atteso ${c.atteso}, ottenuto ${ottenuto}`);
|
||||
}
|
||||
}
|
||||
expect(divergenti.slice(0, 5).join('\n')).toBe('');
|
||||
});
|
||||
}
|
||||
|
||||
it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle DEFINITE NEL CODICE', () => {
|
||||
// Prima versione di questo test: passava alla funzione le tabelle lette dagli
|
||||
// argomenti del riferimento (c.args), non le costanti TIERS_* definite nel
|
||||
// codice — le sei tabelle non erano confrontate contro l'oracolo da nessuno.
|
||||
// Qui si usano SOLO le costanti del modulo: se una venisse alterata, nessun
|
||||
// caso combacerebbe piu' con gli argomenti del riferimento e il test cade.
|
||||
const casi = RIF.casi.filter((c) => c.fn === 'score_bw_ratio_lift');
|
||||
expect(casi.length).toBeGreaterThan(50);
|
||||
|
||||
const TABELLE: [string, [number, number][], [number, number][]][] = [
|
||||
['bench', F.TIERS_BENCH_M, F.TIERS_BENCH_F],
|
||||
['squat', F.TIERS_SQUAT_M, F.TIERS_SQUAT_F],
|
||||
['row', F.TIERS_ROW_M, F.TIERS_ROW_F],
|
||||
];
|
||||
const perTabella: Record<string, number> = { bench: 0, squat: 0, row: 0 };
|
||||
|
||||
for (const c of casi) {
|
||||
const [carico, peso, rip, tiersMRif, , sesso] = c.args as [number, number, number, unknown, unknown, 'M' | 'F'];
|
||||
// Identifica QUALE tabella del codice questo caso sta esercitando confrontando
|
||||
// gli argomenti del riferimento con le costanti vere — non il contrario.
|
||||
const trovata = TABELLE.find(([, tm]) => JSON.stringify(tm) === JSON.stringify(tiersMRif));
|
||||
expect(trovata, `nessuna tabella del codice combacia con gli argomenti del caso: carico ${carico} ${sesso}`).toBeDefined();
|
||||
const [nome, tiersM, tiersF] = trovata!;
|
||||
perTabella[nome]++;
|
||||
|
||||
const ottenuto = F.scoreSollevamentoSuPeso(carico, peso, rip, tiersM, tiersF, sesso);
|
||||
expect(ottenuto, `${nome} carico ${carico} ${sesso}`).toBeCloseTo(c.atteso, 1);
|
||||
}
|
||||
// Tutte e tre le tabelle sono state esercitate davvero, non solo dichiarate.
|
||||
expect(perTabella.bench).toBeGreaterThan(0);
|
||||
expect(perTabella.squat).toBeGreaterThan(0);
|
||||
expect(perTabella.row).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('le due funzioni escluse dal porting non esistono nel modulo', () => {
|
||||
// Agilita: il cliente l'ha esclusa dallo score il 13/08 (scale non comparabili fra protocolli).
|
||||
expect((F as Record<string, unknown>).scoreAgilita).toBeUndefined();
|
||||
expect((F as Record<string, unknown>).scoreAgilityMs).toBeUndefined();
|
||||
// score_generic_range_local: definita in fondo all'oracolo ma non chiamata da nessun peso.
|
||||
expect((F as Record<string, unknown>).scoreGenericRangeLocal).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
|
||||
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
||||
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
|
||||
import { calcolaAsse } from '../../src/lib/longevity/motore/cascata';
|
||||
import { applicaCurva, calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore';
|
||||
|
||||
function dbPronto() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
seedPesi(db, MODEL_VERSION);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||
return db;
|
||||
}
|
||||
|
||||
const voce = (db: ReturnType<typeof dbPronto>, id: string) =>
|
||||
db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record<string, unknown>;
|
||||
|
||||
describe('il motore legge le curve dal registro', () => {
|
||||
it('applica la curva dichiarata per il test, non una scritta nel codice', () => {
|
||||
const db = dbPronto();
|
||||
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
|
||||
expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9
|
||||
expect(applicaCurva(v as never, 4)).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it('rispetta la soglia dell alcol come e scritta nel registro', () => {
|
||||
const db = dbPronto();
|
||||
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
|
||||
expect(applicaCurva(v as never, 0)).toBe(100);
|
||||
expect(applicaCurva(v as never, 7)).toBe(100);
|
||||
expect(applicaCurva(v as never, 14)).toBe(0);
|
||||
});
|
||||
|
||||
it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => {
|
||||
const db = dbPronto();
|
||||
db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`)
|
||||
.run(JSON.stringify({ best: 0, worst: 7 }));
|
||||
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
|
||||
expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100
|
||||
});
|
||||
|
||||
it('un test disattivato non entra nel calcolo', () => {
|
||||
// NOTA: la versione originale di questo test disattivava 'q_ore_sonno' e si
|
||||
// aspettava una differenza. Ma 'q_ore_sonno' e' uno delle SEI voci del
|
||||
// sotto-dominio 'questionario_sonno' (peso 0.20 dentro "Recupero & Sistema
|
||||
// Nervoso", che pesa in tutto 1.00: hrv 0.50 + pressione 0.15 + hrr 0.15 +
|
||||
// questionario_sonno 0.20). Quell'asse resta 'insufficiente' con o senza
|
||||
// 'q_ore_sonno' (0.20 < COPERTURA_MINIMA 0.4, e hrv/pressione/hrr non hanno
|
||||
// ancora una voce nel registro): siccome un asse insufficiente non porta
|
||||
// 'valore' nel tipo Punteggio, il JSON prima/dopo risultava IDENTICO — la
|
||||
// suite lo ha confermato (verificato eseguendo il test: fallisce con le due
|
||||
// stringhe uguali). Corretto qui usando 'q_attivita': NON e' l'unica voce
|
||||
// del sotto-dominio 'questionario_lifestyle' (ne ha sei: q_attivita,
|
||||
// q_alimentazione, q_sigarette, q_alcol_life, q_luce, q_schermi), ma in
|
||||
// QUESTO fixture e' l'unica delle sei a cui si risponde. Disattivarla
|
||||
// svuota davvero il sotto-dominio (nessun'altra voce lifestyle compilata a
|
||||
// sostituirla), la copertura dell'asse "Stile di Vita & Sonno" (peso 1.00
|
||||
// su quel solo sotto-dominio) cade da 1.00 a 0 e lo stato passa da 'ok' a
|
||||
// 'insufficiente' — una differenza vera e osservabile. Se in futuro il
|
||||
// fixture compilasse anche un'altra risposta lifestyle, questo test
|
||||
// andrebbe aggiornato: la disattivazione non svuoterebbe piu' il
|
||||
// sotto-dominio da sola.
|
||||
const db = dbPronto();
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte: {
|
||||
q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0,
|
||||
q_attivita: 5,
|
||||
},
|
||||
});
|
||||
const prima = calcolaSessione(db, 1);
|
||||
expect(prima.assi['Stile di Vita & Sonno'].stato).toBe('ok');
|
||||
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_attivita'`).run();
|
||||
const dopo = calcolaSessione(db, 1);
|
||||
expect(dopo.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
|
||||
expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo));
|
||||
});
|
||||
|
||||
it('una curva con i parametri mancanti nel registro non calcola un punteggio: restituisce null, mai 100', () => {
|
||||
// Riproduce il difetto trovato in revisione: registro modificabile senza migrazioni,
|
||||
// una riga con params vuoti o incompleti non deve mai dare il punteggio pieno.
|
||||
const db = dbPronto();
|
||||
// bell (q_ore_sonno): params svuotati del tutto.
|
||||
const sonno = { ...voce(db, 'q_ore_sonno'), params: {} };
|
||||
expect(applicaCurva(sonno as never, 3)).toBeNull(); // 3 ore di sonno: mai 100
|
||||
// lin_dec (q_alcol_life): manca 'worst'.
|
||||
const alcol = { ...voce(db, 'q_alcol_life'), params: { best: 7 } };
|
||||
expect(applicaCurva(alcol as never, 40)).toBeNull(); // 40 unita/settimana: mai 100
|
||||
// decstep (q_sigarette): steps mancanti.
|
||||
const sigarette = { ...voce(db, 'q_sigarette'), params: {} };
|
||||
expect(applicaCurva(sigarette as never, 40)).toBeNull(); // 40 sigarette/giorno: mai 100
|
||||
});
|
||||
|
||||
it('una curva coi parametri completi continua a funzionare (il controllo non e troppo severo)', () => {
|
||||
const db = dbPronto();
|
||||
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
|
||||
expect(applicaCurva(v as never, 8)).toBe(100);
|
||||
});
|
||||
|
||||
it('due test dello stesso sotto-dominio danno la MEDIA dei punteggi, non la somma ne un singolo valore', () => {
|
||||
// q_attivita e q_alimentazione condividono il sotto-dominio 'questionario_lifestyle',
|
||||
// che pesa 1.00 (unico elemento) dentro l'asse "Stile di Vita & Sonno": il valore
|
||||
// dell'asse coincide col valore del sotto-dominio, quindi la media si legge diretta.
|
||||
const db = dbPronto();
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
// q_attivita curva incstep, steps [[0,20],[2,50],[4,80],[999,100]]: valore 3 ->
|
||||
// lerp fra (2,50) e (4,80) a meta' strada -> punteggio 65.
|
||||
// q_alimentazione curva x10: valore 6 -> punteggio 60.
|
||||
// media = (65+60)/2 = 62.5: diversa dalla somma (125) e da ciascun addendo (65, 60).
|
||||
risposte: { q_attivita: 3, q_alimentazione: 6 },
|
||||
});
|
||||
const r = calcolaSessione(db, 1);
|
||||
const asse = r.assi['Stile di Vita & Sonno'];
|
||||
expect(asse.stato).toBe('ok');
|
||||
if (asse.stato === 'ok') expect(asse.valore).toBe(62.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcolo e congelamento di una sessione', () => {
|
||||
it('produce i sette assi, e quelli senza dati sono insufficienti', () => {
|
||||
const db = dbPronto();
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 },
|
||||
});
|
||||
const r = calcolaSessione(db, 1);
|
||||
expect(Object.keys(r.assi).length).toBe(7);
|
||||
// il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40%
|
||||
expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente');
|
||||
// Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto
|
||||
expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente');
|
||||
});
|
||||
|
||||
it('congela i punteggi con le DUE versioni', () => {
|
||||
const db = dbPronto();
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte: { q_riposato: 7 },
|
||||
});
|
||||
salvaScore(db, 1, calcolaSessione(db, 1));
|
||||
const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record<string, unknown>[];
|
||||
expect(righe.length).toBeGreaterThan(0);
|
||||
for (const r of righe) {
|
||||
expect(r.model_version).toBe(MODEL_VERSION);
|
||||
expect(r.quest_version).toBe('v1.0');
|
||||
if (r.stato === 'insufficiente') expect(r.valore).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => {
|
||||
const db = dbPronto();
|
||||
const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura');
|
||||
expect(pesi.handgrip).toBe(0.25);
|
||||
expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('senza eta la Fitness Age e null, non un numero negativo dichiarato valido', () => {
|
||||
// Bug trovato in revisione: eta_alla_data e' nullable nello schema (l'apertura di
|
||||
// una sessione e il salvataggio di un questionario accettano l'eta opzionale), ma
|
||||
// il motore la leggeva con un ripiego a zero. Qui costruiamo un composito Fitness
|
||||
// Age davvero 'ok' (copertura esattamente 0.40: cardio 0.30 + composizione 0.10),
|
||||
// cosi' il difetto e' osservabile: col ripiego a zero l'eta anagrafica diventa 0
|
||||
// e la formula "eta - (composito - 50) * 0.4" esce negativa ma con stato 'ok'.
|
||||
// Due voci fisiche aggiunte al registro qui (arriveranno davvero col piano degli
|
||||
// import): servono solo a rendere il composito calcolabile senza aspettarle.
|
||||
const db = dbPronto();
|
||||
db.prepare(
|
||||
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
|
||||
VALUES ('wt_vo2max', 'VO2max stimato', 'num', 'lin_dec', ?, 'Cardio-Respiratorio', 'vo2max', '2026-01-01')`
|
||||
).run(JSON.stringify({ best: 40, worst: 20 }));
|
||||
db.prepare(
|
||||
`INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da)
|
||||
VALUES ('wt_grasso', 'Grasso % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'grasso', '2026-01-01')`
|
||||
).run(JSON.stringify({ best: 10, worst: 40 }));
|
||||
|
||||
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup' }); // nessuna eta
|
||||
registraMisure(db, s, 'wellness_tower', [
|
||||
{ test_id: 'wt_vo2max', valore_num: 40 }, // curva a 100
|
||||
{ test_id: 'wt_grasso', valore_num: 10 }, // curva a 100
|
||||
]);
|
||||
|
||||
const r = calcolaSessione(db, s);
|
||||
expect(r.assi['Cardio-Respiratorio'].stato).toBe('ok');
|
||||
expect(r.assi['Composizione Corporea'].stato).toBe('ok');
|
||||
expect(r.fitnessAgeCopertura).toBe(0.4); // composito 'ok': la soglia e' inclusiva
|
||||
expect(r.fitnessAge).toBeNull(); // ma senza eta anagrafica, la Fitness Age non esiste
|
||||
});
|
||||
|
||||
it('una misura fuori range non entra nello score (§9 della spec)', () => {
|
||||
// Caso letterale della spec: una risposta 0-10 di 999 e' fuori dal fondoscala
|
||||
// dichiarato (come la capacita' vitale di 5148 mL su un fondoscala 2000-3000),
|
||||
// si scrive e si marca, ma non deve produrre un punteggio.
|
||||
// q_alimentazione e' l'UNICA voce del sotto-dominio 'questionario_lifestyle'
|
||||
// (peso 1.00, unico elemento dell'asse "Stile di Vita & Sonno"): se la misura
|
||||
// fuori range entrasse nel calcolo l'asse risulterebbe 'ok' con valore 100
|
||||
// (curvaDirettaX10(999) clampato); esclusa, il sotto-dominio resta scoperto e
|
||||
// l'asse e' 'insufficiente'. E' la differenza osservabile che prova il filtro.
|
||||
const db = dbPronto();
|
||||
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'questionario', eta_alla_data: 35 });
|
||||
const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_alimentazione', valore_num: 999 }]);
|
||||
expect(esito.fuoriRange).toEqual(['q_alimentazione']);
|
||||
|
||||
const r = calcolaSessione(db, s);
|
||||
expect(r.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
|
||||
|
||||
const riga = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_alimentazione'`).get() as { fuori_range: number };
|
||||
expect(riga.fuori_range).toBe(1);
|
||||
});
|
||||
|
||||
it('scrive nella tabella score un valore vero e la copertura vera, non solo lo stato', () => {
|
||||
// Sabotaggi che questo test deve intercettare: tutti i valori scritti a null, o
|
||||
// tutte le coperture forzate a 1. Serve un caso con copertura NON piena (0.5, non
|
||||
// 1.0) perche' un "sempre 1" non si distinguerebbe da un caso gia' pieno.
|
||||
const db = dbPronto();
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
// q_energia_media (x10) e' l'unica voce misurata del sotto-dominio
|
||||
// 'questionario_energia_stress' (peso 0.50 nell'asse, l'altro 0.50 e' hrv,
|
||||
// non ancora nel registro): copertura 0.50, non 1.
|
||||
risposte: { q_energia_media: 8 },
|
||||
});
|
||||
const risultato = calcolaSessione(db, 1);
|
||||
salvaScore(db, 1, risultato);
|
||||
const riga = db.prepare(
|
||||
`SELECT valore, copertura, stato FROM score WHERE tipo = 'asse' AND elemento = ?`
|
||||
).get('Energia & Regolazione Stress') as { valore: number; copertura: number; stato: string };
|
||||
expect(riga.stato).toBe('ok');
|
||||
expect(riga.valore).toBe(80); // curvaDirettaX10(8) = 80, unico contributo
|
||||
expect(riga.copertura).toBe(0.5); // 0.50 di peso disponibile su 1.00
|
||||
});
|
||||
});
|
||||
|
||||
describe('leggiScore legge dalla tabella, tipato come Punteggio', () => {
|
||||
it('restituisce assi/macro/fitness_age come Punteggio: nessun valore leggibile da un insufficiente', () => {
|
||||
const db = dbPronto();
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte: { q_attivita: 3, q_alimentazione: 6 },
|
||||
});
|
||||
const risultato = calcolaSessione(db, 1);
|
||||
salvaScore(db, 1, risultato);
|
||||
const letto = leggiScore(db, 1);
|
||||
expect(letto.assi['Stile di Vita & Sonno'].stato).toBe('ok');
|
||||
if (letto.assi['Stile di Vita & Sonno'].stato === 'ok') {
|
||||
expect(letto.assi['Stile di Vita & Sonno'].valore).toBe(62.5);
|
||||
}
|
||||
expect(letto.assi['Forza & Struttura'].stato).toBe('insufficiente');
|
||||
expect((letto.assi['Forza & Struttura'] as { valore?: number }).valore).toBeUndefined();
|
||||
});
|
||||
|
||||
it('due calcoli sulla stessa sessione non si sovrascrivono: leggiScore prende l ultimo', () => {
|
||||
// Decisione presa: salvaScore non ha vincolo di unicita', la storia si tiene.
|
||||
// leggiScore deve dichiarare esplicitamente nella query il criterio dell'ultimo
|
||||
// calcolo (non l'ordine naturale delle righe).
|
||||
const db = dbPronto();
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte: { q_attivita: 3, q_alimentazione: 6 }, // -> 62.5
|
||||
});
|
||||
salvaScore(db, 1, calcolaSessione(db, 1));
|
||||
|
||||
// Aggiunge una risposta lifestyle: cambia la media del sotto-dominio, ricalcola
|
||||
// e salva una SECONDA volta sulla stessa sessione.
|
||||
registraMisure(db, 1, 'questionario', [{ test_id: 'q_sigarette', valore_num: 0 }]); // curva decstep, v=0 -> 100
|
||||
salvaScore(db, 1, calcolaSessione(db, 1));
|
||||
|
||||
const righe = db.prepare(
|
||||
`SELECT COUNT(*) n FROM score WHERE sessione_id = 1 AND tipo = 'asse' AND elemento = 'Stile di Vita & Sonno'`
|
||||
).get() as { n: number };
|
||||
expect(righe.n).toBe(2); // la storia si tiene: due righe, non una sovrascritta
|
||||
|
||||
const letto = leggiScore(db, 1);
|
||||
const asse = letto.assi['Stile di Vita & Sonno'];
|
||||
expect(asse.stato).toBe('ok');
|
||||
// media di tre voci ora (65+60+100)/3 = 75, non piu' 62.5 del primo calcolo
|
||||
if (asse.stato === 'ok') expect(asse.valore).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('i pesi sono legati alla versione del modello, non fusi insieme', () => {
|
||||
it('togliendo il filtro sulla versione dalla lettura dei pesi, due versioni diverse darebbero lo stesso punteggio: qui devono differire', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedPesi(db, 'v1.0');
|
||||
// Seconda versione del modello, con pesi DIVERSI per lo stesso asse.
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO pesi (model_version, livello, contenitore, elemento, peso) VALUES (?, 'asse', 'Forza & Struttura', ?, ?)`
|
||||
);
|
||||
ins.run('v2.0', 'handgrip', 0.9);
|
||||
ins.run('v2.0', 'core', 0.1);
|
||||
|
||||
const punteggi = { handgrip: 100, spinta: 0, trazione: 0, arti_inferiori: 0, core: 0 };
|
||||
const v1 = calcolaAsse(pesiDi(db, 'v1.0', 'asse', 'Forza & Struttura'), punteggi);
|
||||
const v2 = calcolaAsse(pesiDi(db, 'v2.0', 'asse', 'Forza & Struttura'), punteggi);
|
||||
expect(v1.stato).toBe('ok');
|
||||
expect(v2.stato).toBe('ok');
|
||||
if (v1.stato === 'ok' && v2.stato === 'ok') {
|
||||
expect(v1.valore).toBe(25); // 100*0.25 (v1.0)
|
||||
expect(v2.valore).toBe(90); // 100*0.9 (v2.0)
|
||||
expect(v1.valore).not.toBe(v2.valore);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// tests/longevity/pagine.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
|
||||
|
||||
describe('il guscio della piattaforma', () => {
|
||||
it('il layout esiste e non monta header e footer pubblici', () => {
|
||||
const l = leggi('src/layouts/Longevity.astro');
|
||||
expect(l).not.toMatch(/import\s+Header/);
|
||||
expect(l).not.toMatch(/import\s+Footer/);
|
||||
expect(l).toMatch(/noindex/); // area riservata: mai indicizzata
|
||||
});
|
||||
|
||||
it('il foglio di stile e globale e prefissato, non scoped', () => {
|
||||
expect(existsSync(join(process.cwd(), 'src/styles/longevity.css'))).toBe(true);
|
||||
const css = leggi('src/styles/longevity.css');
|
||||
const classi = [...css.matchAll(/^\.([a-z-]+)/gm)].map((m) => m[1]);
|
||||
expect(classi.length).toBeGreaterThan(3);
|
||||
// "lg" da solo o "lg-qualcosa": il solo startsWith('lg') lascerebbe passare anche "lgfoo".
|
||||
expect(classi.every((c) => c === 'lg' || c.startsWith('lg-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('usa i token del sito invece di inventare colori', () => {
|
||||
const css = leggi('src/styles/longevity.css');
|
||||
expect(css).toMatch(/var\(--c-/);
|
||||
});
|
||||
|
||||
it('nessuna variabile porta un nome da semaforo (verde/buono/ok)', () => {
|
||||
const css = leggi('src/styles/longevity.css').toLowerCase();
|
||||
// il mattone del fuori range e ammesso; un verde "tutto bene" no
|
||||
expect(css).not.toMatch(/--lg-ok|--lg-buono|--lg-verde/);
|
||||
});
|
||||
|
||||
it('in tutto il foglio compaiono solo i colori ammessi, salvo le eccezioni dichiarate', () => {
|
||||
// Il test sopra cerca nomi vietati: chi scrivesse un verde in esadecimale, o lo
|
||||
// chiamasse con un nome innocuo (var(--lg-buona-salute)), passerebbe indisturbato.
|
||||
// Qui si fa il contrario: si elenca ciò che PUÒ comparire, e qualunque altro colore —
|
||||
// letterale (esadecimale, rgb(), nome CSS) o var() con un altro nome — è una
|
||||
// regressione.
|
||||
//
|
||||
// PRIMA questo test filtrava le regole sul selettore ("solo quelle con 'lg-score' nel
|
||||
// nome"): un elenco di QUALI regole guardare invecchia appena nasce una classe nuova —
|
||||
// è successo con .lg-radar__voce-nome, .lg-radar__intro, .lg-macro__nota (Task 3): non
|
||||
// contengono la stringa "lg-score", quindi non venivano MAI controllate, in silenzio.
|
||||
// Il foglio è interamente prefissato `lg-` per costruzione (verificato dal test sopra):
|
||||
// non serve scegliere quali regole guardare, si guardano TUTTE, e le eccezioni
|
||||
// legittime si dichiarano una per una — selettore, valore esatto, motivo — così un
|
||||
// colore fuori lista entra solo con una decisione visibile, mai con un buco nel filtro.
|
||||
const css = leggi('src/styles/longevity.css');
|
||||
const AMMESSI = new Set([
|
||||
'--c-accent', '--c-accent-dark', // l'accento del sito: l'unica leva per l'intensità
|
||||
'--c-heading', '--c-text', '--c-text-light', // testo neutro, uguale ovunque nel sito
|
||||
'--c-bg', '--c-bg-alt', // sfondi neutri
|
||||
'--lg-line', '--lg-surface', // struttura locale, neutra
|
||||
'--lg-tratteggio', // il perimetro di ciò che non sappiamo
|
||||
'--lg-fuori-range', // SOLO una misura sospetta, mai un punteggio
|
||||
]);
|
||||
// Eccezioni dichiarate: selettore esatto + valore esatto + perché non è un giudizio sul
|
||||
// punteggio. Ogni riga qui è una decisione presa a vista, non un buco silenzioso.
|
||||
const ECCEZIONI: { selettore: string; valore: string; motivo: string }[] = [
|
||||
{
|
||||
selettore: '.lg-form__nav button:last-child:not(:only-child)',
|
||||
valore: '#fff',
|
||||
motivo: "testo bianco per contrasto sul bottone pieno d'accento — non un colore di punteggio",
|
||||
},
|
||||
];
|
||||
const NON_COLORE = /^(transparent|inherit|initial|unset|currentcolor|none)$/i;
|
||||
const PROP_COLORE = /^(color|background|background-color|border(-\w+)?-color|outline-color|fill|stroke)$/i;
|
||||
|
||||
const violazioni: string[] = [];
|
||||
for (const regola of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const selettore = regola[1].trim();
|
||||
for (const dich of regola[2].split(';')) {
|
||||
const due = dich.indexOf(':');
|
||||
if (due < 0) continue;
|
||||
const prop = dich.slice(0, due).trim().toLowerCase();
|
||||
const val = dich.slice(due + 1).trim();
|
||||
if (!PROP_COLORE.test(prop) || val === '' || NON_COLORE.test(val)) continue;
|
||||
const varMatch = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,.*)?\)$/i);
|
||||
if (varMatch && AMMESSI.has(varMatch[1])) continue;
|
||||
if (ECCEZIONI.some((e) => e.selettore === selettore && e.valore === val)) continue;
|
||||
violazioni.push(`${selettore} { ${prop}: ${val} }`);
|
||||
}
|
||||
}
|
||||
expect(violazioni).toEqual([]);
|
||||
});
|
||||
|
||||
it('la rotta del cliente e sotto /longevity, e rimanda davvero al referto', () => {
|
||||
const p = join(process.cwd(), 'src/pages/longevity/index.astro');
|
||||
expect(existsSync(p)).toBe(true);
|
||||
const src = readFileSync(p, 'utf8');
|
||||
expect(src).toMatch(/Astro\.redirect\(\s*['"`]\/longevity\/io['"`]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// La prima apertura del referto (revisione del 22/08): con i dati di oggi il cliente vede
|
||||
// molti assi tratteggiati. È corretto, ma la pagina deve SPIEGARLO, non lasciarlo sembrare
|
||||
// un guasto. Questi controlli leggono il sorgente delle pagine/componenti — come già fa
|
||||
// referto.test.ts per "nessuna pagina interroga il database da sola" — perché non c'è un
|
||||
// harness di rendering per Astro/React in questo repo (nessuna dipendenza nuova ammessa).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8');
|
||||
|
||||
describe('la Fitness Age, quando manca, spiega perché (come le altre carte)', () => {
|
||||
it('io.astro mostra una nota di copertura anche per la Fitness Age, non solo un trattino nudo', () => {
|
||||
const src = leggi('src/pages/longevity/io.astro');
|
||||
// Deve leggere la copertura dedicata (vista.ts, fitnessAgeCopertura), non inventarsi
|
||||
// un numero: la sola presenza del trattino '—' non basta più.
|
||||
expect(src).toMatch(/fitnessAgeCopertura/);
|
||||
expect(src).toMatch(/Copertura dati/);
|
||||
});
|
||||
|
||||
it('il numero della Fitness Age, quando c\'è, è formattato all\'italiana', () => {
|
||||
const src = leggi('src/pages/longevity/io.astro');
|
||||
expect(src).toMatch(/formattaNumero\(referto\.fitnessAge\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('il radar spiega prima di mostrare', () => {
|
||||
it('la spiegazione del radar precede il grafico, non lo segue', () => {
|
||||
const src = leggi('src/components/longevity/Radar.tsx');
|
||||
const idxIntro = src.indexOf("L'area piena");
|
||||
const idxGrafico = src.indexOf('<ResponsiveContainer');
|
||||
expect(idxIntro).toBeGreaterThan(-1);
|
||||
expect(idxGrafico).toBeGreaterThan(-1);
|
||||
expect(idxIntro).toBeLessThan(idxGrafico);
|
||||
});
|
||||
|
||||
it("l'elenco di ciò che manca invita a fare qualcosa, non solo a leggere nomi di test", () => {
|
||||
const src = leggi('src/components/longevity/Radar.tsx');
|
||||
expect(src).toMatch(/trainer/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la copertura si mostra sempre, non solo quando il numero manca', () => {
|
||||
it('MacroScore.astro non nasconde più la copertura quando il punteggio c\'è', () => {
|
||||
const src = leggi('src/components/longevity/MacroScore.astro');
|
||||
// PRIMA: `{valore === null && (<p ...>Copertura dati...`. La nota deve poter comparire
|
||||
// anche quando valore !== null: la vecchia guardia esatta non deve più esserci.
|
||||
expect(src).not.toMatch(/\{valore === null && \(\s*<p class="lg-muted lg-macro__nota">/);
|
||||
expect(src).toMatch(/Copertura dati/);
|
||||
});
|
||||
|
||||
it('il punteggio di MacroScore è formattato all\'italiana', () => {
|
||||
const src = leggi('src/components/longevity/MacroScore.astro');
|
||||
expect(src).toMatch(/formattaNumero\(valore\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chi ha già compilato può ricompilare (monitoraggio nel tempo)', () => {
|
||||
it('io.astro offre il collegamento al questionario anche quando il referto esiste già', () => {
|
||||
const src = leggi('src/pages/longevity/io.astro');
|
||||
const idxReferto = src.indexOf('{referto && (');
|
||||
expect(idxReferto).toBeGreaterThan(-1);
|
||||
const blocco = src.slice(idxReferto);
|
||||
expect(blocco).toMatch(/href=["'`]\/longevity\/questionario["'`]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro } from '../../src/lib/longevity/registro';
|
||||
import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
|
||||
import { creaCliente } from '../../src/lib/longevity/anagrafica';
|
||||
import { CONSENT_VERSION, QUEST_VERSION } from '../../src/lib/longevity/questionario';
|
||||
|
||||
function dbPronto() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('i campi del questionario vengono dal registro', () => {
|
||||
it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
|
||||
const db = dbPronto();
|
||||
const campi = campiDelQuestionario(db, '2026-08-22');
|
||||
expect(campi.length).toBe(20);
|
||||
const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
|
||||
expect(alcol.etichetta).toMatch(/alcoliche/i);
|
||||
expect(alcol.max).toBe(30);
|
||||
});
|
||||
|
||||
it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
|
||||
const db = dbPronto();
|
||||
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
|
||||
const campi = campiDelQuestionario(db, '2026-08-22');
|
||||
expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
|
||||
expect(campi.length).toBe(19);
|
||||
});
|
||||
});
|
||||
|
||||
describe('il salvataggio, e il consenso', () => {
|
||||
const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };
|
||||
|
||||
it('salva una compilazione quando il consenso e dato', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaDalForm(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte, consensoSanitario: 'Si', liberi: {},
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||
expect(n.n).toBe(1);
|
||||
});
|
||||
|
||||
it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaDalForm(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte, consensoSanitario: 'No', liberi: {},
|
||||
});
|
||||
expect(esito.ok).toBe(false);
|
||||
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||
expect(n.n).toBe(0); // niente sessione, niente misure
|
||||
});
|
||||
|
||||
it('NON salva se il consenso manca del tutto', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaDalForm(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte, consensoSanitario: '', liberi: {},
|
||||
});
|
||||
expect(esito.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
|
||||
const db = dbPronto();
|
||||
salvaDalForm(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte, consensoSanitario: 'No', liberi: {},
|
||||
});
|
||||
const note = db.prepare(
|
||||
`SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
|
||||
).all() as { campo_id: string; testo: string }[];
|
||||
expect(note.length).toBeGreaterThan(0);
|
||||
expect(note[0].testo).toMatch(/No/);
|
||||
});
|
||||
|
||||
// PRIMA di questa correzione il ramo del consenso DATO non era coperto da nessun test:
|
||||
// cancellando la riga che registra il consenso accettato (registraConsenso dentro il ramo
|
||||
// affermativo di salvaDalForm) la suite restava tutta verde — provato. Il consenso dato è
|
||||
// l'unico documento che un domani si esibisce a chi chiede conto del trattamento, tanto
|
||||
// quanto il rifiuto (già coperto sopra).
|
||||
it('registra il consenso anche quando e dato: e il documento che un domani va esibito', () => {
|
||||
const db = dbPronto();
|
||||
const esito = salvaDalForm(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte, consensoSanitario: 'Si', liberi: {},
|
||||
});
|
||||
expect(esito.ok).toBe(true);
|
||||
const note = db.prepare(
|
||||
`SELECT campo_id, testo, sessione_id FROM profilo_note WHERE campo_id = 'consenso_sanitario'`
|
||||
).all() as { campo_id: string; testo: string; sessione_id: number | null }[];
|
||||
expect(note.length).toBe(1);
|
||||
expect(note[0].testo).toMatch(/Si/);
|
||||
if (esito.ok) expect(note[0].sessione_id).toBe(esito.sessioneId);
|
||||
});
|
||||
|
||||
// Il record del consenso portava la versione delle DOMANDE (QUEST_VERSION): se cambia il
|
||||
// testo del consenso senza che cambi una domanda, il record non lo diceva. Ora porta una
|
||||
// versione propria (CONSENT_VERSION, questionario.ts), dichiarata accanto a QUEST_VERSION.
|
||||
it('il record del consenso porta CONSENT_VERSION, non QUEST_VERSION', () => {
|
||||
const db = dbPronto();
|
||||
salvaDalForm(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte, consensoSanitario: 'Si', liberi: {},
|
||||
});
|
||||
const nota = db.prepare(
|
||||
`SELECT testo FROM profilo_note WHERE campo_id = 'consenso_sanitario'`
|
||||
).get() as { testo: string };
|
||||
expect(nota.testo).toContain(`consenso ${CONSENT_VERSION}`);
|
||||
expect(nota.testo).not.toMatch(/questionario\s+v/i);
|
||||
// Le due costanti oggi coincidono di valore ('v1.0'): la prova che conta è che sono
|
||||
// DUE COSTANTI DISTINTE (non lo stesso import rietichettato) — non che i valori
|
||||
// divergano oggi.
|
||||
expect(CONSENT_VERSION).not.toBe(undefined);
|
||||
expect(QUEST_VERSION).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('il codice cliente si risolve dalla sessione (codicePerUtenteLoggato)', () => {
|
||||
const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
|
||||
expect(src).toMatch(/codicePerUtente/);
|
||||
});
|
||||
|
||||
// PRIMA questo test cercava, nel sorgente, le stringhe letterali "body.client_code" e
|
||||
// "corpo.client_code" — ma il file chiama la variabile del corpo `data`, non `body` né
|
||||
// `corpo`: la lista di divieti non nominava nessuna variabile che il file usasse
|
||||
// davvero, quindi era vacua per costruzione. Provato: aggiungendo all'endpoint
|
||||
// `if (typeof data.client_code === 'string') codice = data.client_code;` (la vulnerabilità
|
||||
// vera — un cliente scrive nel fascicolo di un altro) la suite restava tutta verde.
|
||||
//
|
||||
// Il fix e' strutturale (il tipo del corpo, in questionario.ts, non ha il campo
|
||||
// client_code — chi lo leggesse da li' non passerebbe tsc --noEmit) e questo e' il test
|
||||
// di comportamento che lo dimostra end-to-end: chiama l'endpoint VERO con un client_code
|
||||
// estraneo nel corpo e verifica dove la sessione finisce scritta.
|
||||
it('un client_code nel corpo della richiesta viene ignorato: la sessione finisce sul cliente vero, mai su quello iniettato', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'lg-api-test-'));
|
||||
const idPath = join(dir, 'identity.db');
|
||||
const lgPath = join(dir, 'longevity.db');
|
||||
const primaId = process.env.IDENTITY_DB_PATH;
|
||||
const primaLg = process.env.LONGEVITY_DB_PATH;
|
||||
try {
|
||||
process.env.IDENTITY_DB_PATH = idPath;
|
||||
process.env.LONGEVITY_DB_PATH = lgPath;
|
||||
|
||||
// Setup con connessioni proprie, sullo stesso file che il singleton dell'endpoint
|
||||
// aprirà (le due variabili d'ambiente sopra), come in anagrafica.test.ts.
|
||||
const id = createIdentityDb(idPath);
|
||||
const lg = createLongevityDb(lgPath);
|
||||
seedRegistro(lg);
|
||||
const codiceVero = creaCliente(id, lg, { nome: 'Vera', cognome: 'Utente', sesso: 'F', user_id: 1 });
|
||||
const codiceAltro = creaCliente(id, lg, { nome: 'Altro', cognome: 'Cliente', sesso: 'M', user_id: 2 });
|
||||
|
||||
const { POST } = await import('../../src/pages/api/longevity/questionario');
|
||||
const richiesta = new Request('http://localhost/api/longevity/questionario', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_code: codiceAltro, // tentativo: scrivere nel fascicolo di un altro cliente
|
||||
risposte: { q_riposato: 8 },
|
||||
consensoSanitario: 'Si',
|
||||
}),
|
||||
});
|
||||
const res = await POST({ request: richiesta, locals: { user: { id: 1 } } } as any);
|
||||
expect(res.status).toBe(201);
|
||||
const corpo = await res.json();
|
||||
|
||||
const riga = lg.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(corpo.sessioneId) as
|
||||
{ client_code: string };
|
||||
expect(riga.client_code).toBe(codiceVero);
|
||||
expect(riga.client_code).not.toBe(codiceAltro);
|
||||
} finally {
|
||||
if (primaId === undefined) delete process.env.IDENTITY_DB_PATH; else process.env.IDENTITY_DB_PATH = primaId;
|
||||
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro } from '../../src/lib/longevity/registro';
|
||||
import { salvaCompilazione, QUEST_VERSION } from '../../src/lib/longevity/questionario';
|
||||
|
||||
function dbPronto() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('compilazione del questionario', () => {
|
||||
it('salva le risposte punteggiate come misure', () => {
|
||||
const db = dbPronto();
|
||||
const s = salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-21',
|
||||
risposte: { q_ore_sonno: 7.5, q_riposato: 8, q_alcol_life: 3 },
|
||||
});
|
||||
const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ? AND fonte = 'questionario'`)
|
||||
.get(s) as { n: number };
|
||||
expect(n.n).toBe(3);
|
||||
});
|
||||
|
||||
it('marchia la compilazione con la versione del questionario', () => {
|
||||
const db = dbPronto();
|
||||
const s = salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } });
|
||||
const row = db.prepare(`SELECT tipo, quest_version FROM sessioni WHERE id = ?`).get(s) as
|
||||
{ tipo: string; quest_version: string };
|
||||
expect(row.tipo).toBe('questionario');
|
||||
expect(row.quest_version).toBe(QUEST_VERSION);
|
||||
});
|
||||
|
||||
it('i campi liberi vanno in profilo_note, non fra le misure', () => {
|
||||
const db = dbPronto();
|
||||
const s = salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-21',
|
||||
risposte: { q_riposato: 6 },
|
||||
liberi: { farmaci: 'nessuno', obiettivi: 'dormire meglio' },
|
||||
});
|
||||
const note = db.prepare(`SELECT campo_id, testo FROM profilo_note WHERE sessione_id = ? ORDER BY campo_id`)
|
||||
.all(s) as { campo_id: string; testo: string }[];
|
||||
expect(note.map((n) => n.campo_id)).toEqual(['farmaci', 'obiettivi']);
|
||||
const misure = db.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(s) as { test_id: string }[];
|
||||
expect(misure.map((m) => m.test_id)).toEqual(['q_riposato']);
|
||||
});
|
||||
|
||||
it('ogni compilazione e un record nuovo, non un aggiornamento', () => {
|
||||
const db = dbPronto();
|
||||
salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } });
|
||||
salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-09-21', risposte: { q_riposato: 9 } });
|
||||
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni WHERE tipo = 'questionario'`).get() as { n: number };
|
||||
expect(n.n).toBe(2);
|
||||
});
|
||||
|
||||
it('una risposta con id sconosciuto fa fallire la compilazione intera', () => {
|
||||
const db = dbPronto();
|
||||
expect(() =>
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-21',
|
||||
risposte: { q_riposato: 6, q_domanda_nuova: 3 },
|
||||
})
|
||||
).toThrow(/q_domanda_nuova/);
|
||||
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||
expect(n.n).toBe(0);
|
||||
});
|
||||
|
||||
it('fallimento a metà non lascia traccia: sessione, misure e note sono atomiche', () => {
|
||||
const db = dbPronto();
|
||||
// Passa due campi liberi: il primo valido (farmaci), il secondo con null (obiettivi).
|
||||
// Il primo insert su profilo_note avviene (per farmaci: 'nessuno'), poi fallisce su
|
||||
// obiettivi quando tenta di eseguire testo.trim() su un null, lanciando un TypeError.
|
||||
// Il rollback della transazione deve annullare anche il primo insert già avvenuto.
|
||||
expect(() =>
|
||||
salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-21',
|
||||
risposte: { q_riposato: 6 },
|
||||
liberi: { farmaci: 'nessuno', obiettivi: null as any }, // null causa TypeError su .trim()
|
||||
})
|
||||
).toThrow();
|
||||
// Verifica che la transazione sia stata rollback: niente sessione, niente misure, niente note.
|
||||
// Se il rollback non funzionasse, almeno il record di farmaci resterebbe in profilo_note.
|
||||
const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||
const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
|
||||
const note = db.prepare(`SELECT COUNT(*) n FROM profilo_note`).get() as { n: number };
|
||||
expect(sessioni.n).toBe(0);
|
||||
expect(misure.n).toBe(0);
|
||||
expect(note.n).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// tests/longevity/referto.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
|
||||
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
||||
import { calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
|
||||
import { refertoDi } from '../../src/lib/longevity/vista';
|
||||
|
||||
function conCompilazione() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db); seedPesi(db, MODEL_VERSION);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||
const s = salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte: { q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10 },
|
||||
});
|
||||
salvaScore(db, s, calcolaSessione(db, s));
|
||||
return { db, s };
|
||||
}
|
||||
|
||||
/**
|
||||
* Come conCompilazione, con IN PIU' una risposta di questionario_sonno (q_riposato).
|
||||
* Serve solo alla prova sul congelamento, che ha bisogno di un asse con PIU' sotto-domini
|
||||
* pesati e copertura parziale non nulla — vedi il commento sopra la mutazione qui sotto per
|
||||
* il perche' conCompilazione da sola non basta.
|
||||
*/
|
||||
function conCoperturaParziale() {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db); seedPesi(db, MODEL_VERSION);
|
||||
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||
const s = salvaCompilazione(db, {
|
||||
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
risposte: {
|
||||
q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10,
|
||||
q_riposato: 8,
|
||||
},
|
||||
});
|
||||
salvaScore(db, s, calcolaSessione(db, s));
|
||||
return { db, s };
|
||||
}
|
||||
|
||||
describe('il referto del cliente', () => {
|
||||
it('restituisce i sette assi', () => {
|
||||
const { db, s } = conCompilazione();
|
||||
expect(refertoDi(db, s).assi.length).toBe(7);
|
||||
});
|
||||
|
||||
it('un asse senza dati NON porta un numero', () => {
|
||||
const { db, s } = conCompilazione();
|
||||
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
|
||||
expect(forza.valore).toBeNull();
|
||||
});
|
||||
|
||||
it('e dice cosa manca per completarlo, invece di lasciare un buco', () => {
|
||||
const { db, s } = conCompilazione();
|
||||
const forza = refertoDi(db, s).assi.find((a) => a.nome === 'Forza & Struttura')!;
|
||||
expect(forza.mancano.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('espone la copertura della Fitness Age anche quando è insufficiente (serve alla nota in pagina)', () => {
|
||||
// conCompilazione() non porta nessuna misura del composito Fitness Age (cardio,
|
||||
// handgrip, hrv, forza_resto, composizione, stabilita: nessuno di questi sotto-domini
|
||||
// ha dati in questa sessione, solo il questionario), quindi fitnessAge è null — ma
|
||||
// PRIMA di questa correzione refertoDi scartava anche la copertura insieme al valore:
|
||||
// la pagina non aveva modo di dire "41%", solo di dire "manca".
|
||||
const { db, s } = conCompilazione();
|
||||
const referto = refertoDi(db, s);
|
||||
expect(referto.fitnessAge).toBeNull();
|
||||
expect(referto.fitnessAgeCopertura).toBe(0);
|
||||
});
|
||||
|
||||
it('un asse coperto porta il suo numero', () => {
|
||||
const { db, s } = conCompilazione();
|
||||
const stile = refertoDi(db, s).assi.find((a) => a.nome === 'Stile di Vita & Sonno')!;
|
||||
expect(stile.valore).not.toBeNull();
|
||||
expect(stile.mancano).toEqual([]);
|
||||
});
|
||||
|
||||
it('legge i punteggi congelati, non li ricalcola al volo', () => {
|
||||
const { db, s } = conCoperturaParziale();
|
||||
const prima = JSON.stringify(refertoDi(db, s));
|
||||
// ATTENZIONE alla scelta della mutazione: non una qualunque, una che DISCRIMINA
|
||||
// davvero un ricalcolo da una lettura congelata.
|
||||
//
|
||||
// 'Recupero & Sistema Nervoso' pesa QUATTRO sotto-domini (hrv 0.50, pressione 0.15,
|
||||
// hrr 0.15, questionario_sonno 0.20); questa sessione ne misura UNO solo
|
||||
// (questionario_sonno, via q_riposato in conCoperturaParziale) — copertura vera
|
||||
// 0.20/1.00=0.20, sotto COPERTURA_MINIMA (0.4): l'asse e' 'insufficiente', ma con
|
||||
// disponibili.length===1, non 0 — la formula del peso viene davvero eseguita, non
|
||||
// bypassata dalla scorciatoia "nessun dato disponibile" di aggrega() in cascata.ts.
|
||||
// Azzerando il peso di 'hrv' (un sotto-dominio ASSENTE) il totale scende a 0.50 e la
|
||||
// copertura vera diventa 0.20/0.50=0.40: attraversa la soglia, l'asse passerebbe da
|
||||
// insufficiente a ok SE il referto ricalcolasse (provato: vedi task-3-report.md).
|
||||
//
|
||||
// Una mutazione come questa NON discrimina, ed e' un errore da non ripetere:
|
||||
// il peso dell'UNICO sotto-dominio di un asse a un solo elemento (es.
|
||||
// 'questionario_lifestyle', 1.00 in 'Stile di Vita & Sonno') si semplifica sempre,
|
||||
// sia al numeratore sia al denominatore della media pesata — cambiarlo non altera
|
||||
// ne' il valore ne' la copertura, quindi il test resterebbe verde anche se
|
||||
// refertoDi ricalcolasse davvero (era il bug di questo stesso test, corretto qui).
|
||||
db.prepare(
|
||||
`UPDATE pesi SET peso = 0 WHERE model_version = ? AND livello='asse' AND contenitore='Recupero & Sistema Nervoso' AND elemento='hrv'`
|
||||
).run(MODEL_VERSION);
|
||||
expect(JSON.stringify(refertoDi(db, s))).toBe(prima);
|
||||
});
|
||||
|
||||
it('nessuna pagina interroga il database da sola', () => {
|
||||
// Scandisce TUTTE le pagine sotto src/pages/longevity/, non un elenco scritto qui:
|
||||
// un elenco di due nomi non protegge una pagina nuova, che passerebbe indisturbata
|
||||
// anche con una query dentro (verificato: una prova-query.astro con un
|
||||
// db.prepare('SELECT 1') non veniva vista dalla versione precedente di questo test).
|
||||
const dir = join(process.cwd(), 'src/pages/longevity');
|
||||
const pagine = (readdirSync(dir, { recursive: true }) as string[])
|
||||
.filter((f) => f.endsWith('.astro'));
|
||||
expect(pagine.length).toBeGreaterThan(0); // se la cartella si svuota, il test non deve tacere per errore
|
||||
for (const f of pagine) {
|
||||
const percorso = `src/pages/longevity/${f}`;
|
||||
const src = readFileSync(join(dir, f), 'utf8');
|
||||
expect(src, `${percorso} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
import { seedRegistro, seedPesi, testAttivi, esisteTest } from '../../src/lib/longevity/registro';
|
||||
|
||||
describe('registro dei test', () => {
|
||||
it('carica i 20 campi punteggiati del questionario', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
const q = db.prepare(`SELECT COUNT(*) n FROM registro_test WHERE test_id LIKE 'q_%'`).get() as { n: number };
|
||||
expect(q.n).toBe(20);
|
||||
});
|
||||
|
||||
it('alcol_life porta la soglia 7-14 voluta da Nicola', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
const row = db.prepare(`SELECT curva, params FROM registro_test WHERE test_id = 'q_alcol_life'`)
|
||||
.get() as { curva: string; params: string };
|
||||
expect(row.curva).toBe('lin_dec');
|
||||
expect(JSON.parse(row.params)).toEqual({ best: 7, worst: 14 });
|
||||
});
|
||||
|
||||
it('calo_pomeridiano usa la curva invertita, la quinta che al Python manca', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
const row = db.prepare(`SELECT curva FROM registro_test WHERE test_id = 'q_calo_pomeridiano'`)
|
||||
.get() as { curva: string };
|
||||
expect(row.curva).toBe('x10_inv');
|
||||
});
|
||||
|
||||
it('un test disattivato non e piu attivo dopo la sua data', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
db.prepare(`UPDATE registro_test SET attivo_a = '2026-08-01' WHERE test_id = 'q_sigarette'`).run();
|
||||
const attivi = testAttivi(db, '2026-08-21').map((t) => t.test_id);
|
||||
expect(attivi).not.toContain('q_sigarette');
|
||||
expect(testAttivi(db, '2026-07-01').map((t) => t.test_id)).toContain('q_sigarette');
|
||||
});
|
||||
|
||||
it('esisteTest distingue un test vero da uno inventato', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedRegistro(db);
|
||||
expect(esisteTest(db, 'q_ore_sonno')).toBe(true);
|
||||
expect(esisteTest(db, 'q_inventato')).toBe(false);
|
||||
});
|
||||
|
||||
it('i pesi di ogni asse sommano a 1', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedPesi(db, 'v1.0');
|
||||
const righe = db.prepare(
|
||||
`SELECT contenitore, ROUND(SUM(peso), 6) tot FROM pesi
|
||||
WHERE model_version = 'v1.0' AND livello = 'asse' GROUP BY contenitore`
|
||||
).all() as { contenitore: string; tot: number }[];
|
||||
expect(righe.length).toBe(7);
|
||||
for (const r of righe) expect(r.tot).toBe(1);
|
||||
});
|
||||
|
||||
it('i pesi sono legati alla versione del modello', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedPesi(db, 'v1.0');
|
||||
seedPesi(db, 'v2.0');
|
||||
const n = db.prepare(`SELECT COUNT(DISTINCT model_version) n FROM pesi`).get() as { n: number };
|
||||
expect(n.n).toBe(2);
|
||||
});
|
||||
|
||||
it('il plank pesa su due assi: va visto, non nascosto', () => {
|
||||
const db = createLongevityDb(':memory:');
|
||||
seedPesi(db, 'v1.0');
|
||||
const righe = db.prepare(
|
||||
`SELECT contenitore FROM pesi WHERE model_version='v1.0' AND livello='asse' AND elemento IN ('core','plank')`
|
||||
).all() as { contenitore: string }[];
|
||||
expect(righe.map((r) => r.contenitore).sort())
|
||||
.toEqual(['Forza & Struttura', 'Stabilità & Mobilità Funzionale']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
# L'oracolo del motore di calcolo
|
||||
|
||||
`isl_scoring_engine.py` è il motore di punteggio **scritto dal cliente** (In-Sanity Lab),
|
||||
ricevuto il 2026-08-21. Non è codice nostro e non va modificato: sta qui perché è
|
||||
l'**oracolo** contro cui verifichiamo il porting in TypeScript.
|
||||
|
||||
Il porting delle curve è la parte più delicata della piattaforma: un coefficiente
|
||||
trascritto male produce un punteggio sanitario sbagliato che sembra plausibile e che
|
||||
nessun test funzionale intercetterebbe, perché diventerebbe il valore "atteso" di tutto
|
||||
ciò che viene dopo. La fedeltà non è garantita dalla trascrizione, ma dal confronto
|
||||
numerico: `genera-riferimento.py` esegue questo motore su una griglia di casi e scrive
|
||||
`riferimento.json`, che il test TypeScript rilegge per confrontare i due risultati.
|
||||
|
||||
Serve `python3` (solo libreria standard: il motore importa unicamente `dataclasses` e
|
||||
`typing`).
|
||||
|
||||
## Rigenerare il riferimento
|
||||
|
||||
python3 tests/longevity/riferimento/genera-riferimento.py
|
||||
|
||||
Va rifatto solo se il cliente manda una versione nuova del motore. In quel caso il
|
||||
confronto dirà da sé quali curve sono cambiate.
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Esegue il motore del cliente su una griglia fitta di ingressi e scrive i risultati
|
||||
in riferimento.json. È il metro contro cui si misura il porting in TypeScript:
|
||||
non serve a provare che l'oracolo è giusto, ma che il nostro porting gli è fedele.
|
||||
|
||||
Uso: python3 tests/longevity/riferimento/genera-riferimento.py
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
QUI = pathlib.Path(__file__).parent
|
||||
sys.path.insert(0, str(QUI))
|
||||
|
||||
import isl_scoring_engine as m
|
||||
|
||||
casi = []
|
||||
|
||||
|
||||
def prova(fn_nome, *args):
|
||||
"""Esegue una funzione dell'oracolo e registra ingressi e uscita."""
|
||||
fn = getattr(m, fn_nome)
|
||||
casi.append({"fn": fn_nome, "args": list(args), "atteso": fn(*args)})
|
||||
|
||||
|
||||
def griglia(inizio, fine, passo):
|
||||
"""Valori da inizio a fine compreso, con arrotondamento pulito."""
|
||||
n, v = [], inizio
|
||||
while v <= fine + 1e-9:
|
||||
n.append(round(v, 4))
|
||||
v += passo
|
||||
return n
|
||||
|
||||
|
||||
# --- curve generiche del questionario: tutto il dominio, non un campione ---
|
||||
for v in griglia(0, 14, 0.5):
|
||||
prova("score_bell_curve", v, 4, 7, 9, 12) # q_ore_sonno
|
||||
prova("score_decreasing", v, 0, 7) # q_caffeina e gemelle
|
||||
prova("score_decreasing", v, 7, 14) # q_alcol_life: la soglia voluta
|
||||
for v in griglia(0, 10, 0.5):
|
||||
prova("score_direct_x10", v)
|
||||
for v in griglia(0, 7, 0.5):
|
||||
prova("score_increasing_plateau", v, 0, 4)
|
||||
prova("score_increasing_plateau", v, 2, 6) # worst != 0: l'offset e' l'unico parametro a rischio refuso
|
||||
|
||||
# --- composizione corporea: attorno ai confini delle bande ---
|
||||
for sesso in ("M", "F"):
|
||||
for v in griglia(0, 45, 0.5):
|
||||
prova("score_fat_percent", v, sesso)
|
||||
for v in griglia(20, 60, 0.5):
|
||||
prova("score_muscle_percent", v, sesso)
|
||||
for v in griglia(0.5, 1.3, 0.01):
|
||||
prova("score_whr", v, sesso)
|
||||
|
||||
# --- cardio ---
|
||||
for v in griglia(85, 100, 1):
|
||||
prova("score_spo2", v)
|
||||
for sbp in griglia(90, 200, 5):
|
||||
for dbp in griglia(50, 120, 5):
|
||||
prova("score_blood_pressure", sbp, dbp)
|
||||
for v in griglia(0, 40, 1):
|
||||
prova("score_hrr", v)
|
||||
for sesso in ("M", "F"):
|
||||
for eta in griglia(20, 75, 5):
|
||||
for v in griglia(15, 70, 2.5):
|
||||
prova("score_vo2max", v, eta, sesso)
|
||||
for v in griglia(120, 200, 5):
|
||||
prova("vo2max_from_step_test", v, "M")
|
||||
prova("vo2max_from_step_test", v, "F")
|
||||
for v in griglia(100, 300, 10):
|
||||
prova("vo2max_from_mutt", v)
|
||||
for w in griglia(50, 200, 10):
|
||||
prova("vo2max_from_milfit", w, 75)
|
||||
# caso base + variazione di un parametro alla volta: un punto solo non separa i 5 coefficienti
|
||||
BASE_2KM = (15.5, 130, 40, 24) # tempo_min, hr, eta, bmi
|
||||
prova("vo2max_from_2km_walk", *BASE_2KM)
|
||||
for tempo in griglia(10, 25, 2.5):
|
||||
prova("vo2max_from_2km_walk", tempo, BASE_2KM[1], BASE_2KM[2], BASE_2KM[3])
|
||||
for hr in griglia(100, 160, 10):
|
||||
prova("vo2max_from_2km_walk", BASE_2KM[0], hr, BASE_2KM[2], BASE_2KM[3])
|
||||
for eta in griglia(20, 75, 5):
|
||||
prova("vo2max_from_2km_walk", BASE_2KM[0], BASE_2KM[1], eta, BASE_2KM[3])
|
||||
for bmi in griglia(18, 32, 2):
|
||||
prova("vo2max_from_2km_walk", BASE_2KM[0], BASE_2KM[1], BASE_2KM[2], bmi)
|
||||
|
||||
# --- forza ---
|
||||
for sesso in ("M", "F"):
|
||||
for eta in griglia(25, 75, 5):
|
||||
for v in griglia(10, 70, 2.5):
|
||||
prova("score_handgrip", v, eta, sesso)
|
||||
for v in griglia(0, 60, 2):
|
||||
prova("score_pushup", v, eta, sesso)
|
||||
for v in griglia(0, 250, 5):
|
||||
prova("score_plank", v, sesso)
|
||||
confini_plank = [79, 97, 122, 157, 201] if sesso == "M" else [35, 63, 84, 108, 142]
|
||||
for v in confini_plank: # confini esatti delle bande: costano zero, tolgono ogni dubbio
|
||||
prova("score_plank", v, sesso)
|
||||
for v in griglia(0, 120, 5):
|
||||
prova("score_flexed_arm_hang", v, sesso)
|
||||
for v in griglia(0, 60, 2):
|
||||
prova("score_sit_to_stand_1min", v, 40, sesso)
|
||||
for v in griglia(-30, 30, 1):
|
||||
prova("score_sit_and_reach", v, sesso)
|
||||
for eta in griglia(25, 75, 5):
|
||||
for v in griglia(-30, 20, 2):
|
||||
prova("score_back_scratch", v, eta, sesso)
|
||||
|
||||
# --- sollevamenti sui rapporti col peso corporeo ---
|
||||
for sesso in ("M", "F"):
|
||||
for carico in griglia(20, 200, 10):
|
||||
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_BENCH_M, m.TIERS_BENCH_F, sesso)
|
||||
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_SQUAT_M, m.TIERS_SQUAT_F, sesso)
|
||||
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_ROW_M, m.TIERS_ROW_F, sesso)
|
||||
|
||||
# --- stabilità ---
|
||||
for v in griglia(0, 30, 1):
|
||||
prova("score_flamingo", v)
|
||||
for a in griglia(0, 180, 10):
|
||||
prova("score_shoulder_mobility_wt", a, a)
|
||||
|
||||
uscita = QUI / "riferimento.json"
|
||||
uscita.write_text(json.dumps(
|
||||
{"generato_da": "isl_scoring_engine.py", "casi": casi},
|
||||
indent=1, ensure_ascii=False,
|
||||
))
|
||||
print(f"{len(casi)} casi scritti in {uscita}")
|
||||
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
IN-SANITY LAB — Motore di calcolo Longevity Score
|
||||
===================================================
|
||||
|
||||
Implementa la cascata a 4 livelli definita nel foglio "Pesi e Formule" del
|
||||
Registro Test:
|
||||
|
||||
1. Sotto-metrica grezza -> punteggio 0-100 (normalizzazione test-specifica)
|
||||
2. Punteggi sotto-metrica -> punteggio Asse (media pesata, rinormalizzata
|
||||
se mancano dati)
|
||||
3. Punteggi Asse -> 3 Macro-score (Performance / Energy / Recovery)
|
||||
4. Punteggi Asse -> Fitness Age
|
||||
|
||||
Regola di copertura (uguale a ogni livello): se un elemento manca, il suo
|
||||
peso si ridistribuisce sugli elementi disponibili. Se la copertura di peso
|
||||
disponibile scende sotto il 40%, l'elemento calcolato viene marcato
|
||||
'insufficiente' invece che pieno.
|
||||
|
||||
Questo modulo NON include l'interfaccia (dashboard) - produce solo i numeri.
|
||||
Si integra con:
|
||||
- parse_wellness_tower.py (dati automatici Composizione/Cardio/Recupero)
|
||||
- analyze_calibre.py (VO2max diretto, gia' esistente)
|
||||
- inserimento manuale / futura web app (resto dei test)
|
||||
|
||||
Uso rapido: vedi la funzione demo() in fondo al file.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
MIN_COVERAGE = 0.40 # sotto questa soglia di peso disponibile, l'elemento e' 'insufficiente'
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# LIVELLO 1 — Normalizzazione sotto-metrica -> 0-100
|
||||
# =========================================================================
|
||||
|
||||
def clamp(x, lo=0, hi=100):
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def _lerp(x, x0, x1, y0, y1):
|
||||
if x1 == x0:
|
||||
return y0
|
||||
t = (x - x0) / (x1 - x0)
|
||||
t = max(0, min(1, t))
|
||||
return y0 + t * (y1 - y0)
|
||||
|
||||
|
||||
# --- Composizione Corporea -------------------------------------------------
|
||||
|
||||
def score_fat_percent(fat_pct, sex):
|
||||
"""Curva a campana ACE. Il picco e' su Atleti/Fitness, non sul grasso piu basso."""
|
||||
m = sex.strip().lower().startswith("m")
|
||||
bands = [
|
||||
(0, 2, 40), (2, 5, 65), (5, 13, 100), (13, 17, 90), (17, 24, 65), (24, 35, 30), (35, 200, 10),
|
||||
] if m else [
|
||||
(0, 10, 40), (10, 13, 65), (13, 20, 100), (20, 24, 90), (24, 31, 65), (31, 40, 30), (40, 200, 10),
|
||||
]
|
||||
for lo, hi, val in bands:
|
||||
if lo <= fat_pct < hi:
|
||||
return val
|
||||
return 50
|
||||
|
||||
|
||||
def score_muscle_percent(muscle_pct, sex):
|
||||
"""Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device)."""
|
||||
lo, hi = (42.9, 52.4) if sex.strip().lower().startswith("m") else (37.8, 46.2)
|
||||
if muscle_pct >= hi:
|
||||
return 100
|
||||
if muscle_pct <= lo - 10:
|
||||
return 20
|
||||
return round(_lerp(muscle_pct, lo - 10, hi, 20, 100), 1)
|
||||
|
||||
|
||||
def score_whr(whr, sex):
|
||||
threshold = 0.90 if sex.strip().lower().startswith("m") else 0.85
|
||||
if whr <= threshold - 0.10:
|
||||
return 100
|
||||
if whr <= threshold:
|
||||
return round(_lerp(whr, threshold - 0.10, threshold, 100, 60), 1)
|
||||
return clamp(round(60 - (whr - threshold) * 300, 1))
|
||||
|
||||
|
||||
# --- Cardio-Respiratorio ----------------------------------------------------
|
||||
|
||||
def vo2max_from_step_test(hr_recovery, sex):
|
||||
"""Queen's College Step Test (McArdle et al. 1972)."""
|
||||
if sex.strip().lower().startswith("m"):
|
||||
return 111.33 - 0.42 * hr_recovery
|
||||
return 65.81 - 0.1847 * hr_recovery
|
||||
|
||||
|
||||
def vo2max_from_2km_walk(tempo_min, hr, eta, bmi):
|
||||
"""UKK 2km Walking Test (Laukkanen/Oja)."""
|
||||
return 116.2 - 2.98 * tempo_min - 0.11 * hr - 0.14 * eta - 0.39 * bmi
|
||||
|
||||
|
||||
def vo2max_from_mutt(vam_m_min):
|
||||
"""Formula ACSM su VAM raggiunta al tapis. Etichettare come 'teorico/stimato'."""
|
||||
return 0.2 * vam_m_min + 3.5
|
||||
|
||||
|
||||
def vo2max_from_milfit(watt, peso_kg):
|
||||
"""Formula ACSM cicloergometro. Etichettare come 'teorico/stimato'. Valida 50-200W."""
|
||||
return (10.8 * watt) / peso_kg + 7
|
||||
|
||||
|
||||
def score_vo2max(vo2max, eta, sex):
|
||||
"""ACSM/FRIEND Registry, bande approssimate per fascia d'eta e sesso."""
|
||||
m = sex.strip().lower().startswith("m")
|
||||
# soglie 'buono' per decade (uomini), interpolate da FRIEND Registry
|
||||
good_thresholds_m = {20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30}
|
||||
good_thresholds_f = {20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23}
|
||||
table = good_thresholds_m if m else good_thresholds_f
|
||||
decade = min(70, max(20, (eta // 10) * 10))
|
||||
good = table[decade]
|
||||
poor = good * 0.65
|
||||
superior = good * 1.35
|
||||
if vo2max <= poor:
|
||||
return clamp(round(_lerp(vo2max, 0, poor, 10, 40), 1))
|
||||
if vo2max <= good:
|
||||
return round(_lerp(vo2max, poor, good, 40, 70), 1)
|
||||
return clamp(round(_lerp(vo2max, good, superior, 70, 100), 1))
|
||||
|
||||
|
||||
def score_spo2(spo2_pct):
|
||||
"""Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico."""
|
||||
if spo2_pct >= 97:
|
||||
return 100
|
||||
if spo2_pct >= 95:
|
||||
return 85
|
||||
if spo2_pct >= 90:
|
||||
return 50
|
||||
return 15
|
||||
|
||||
|
||||
# --- Recupero & Sistema Nervoso --------------------------------------------
|
||||
|
||||
def score_blood_pressure(sbp, dbp):
|
||||
"""ESC/ESH 2018. Vale la categoria piu alta tra sistolica e diastolica."""
|
||||
if sbp >= 180 or dbp >= 110:
|
||||
return 5
|
||||
if sbp >= 160 or dbp >= 100:
|
||||
return 25
|
||||
if sbp >= 140 or dbp >= 90:
|
||||
return 45
|
||||
if sbp >= 130 or dbp >= 85:
|
||||
return 70
|
||||
if sbp >= 120 or dbp >= 80:
|
||||
return 90
|
||||
return 100
|
||||
|
||||
|
||||
def score_hrr(calo_bpm_1min):
|
||||
"""Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999)."""
|
||||
if calo_bpm_1min >= 12:
|
||||
return clamp(round(_lerp(calo_bpm_1min, 12, 30, 70, 100), 1))
|
||||
return clamp(round(_lerp(calo_bpm_1min, 0, 12, 20, 70), 1))
|
||||
|
||||
|
||||
# --- Forza & Struttura -------------------------------------------------------
|
||||
|
||||
def score_handgrip(kg, eta, sex):
|
||||
"""Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'eta."""
|
||||
m = sex.strip().lower().startswith("m")
|
||||
# ancore (eta, valore medio kg) approssimate dalla letteratura citata
|
||||
anchors_m = [(25, 49.7), (40, 46), (60, 38), (75, 30)]
|
||||
anchors_f = [(25, 30), (40, 28), (60, 24), (75, 18.7)]
|
||||
anchors = anchors_m if m else anchors_f
|
||||
eta_c = clamp(eta, 25, 75)
|
||||
for i in range(len(anchors) - 1):
|
||||
a_eta, a_val = anchors[i]
|
||||
b_eta, b_val = anchors[i + 1]
|
||||
if a_eta <= eta_c <= b_eta:
|
||||
mean_val = _lerp(eta_c, a_eta, b_eta, a_val, b_val)
|
||||
break
|
||||
else:
|
||||
mean_val = anchors[-1][1]
|
||||
# media = punteggio 70; +/- 40% della media copre la banda 20-100
|
||||
ratio = kg / mean_val if mean_val else 1
|
||||
return clamp(round(_lerp(ratio, 0.5, 1.5, 20, 100), 1))
|
||||
|
||||
|
||||
def score_pushup(reps, eta, sex):
|
||||
"""ACSM/CSEP, bande approssimate per decade."""
|
||||
m = sex.strip().lower().startswith("m")
|
||||
base_good_m, base_good_f = 22, 15 # 35-39 anni, 'buono' minimo
|
||||
decade_offset = max(0, (eta - 35) // 10) * 2.5 # calo ~2.5 rip/decade dopo i 35
|
||||
good = (base_good_m if m else base_good_f) - decade_offset
|
||||
superior = good * 1.6
|
||||
poor = good * 0.5
|
||||
if reps <= poor:
|
||||
return clamp(round(_lerp(reps, 0, poor, 10, 40), 1))
|
||||
if reps <= good:
|
||||
return round(_lerp(reps, poor, good, 40, 70), 1)
|
||||
return clamp(round(_lerp(reps, good, superior, 70, 100), 1))
|
||||
|
||||
|
||||
def score_bw_ratio_lift(carico_kg, peso_corporeo_kg, rip, tiers_m, tiers_f, sex):
|
||||
"""Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite)."""
|
||||
rm1 = carico_kg * 36 / (37 - rip) # Brzycki
|
||||
ratio = rm1 / peso_corporeo_kg
|
||||
tiers = tiers_m if sex.strip().lower().startswith("m") else tiers_f
|
||||
# tiers = [(soglia_ratio, punteggio), ...] crescente
|
||||
prev_r, prev_s = 0, 10
|
||||
for r, s in tiers:
|
||||
if ratio <= r:
|
||||
return round(_lerp(ratio, prev_r, r, prev_s, s), 1)
|
||||
prev_r, prev_s = r, s
|
||||
return 100
|
||||
|
||||
|
||||
TIERS_BENCH_M = [(0.5, 30), (1.0, 60), (1.25, 80), (1.5, 100)]
|
||||
TIERS_BENCH_F = [(0.3, 30), (0.6, 60), (0.75, 80), (1.0, 100)]
|
||||
TIERS_SQUAT_M = [(0.75, 30), (1.5, 60), (1.75, 80), (2.0, 100)]
|
||||
TIERS_SQUAT_F = [(0.5, 30), (1.1, 60), (1.3, 80), (1.5, 100)]
|
||||
TIERS_ROW_M = [(0.45, 30), (0.70, 60), (0.95, 80), (1.20, 100)]
|
||||
TIERS_ROW_F = [(0.30, 30), (0.45, 60), (0.60, 80), (0.80, 100)]
|
||||
|
||||
|
||||
def score_flexed_arm_hang(sec, sex):
|
||||
good, superior = (45, 70) if sex.strip().lower().startswith("m") else (25, 50)
|
||||
if sec <= good * 0.4:
|
||||
return clamp(round(_lerp(sec, 0, good * 0.4, 10, 40), 1))
|
||||
if sec <= good:
|
||||
return round(_lerp(sec, good * 0.4, good, 40, 70), 1)
|
||||
return clamp(round(_lerp(sec, good, superior, 70, 100), 1))
|
||||
|
||||
|
||||
def score_sit_to_stand_1min(reps, eta, sex, bmi=24):
|
||||
"""Reference equation adulti 18-95 (Zalewski et al.-style)."""
|
||||
sex_code = 0 if sex.strip().lower().startswith("m") else 1
|
||||
predicted = 61.53 - 0.34 * eta - 3.57 * sex_code - 0.33 * bmi
|
||||
ratio = reps / predicted if predicted else 1
|
||||
return clamp(round(_lerp(ratio, 0.5, 1.3, 20, 100), 1))
|
||||
|
||||
|
||||
def score_plank(sec, sex):
|
||||
bands_m = [(79, 20), (97, 40), (122, 55), (157, 75), (201, 90)]
|
||||
bands_f = [(35, 20), (63, 40), (84, 55), (108, 75), (142, 90)]
|
||||
bands = bands_m if sex.strip().lower().startswith("m") else bands_f
|
||||
prev_t, prev_s = 0, 10
|
||||
for t, s in bands:
|
||||
if sec <= t:
|
||||
return round(_lerp(sec, prev_t, t, prev_s, s), 1)
|
||||
prev_t, prev_s = t, s
|
||||
return 100
|
||||
|
||||
|
||||
# --- Stabilità & Mobilità ----------------------------------------------------
|
||||
|
||||
def score_back_scratch(cm, eta, sex):
|
||||
"""Rikli & Jones (Senior Fitness Test) + studio norvegese per fascia under 60.
|
||||
Positivo=sovrapposizione dita, negativo=distanza. Anchor a 62 anni (dato solido),
|
||||
estrapolato linearmente (~4cm/5 anni) per le altre eta. Approssimazione da
|
||||
dichiarare, non tabella completa."""
|
||||
m = sex.strip().lower().startswith("m")
|
||||
anchor_eta, anchor_cm = 62, (-8.6 if m else -1.8)
|
||||
decline_per_year = 0.8 # cm peggioramento per anno di eta in piu
|
||||
expected = anchor_cm - (eta - anchor_eta) * decline_per_year
|
||||
# punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo
|
||||
diff = cm - expected
|
||||
return clamp(round(_lerp(diff, -15, 15, 20, 100), 1))
|
||||
|
||||
|
||||
def score_shoulder_mobility_wt(outreach_deg, buckling_deg):
|
||||
"""Wellness Tower, riferimento 180 gradi (outreach/buckling)."""
|
||||
avg = (outreach_deg + buckling_deg) / 2
|
||||
return clamp(round(avg / 180 * 100, 1))
|
||||
|
||||
|
||||
def score_flamingo(cadute):
|
||||
"""Decrescente: meno cadute = meglio."""
|
||||
if cadute <= 3:
|
||||
return 100
|
||||
if cadute <= 7:
|
||||
return round(_lerp(cadute, 3, 7, 100, 80), 1)
|
||||
if cadute <= 15:
|
||||
return round(_lerp(cadute, 7, 15, 80, 50), 1)
|
||||
return clamp(round(_lerp(cadute, 15, 30, 50, 10), 1))
|
||||
|
||||
|
||||
def score_sit_and_reach(cm, sex):
|
||||
median = 24 if sex.strip().lower().startswith("m") else 31
|
||||
return clamp(round(_lerp(cm, median - 20, median + 10, 20, 100), 1))
|
||||
|
||||
|
||||
def score_agility_ms(ms, eta, sex):
|
||||
"""Blomkvist et al. 2017, decrescente (meno ms = meglio)."""
|
||||
base = 250 if sex.strip().lower().startswith("m") else 265
|
||||
decade_add = max(0, (eta - 30) // 10) * 35
|
||||
expected = base + decade_add
|
||||
ratio = expected / ms if ms else 1
|
||||
return clamp(round(_lerp(ratio, 0.7, 1.3, 20, 100), 1))
|
||||
|
||||
|
||||
# --- Questionario: curve generiche -----------------------------------------
|
||||
|
||||
def score_bell_curve(value, low, peak_low, peak_high, high):
|
||||
"""Curva a campana: picco tra peak_low e peak_high, decresce ai lati."""
|
||||
if peak_low <= value <= peak_high:
|
||||
return 100
|
||||
if value < peak_low:
|
||||
return clamp(round(_lerp(value, low, peak_low, 10, 100), 1))
|
||||
return clamp(round(_lerp(value, peak_high, high, 100, 10), 1))
|
||||
|
||||
|
||||
def score_decreasing(value, best, worst):
|
||||
return clamp(round(_lerp(value, best, worst, 100, 0), 1))
|
||||
|
||||
|
||||
def score_increasing_plateau(value, worst, plateau_start):
|
||||
return clamp(round(_lerp(value, worst, plateau_start, 20, 100), 1))
|
||||
|
||||
|
||||
def score_direct_x10(value_0_10):
|
||||
return clamp(round(value_0_10 * 10, 1))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# LIVELLO 2 — Sotto-metriche -> punteggio Asse
|
||||
# =========================================================================
|
||||
|
||||
@dataclass
|
||||
class WeightedScore:
|
||||
score: Optional[float] # None se dato mancante
|
||||
weight: float # peso nominale (dalla config)
|
||||
|
||||
|
||||
def aggregate(items: list[WeightedScore]):
|
||||
"""Rinormalizza sui pesi disponibili. Ritorna (punteggio, copertura, stato)."""
|
||||
total_weight = sum(i.weight for i in items)
|
||||
available = [i for i in items if i.score is not None]
|
||||
available_weight = sum(i.weight for i in available)
|
||||
coverage = available_weight / total_weight if total_weight else 0
|
||||
|
||||
if not available:
|
||||
return None, 0.0, "insufficiente"
|
||||
|
||||
weighted_sum = sum(i.score * i.weight for i in available)
|
||||
score = weighted_sum / available_weight
|
||||
|
||||
status = "insufficiente" if coverage < MIN_COVERAGE else "ok"
|
||||
return round(score, 1), round(coverage, 2), status
|
||||
|
||||
|
||||
# Config pesi sotto-dominio -> asse (dal foglio "Pesi e Formule")
|
||||
AXIS_SUBDOMAIN_WEIGHTS = {
|
||||
"Forza & Struttura": {
|
||||
"handgrip": 0.25, "spinta": 0.20, "trazione": 0.20, "arti_inferiori": 0.20, "core": 0.15,
|
||||
},
|
||||
"Composizione Corporea": {
|
||||
"grasso": 0.40, "muscolo": 0.35, "whr": 0.25,
|
||||
},
|
||||
"Cardio-Respiratorio": {
|
||||
"vo2max": 0.60, "spirometria": 0.20, "wellness_tower_cardio": 0.20,
|
||||
},
|
||||
"Recupero & Sistema Nervoso": {
|
||||
"hrv": 0.50, "pressione": 0.15, "hrr": 0.15, "questionario_sonno": 0.20,
|
||||
},
|
||||
"Energia & Regolazione Stress": {
|
||||
"hrv": 0.50, "questionario_energia_stress": 0.50,
|
||||
},
|
||||
"Stabilità & Mobilità Funzionale": {
|
||||
"flamingo": 0.35, "sit_and_reach": 0.25, "plank": 0.20, "back_scratch": 0.14, "wellness_tower_shoulder": 0.06,
|
||||
},
|
||||
"Stile di Vita & Sonno": {
|
||||
"questionario_lifestyle": 1.00,
|
||||
},
|
||||
}
|
||||
|
||||
# Config pesi asse -> macro-score
|
||||
MACRO_SCORE_WEIGHTS = {
|
||||
"PERFORMANCE": {
|
||||
"Forza & Struttura": 0.25, "Cardio-Respiratorio": 0.25, "Composizione Corporea": 0.15,
|
||||
"Stabilità & Mobilità Funzionale": 0.20, "Recupero & Sistema Nervoso": 0.10,
|
||||
"Energia & Regolazione Stress": 0.05, "Stile di Vita & Sonno": 0.0,
|
||||
},
|
||||
"ENERGY": {
|
||||
"Energia & Regolazione Stress": 0.30, "Stile di Vita & Sonno": 0.20,
|
||||
"Recupero & Sistema Nervoso": 0.25, "Cardio-Respiratorio": 0.15,
|
||||
"Composizione Corporea": 0.10, "Forza & Struttura": 0.0, "Stabilità & Mobilità Funzionale": 0.0,
|
||||
},
|
||||
"RECOVERY": {
|
||||
"Recupero & Sistema Nervoso": 0.45, "Energia & Regolazione Stress": 0.15,
|
||||
"Stile di Vita & Sonno": 0.20, "Stabilità & Mobilità Funzionale": 0.15,
|
||||
"Forza & Struttura": 0.05, "Cardio-Respiratorio": 0.0, "Composizione Corporea": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
# Config pesi Fitness Age (nota: Handgrip ed HRV entrano isolati, non tramite l'intero asse)
|
||||
FITNESS_AGE_WEIGHTS = {
|
||||
"cardio": 0.30, "handgrip_isolato": 0.20, "hrv_isolato": 0.20,
|
||||
"forza_resto": 0.15, "composizione": 0.10, "stabilita": 0.05,
|
||||
}
|
||||
|
||||
|
||||
def compute_axis(axis_name, subdomain_scores: dict):
|
||||
"""subdomain_scores: {nome_sottodominio: score_0_100 or None}"""
|
||||
weights = AXIS_SUBDOMAIN_WEIGHTS[axis_name]
|
||||
items = [WeightedScore(subdomain_scores.get(k), w) for k, w in weights.items()]
|
||||
score, coverage, status = aggregate(items)
|
||||
return {"score": score, "coverage": coverage, "status": status}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# LIVELLO 3 — 7 Assi -> 3 Macro-score
|
||||
# =========================================================================
|
||||
|
||||
def compute_macro_scores(axis_scores: dict):
|
||||
"""axis_scores: {nome_asse: {'score':..,'status':..}}"""
|
||||
results = {}
|
||||
for macro_name, weights in MACRO_SCORE_WEIGHTS.items():
|
||||
items = [
|
||||
WeightedScore(axis_scores[axis]["score"] if axis_scores[axis]["status"] != "insufficiente" else None, w)
|
||||
for axis, w in weights.items() if w > 0
|
||||
]
|
||||
score, coverage, status = aggregate(items)
|
||||
results[macro_name] = {"score": score, "coverage": coverage, "status": status}
|
||||
return results
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# LIVELLO 4 — Fitness Age
|
||||
# =========================================================================
|
||||
|
||||
def compute_fitness_age(eta_anagrafica, cardio_score, handgrip_score, hrv_score,
|
||||
forza_resto_score, composizione_score, stabilita_score):
|
||||
items = [
|
||||
WeightedScore(cardio_score, FITNESS_AGE_WEIGHTS["cardio"]),
|
||||
WeightedScore(handgrip_score, FITNESS_AGE_WEIGHTS["handgrip_isolato"]),
|
||||
WeightedScore(hrv_score, FITNESS_AGE_WEIGHTS["hrv_isolato"]),
|
||||
WeightedScore(forza_resto_score, FITNESS_AGE_WEIGHTS["forza_resto"]),
|
||||
WeightedScore(composizione_score, FITNESS_AGE_WEIGHTS["composizione"]),
|
||||
WeightedScore(stabilita_score, FITNESS_AGE_WEIGHTS["stabilita"]),
|
||||
]
|
||||
composite, coverage, status = aggregate(items)
|
||||
if composite is None:
|
||||
return {"fitness_age": None, "coverage": coverage, "status": "insufficiente"}
|
||||
fitness_age = eta_anagrafica - (composite - 50) * 0.4
|
||||
return {"fitness_age": round(fitness_age, 1), "composite": composite, "coverage": coverage, "status": status}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# DEMO END-TO-END
|
||||
# =========================================================================
|
||||
|
||||
def demo():
|
||||
"""Esempio completo su un cliente fittizio (M, 38 anni, dati plausibili)."""
|
||||
eta, sex, peso = 38, "M", 90.3
|
||||
|
||||
# --- Livello 1: normalizzazione sotto-metriche ---
|
||||
forza = {
|
||||
"handgrip": score_handgrip(kg=48, eta=eta, sex=sex),
|
||||
"spinta": score_pushup(reps=24, eta=eta, sex=sex),
|
||||
"trazione": score_bw_ratio_lift(80, peso, 5, TIERS_ROW_M, TIERS_ROW_F, sex),
|
||||
"arti_inferiori": score_bw_ratio_lift(140, peso, 5, TIERS_SQUAT_M, TIERS_SQUAT_F, sex),
|
||||
"core": score_plank(sec=110, sex=sex),
|
||||
}
|
||||
composizione = {
|
||||
"grasso": score_fat_percent(12.8, sex),
|
||||
"muscolo": score_muscle_percent(48.8, sex),
|
||||
"whr": score_whr(0.71, sex),
|
||||
}
|
||||
vo2 = vo2max_from_mutt(vam_m_min=230)
|
||||
cardio = {
|
||||
"vo2max": score_vo2max(vo2, eta, sex),
|
||||
"spirometria": 75, # placeholder: da calcolatore GLI dedicato
|
||||
"wellness_tower_cardio": score_spo2(98),
|
||||
}
|
||||
recupero = {
|
||||
"hrv": 68, # placeholder: score gia' normalizzato da ISL HRV Monitor
|
||||
"pressione": score_blood_pressure(114, 81),
|
||||
"hrr": score_hrr(calo_bpm_1min=15),
|
||||
"questionario_sonno": 72, # placeholder aggregato risposte questionario
|
||||
}
|
||||
energia = {
|
||||
"hrv": 65,
|
||||
"questionario_energia_stress": 60,
|
||||
}
|
||||
stabilita = {
|
||||
"flamingo": score_flamingo(cadute=5),
|
||||
"sit_and_reach": score_sit_and_reach(cm=20, sex=sex),
|
||||
"plank": score_plank(sec=110, sex=sex),
|
||||
"back_scratch": score_back_scratch(cm=-5, eta=eta, sex=sex),
|
||||
"wellness_tower_shoulder": score_shoulder_mobility_wt(180, 180),
|
||||
}
|
||||
stile_vita = {
|
||||
"questionario_lifestyle": 70,
|
||||
}
|
||||
|
||||
# --- Livello 2: sotto-metriche -> assi ---
|
||||
axis_scores = {
|
||||
"Forza & Struttura": compute_axis("Forza & Struttura", forza),
|
||||
"Composizione Corporea": compute_axis("Composizione Corporea", composizione),
|
||||
"Cardio-Respiratorio": compute_axis("Cardio-Respiratorio", cardio),
|
||||
"Recupero & Sistema Nervoso": compute_axis("Recupero & Sistema Nervoso", recupero),
|
||||
"Energia & Regolazione Stress": compute_axis("Energia & Regolazione Stress", energia),
|
||||
"Stabilità & Mobilità Funzionale": compute_axis("Stabilità & Mobilità Funzionale", stabilita),
|
||||
"Stile di Vita & Sonno": compute_axis("Stile di Vita & Sonno", stile_vita),
|
||||
}
|
||||
|
||||
print("=== RADAR — 7 ASSI ===")
|
||||
for name, res in axis_scores.items():
|
||||
print(f" {name:35s} {res['score']:>6} (copertura {res['coverage']*100:.0f}%, {res['status']})")
|
||||
|
||||
# --- Livello 3: assi -> 3 macro-score ---
|
||||
macro = compute_macro_scores(axis_scores)
|
||||
print("\n=== MACRO-SCORE ===")
|
||||
for name, res in macro.items():
|
||||
print(f" {name:12s} {res['score']:>6} (copertura {res['coverage']*100:.0f}%, {res['status']})")
|
||||
|
||||
# --- Livello 4: Fitness Age ---
|
||||
fa = compute_fitness_age(
|
||||
eta_anagrafica=eta,
|
||||
cardio_score=axis_scores["Cardio-Respiratorio"]["score"],
|
||||
handgrip_score=forza["handgrip"],
|
||||
hrv_score=recupero["hrv"],
|
||||
forza_resto_score=axis_scores["Forza & Struttura"]["score"],
|
||||
composizione_score=axis_scores["Composizione Corporea"]["score"],
|
||||
stabilita_score=axis_scores["Stabilità & Mobilità Funzionale"]["score"],
|
||||
)
|
||||
print(f"\n=== FITNESS AGE ===")
|
||||
print(f" Eta anagrafica: {eta} -> Fitness Age: {fa['fitness_age']} (composite {fa['composite']}, copertura {fa['coverage']*100:.0f}%)")
|
||||
|
||||
|
||||
def score_generic_range_local(value, low, high):
|
||||
if high == low:
|
||||
return 50
|
||||
pct = (value - low) / (high - low)
|
||||
return clamp(round(pct * 100, 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { safeNext } from '../src/lib/safe-next';
|
||||
|
||||
describe('safeNext', () => {
|
||||
it('accetta path interni', () => {
|
||||
expect(safeNext('/about')).toBe('/about');
|
||||
expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1');
|
||||
});
|
||||
it('rifiuta esterni, protocol-relative, api e vuoti', () => {
|
||||
expect(safeNext(null)).toBe('/');
|
||||
expect(safeNext('')).toBe('/');
|
||||
expect(safeNext('//evil.com')).toBe('/');
|
||||
expect(safeNext('https://evil.com')).toBe('/');
|
||||
expect(safeNext('/api/admin/posts')).toBe('/');
|
||||
expect(safeNext('javascript:alert(1)')).toBe('/');
|
||||
expect(safeNext('not-a-path')).toBe('/');
|
||||
expect(safeNext('/\\evil.com')).toBe('/');
|
||||
expect(safeNext('/\\/evil.com')).toBe('/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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>');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 l’hash', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user