Separazione: le aree riservate diventano un'app propria (apps/platforms)
Campus, Stress Index e Longevity escono dal sito e diventano apps.insanitylab.it, con utenti, sessioni, login e database propri. Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni autosufficienti, cosi' che ognuna resti estraibile. Misura che ha guidato il taglio: le tre aree condividevano col sito SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts. Layout, librerie e dati erano gia' disgiunti. Lato piattaforme: - ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali - canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno) - tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti - /api risponde 401/403, non un redirect verso una pagina di login - tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi Lato sito: - ROLES tornano i tre redazionali; via le regole e il login pubblico - il login del pannello RESPINGE un account emigrato invece di aprirgli una sessione: senza, landingFor lo mandava su /admin, il middleware glielo negava, e si formava un ciclo di redirect che sembra un guasto del sito - l'header non mostra piu' un menu delle piattaforme: un link che esce Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra in Campus e viene rimbalzato da Longevity, il cliente il contrario. Non ancora in produzione: manca il record DNS di apps.insanitylab.it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user