Files
InsanityLab-web/apps/platforms/tests/longevity/motore-curve.test.ts
T
AdrianoDev 6301f521fd Separazione: le aree riservate diventano un'app propria (apps/platforms)
Campus, Stress Index e Longevity escono dal sito e diventano
apps.insanitylab.it, con utenti, sessioni, login e database propri.
Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni
autosufficienti, cosi' che ognuna resti estraibile.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 11:21:12 +00:00

174 lines
9.2 KiB
TypeScript

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);
});
});