From 469251366ec0e4234ac5588bfb0ec9db898a26be Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 10:49:25 +0200 Subject: [PATCH] motore: chiude i tre buchi laterali della barriera dell'insufficiente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eta_alla_data NULL non usa più il ripiego a zero: senza età la Fitness Age è null, non un'età di forma negativa dichiarata valida. Gli assi e i macro-score restano calcolabili (non dipendono dall'età). - applicaCurva valida i parametri richiesti da ciascuna curva prima di applicarla: un registro con params vuoti/incompleti dà null, mai il punteggio pieno che dava lerp con estremi indefiniti. - calcolaSessione esclude le misure fuori_range=1 (§9 della spec): non entrano nello score finché non esiste la colonna di conferma (§12, punto aperto). - leggiScore(db, sessioneId): legge la tabella score ritipata come Punteggio, con l'ultimo calcolo per tipo+elemento esplicito nella query (MAX(id)), non l'ordine naturale delle righe — salvaScore non sovrascrive di proposito (la storia degli score si tiene, discende dal congelamento della §6). - documentata la seconda divergenza dall'oracolo, mai scritta finora: la Fitness Age esclude gli assi insufficienti, l'oracolo li include comunque. Comportamento giusto, ma cambia il numero (25,0 contro 34,8 sullo stesso profilo parziale). - nuovo test che prova che i pesi di due model_version diverse restano separati (pesiDi filtra su model_version, non li fonde). Ogni fix verificato in TDD (test rosso prima, verde dopo) e ri-rotto a mano per confermare che discrimina davvero (vedi report). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF --- src/lib/longevity/motore/cascata.ts | 45 ++++++-- src/lib/longevity/motore/index.ts | 110 +++++++++++++++++- tests/longevity/motore.test.ts | 171 +++++++++++++++++++++++++++- 3 files changed, 312 insertions(+), 14 deletions(-) diff --git a/src/lib/longevity/motore/cascata.ts b/src/lib/longevity/motore/cascata.ts index f930bf5..32d0867 100644 --- a/src/lib/longevity/motore/cascata.ts +++ b/src/lib/longevity/motore/cascata.ts @@ -10,14 +10,31 @@ * peso si ridistribuisce su quelli disponibili; se la copertura di peso * disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente. * - * UNICA DIVERGENZA VOLUTA dall'oracolo: nell'oracolo `aggregate()` restituisce - * il punteggio pieno anche quando lo dichiara insufficiente (la docstring - * dice il contrario di cio' che il codice fa) - chi consuma deve ricordarsi - * di guardare lo stato, e prima o poi qualcuno non lo fa. Qui il tipo - * `Punteggio` chiude la trappola: nel ramo 'insufficiente' il campo `valore` - * non esiste, quindi la regola di prodotto - un asse con dati insufficienti - * si mostra tratteggiato, mai con un numero pieno fasullo - e' impossibile - * da violare per distrazione. Tutto il resto e' fedele all'oracolo. + * DUE DIVERGENZE VOLUTE dall'oracolo: + * + * 1) Nell'oracolo `aggregate()` restituisce il punteggio pieno anche quando lo + * dichiara insufficiente (la docstring dice il contrario di cio' che il + * codice fa) - chi consuma deve ricordarsi di guardare lo stato, e prima o + * poi qualcuno non lo fa. Qui il tipo `Punteggio` chiude la trappola: nel + * ramo 'insufficiente' il campo `valore` non esiste, quindi la regola di + * prodotto - un asse con dati insufficienti si mostra tratteggiato, mai + * con un numero pieno fasullo - e' impossibile da violare per distrazione. + * + * 2) Nel calcolo della Fitness Age (vedi `motore/index.ts`, `valoreAsse`) + * escludiamo gli assi 'insufficiente' dal composito: `cardio`, + * `forza_resto`, `composizione` e `stabilita` entrano solo se il rispettivo + * asse e' 'ok'. L'oracolo invece li include comunque - per lo stesso motivo + * del punto 1: `compute_axis` restituisce uno `score` numerico anche + * quando lo stato e' "insufficiente", e `compute_fitness_age` lo riceve e + * lo usa senza controllare lo stato. E' il comportamento GIUSTO (un asse + * sotto soglia non e' un dato affidabile da far pesare sull'eta biologica), + * ma cambia il numero: su un profilo con assi parzialmente insufficienti, + * verificato a mano, la nostra Fitness Age e' 25,0 contro 34,8 dell'oracolo + * - quasi dieci anni di scarto. Chi confronta il nostro referto col Python + * del cliente su un profilo parziale concludera' che il porting e' rotto: + * non lo e', diverge di proposito qui. + * + * Tutto il resto e' fedele all'oracolo. */ import { COPERTURA_MINIMA } from '../db'; @@ -96,9 +113,17 @@ export function calcolaMacro(pesiMacro: Record, assi: Record, voci: Record ): { fitnessAge: number | null; composito: Punteggio } { @@ -107,7 +132,7 @@ export function calcolaFitnessAge( peso, })); const composito = aggrega(vociPesate); - if (composito.stato === 'insufficiente') { + if (composito.stato === 'insufficiente' || etaAnagrafica === null) { return { fitnessAge: null, composito }; } const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4); diff --git a/src/lib/longevity/motore/index.ts b/src/lib/longevity/motore/index.ts index cd53bd3..a8004f1 100644 --- a/src/lib/longevity/motore/index.ts +++ b/src/lib/longevity/motore/index.ts @@ -19,6 +19,19 @@ * `salvaScore` congela il risultato nella tabella `score`, una riga per * asse/macro/Fitness Age, con `valore` a `null` quando lo stato è * insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo). + * `leggiScore` fa il percorso inverso — legge `score` e la ritipa come + * `Punteggio` — perché nessun altro punto del codice deve fare quella query + * a mano su una colonna nullable. + * + * Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente + * → null, mai un numero fasullo": l'età mancante (nullable nello schema) non + * usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di + * forma negativa; `applicaCurva` valida i parametri della curva prima di + * applicarla, e restituisce `null` se il registro (dato modificabile senza + * migrazioni) ha una riga incompleta, invece del punteggio pieno che dava + * `lerp` con estremi indefiniti; e la lettura delle misure esclude + * `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma + * (punto aperto in §12). */ import type Database from 'better-sqlite3'; @@ -36,15 +49,51 @@ type ParamsCurva = { steps?: [number, number][]; zeroVal?: number; }; +/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */ +function numeroValido(x: unknown): x is number { + return typeof x === 'number' && Number.isFinite(x); +} + +/** + * Verifica che `params` contenga tutto cio' che la curva dichiarata richiede, + * PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile + * senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti + * o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi + * indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il + * punteggio pieno, la peggior direzione di guasto possibile per un motore + * clinico. + */ +function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean { + switch (curva) { + case 'bell': + return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high); + case 'lin_dec': + return numeroValido(p.best) && numeroValido(p.worst); + case 'inc_plateau': + return numeroValido(p.worst) && numeroValido(p.plateauStart); + case 'x10': + case 'x10_inv': + return true; // nessun parametro richiesto + case 'decstep': + case 'incstep': + return Array.isArray(p.steps) && p.steps.length > 0 && + p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1])); + default: + return false; + } +} + /** * Normalizza un valore grezzo secondo la curva dichiarata nel registro per * quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel * motore. Un test senza curva dichiarata restituisce `null`: non contribuisce - * al calcolo di sotto-dominio. + * al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri + * mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`). */ export function applicaCurva(voce: VoceRegistro, valore: number): number | null { if (!voce.curva) return null; const p = (voce.params ?? {}) as ParamsCurva; + if (!paramsValidi(voce.curva, p)) return null; switch (voce.curva) { case 'bell': return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number); @@ -94,8 +143,12 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); const attivi = testAttivi(db, sessione.data); + // fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno + // non le conferma". La colonna della conferma non esiste ancora (punto aperto in + // §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar + // passare le misure confermate, non tolto del tutto. const misurate = new Map( - (db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ?`).all(sessioneId) as + (db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as { test_id: string; valore_num: number | null }[]) .filter((m) => m.valore_num !== null) .map((m) => [m.test_id, m.valore_num as number]) @@ -156,7 +209,7 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu stabilita: valoreAsse('Stabilità & Mobilità Funzionale'), }; const { fitnessAge, composito } = calcolaFitnessAge( - sessione.eta_alla_data ?? 0, + sessione.eta_alla_data, pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'), vociFitnessAge ); @@ -201,3 +254,54 @@ export function salvaScore(db: Database.Database, sessioneId: number, risultato: }); tx(); } + +/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */ +export type PunteggiSessione = { + assi: Record; + macro: Record; + /** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */ + fitnessAge: Punteggio | null; +}; + +/** + * Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come + * `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno + * legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che + * l'interfaccia scriverà su una colonna nullable perderebbe la barriera del + * tipo se restasse un semplice `SELECT *`. Qui non la perde. + * + * `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene, + * non si sovrascrive (discende dal congelamento della §6: sovrascrivere + * cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata + * più volte ha quindi più righe per lo stesso tipo/elemento: questa query + * dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per + * tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe, + * che non è un criterio). + */ +export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione { + const righe = db.prepare( + `SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato + FROM score s + JOIN ( + SELECT tipo, elemento, MAX(id) AS ultimo_id + FROM score + WHERE sessione_id = ? + GROUP BY tipo, elemento + ) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id + WHERE s.sessione_id = ?` + ).all(sessioneId, sessioneId) as + { tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[]; + + const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio => + r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura }; + + const assi: Record = {}; + const macro: Record = {}; + let fitnessAge: Punteggio | null = null; + for (const r of righe) { + if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r); + else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r); + else fitnessAge = aPunteggio(r); + } + return { assi, macro, fitnessAge }; +} diff --git a/tests/longevity/motore.test.ts b/tests/longevity/motore.test.ts index 32e6266..764e122 100644 --- a/tests/longevity/motore.test.ts +++ b/tests/longevity/motore.test.ts @@ -2,7 +2,9 @@ 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 { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore'; +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:'); @@ -76,6 +78,27 @@ describe('il motore legge le curve dal registro', () => { 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 @@ -133,4 +156,150 @@ describe('calcolo e congelamento di una sessione', () => { 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); + } + }); });