diff --git a/apps/platforms/tests/longevity/checkup-alternative-api.test.ts b/apps/platforms/tests/longevity/checkup-alternative-api.test.ts new file mode 100644 index 0000000..3cc7f15 --- /dev/null +++ b/apps/platforms/tests/longevity/checkup-alternative-api.test.ts @@ -0,0 +1,140 @@ +// Test B (#13): l'API checkup.ts scarta i campi di un'alternativa non scelta e aggiunge una +// voce di giornale dalla nota operatore. File separato (stesso motivo di +// correzione-checkup-api.test.ts): l'endpoint usa il singleton getLongevityDb(), e +// LONGEVITY_DB_PATH va impostata PRIMA che lo apra — non nello stesso file di altri test che +// lo inizializzano prima. A differenza di correzione-checkup-api.test.ts (un solo test) qui +// ce ne sono diversi: il singleton si apre UNA volta per tutto il file (beforeAll/afterAll, +// non chiuso e riaperto a ogni test — getLongevityDb() lo terrebbe chiuso dopo il primo +// close()), e ogni test usa un client_code proprio per non leggere le voci di giornale di un +// altro test. +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { getLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; +import { vociDelGiornale } from '../../src/lib/longevity/giornale'; +import { salvaCheckup } from '../../src/lib/longevity/gestionale'; + +// La data d'oggi vera del sistema che esegue i test (non una finta): un checkup con questa +// data non è "nel futuro" per la validazione dell'API. +const OGGI = new Date().toISOString().slice(0, 10); + +let dir: string; +let primaLg: string | undefined; +let lg: ReturnType; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'lg-api-alt-test-')); + primaLg = process.env.LONGEVITY_DB_PATH; + process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db'); + lg = getLongevityDb(); + seedRegistro(lg); + seedPesi(lg, MODEL_VERSION); + for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004', 'ISL-0005']) { + lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code); + } +}); + +afterAll(() => { + lg.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows + if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg; + rmSync(dir, { recursive: true, force: true }); +}); + +describe('API checkup.ts, sezioni ad alternative e nota operatore (#13)', () => { + it('scarta i campi di un\'alternativa non scelta, anche se compilati', async () => { + const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup'); + const form = new FormData(); + form.set('client_code', 'ISL-0001'); + form.set('data', OGGI); + form.set('alt_spinta', 'pushup'); // scelta: push-up + form.set('f_pushup_rip', '15'); // dell'alternativa scelta: deve restare + form.set('f_panca_bil_carico_kg', '60'); // di un'altra alternativa (bilanciere): va scartato + form.set('f_panca_bil_rip', '5'); + const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form }); + + const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); + expect(res.status).toBe(303); + const loc = res.headers.get('Location')!; + expect(loc).toMatch(/salvato=/); + const sessioneId = Number(new URL(loc, 'http://localhost').searchParams.get('salvato')); + + const righe = lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[]; + const ids = righe.map((r) => r.test_id); + expect(ids).toContain('f_pushup_rip'); + expect(ids).not.toContain('f_panca_bil_carico_kg'); + expect(ids).not.toContain('f_panca_bil_rip'); + }); + + it('senza radio inviata, vince la prima alternativa dell\'elenco: i campi delle altre si scartano lo stesso', async () => { + const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup'); + const form = new FormData(); + form.set('client_code', 'ISL-0002'); + form.set('data', OGGI); + // nessun alt_spinta: la prima alternativa di "Forza spinta" è 'manubri' + form.set('f_panca_man_carico_kg', '20'); + form.set('f_panca_man_rip', '5'); + form.set('f_pushup_rip', '15'); // non è la prima alternativa: va scartato + const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form }); + + const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); + const sessioneId = Number(new URL(res.headers.get('Location')!, 'http://localhost').searchParams.get('salvato')); + const ids = (lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[]) + .map((r) => r.test_id); + expect(ids).toContain('f_panca_man_carico_kg'); + expect(ids).not.toContain('f_pushup_rip'); + }); + + it('nota operatore non vuota: aggiunge una voce di giornale con la data del check-up e l\'operatore come autore', async () => { + const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup'); + const form = new FormData(); + form.set('client_code', 'ISL-0003'); + form.set('data', OGGI); + form.set('f_peso_kg', '70'); + form.set('nota', 'Cliente motivato, riprende dopo la pausa estiva.'); + const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form }); + + const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); + expect(res.status).toBe(303); + + const voci = vociDelGiornale(lg, 'ISL-0003'); + expect(voci.length).toBe(1); + expect(voci[0].testo).toBe('Cliente motivato, riprende dopo la pausa estiva.'); + expect(voci[0].data).toBe(OGGI); + expect(voci[0].autore).toBe('nicola'); + }); + + it('nota operatore vuota (o assente): nessuna voce di giornale', async () => { + const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup'); + const form = new FormData(); + form.set('client_code', 'ISL-0004'); + form.set('data', OGGI); + form.set('f_peso_kg', '70'); + form.set('nota', ' '); // solo spazi: trim -> vuoto + const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form }); + + await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); + expect(vociDelGiornale(lg, 'ISL-0004').length).toBe(0); + }); + + it('una correzione (sessione_id nel form) non porta il campo nota: nessuna voce anche se la sessione ha una data valida', async () => { + const esito = salvaCheckup(lg, { + client_code: 'ISL-0005', data: OGGI, eta: 30, operatore: 'nicola', + misure: [{ test_id: 'f_peso_kg', valore_num: 70 }], + }); + if (!esito.ok) throw new Error(esito.errore); + + const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup'); + const form = new FormData(); + form.set('client_code', 'ISL-0005'); + form.set('sessione_id', String(esito.sessioneId)); + form.set('data', OGGI); + form.set('f_peso_kg', '71'); + // niente campo "nota": la pagina non lo mostra in correzione + const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form }); + + await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); + expect(vociDelGiornale(lg, 'ISL-0005').length).toBe(0); + }); +}); diff --git a/apps/platforms/tests/longevity/gestionale-modulo-checkup.test.ts b/apps/platforms/tests/longevity/gestionale-modulo-checkup.test.ts new file mode 100644 index 0000000..f2cbdf4 --- /dev/null +++ b/apps/platforms/tests/longevity/gestionale-modulo-checkup.test.ts @@ -0,0 +1,112 @@ +// Test B (#13): la struttura del modulo di check-up (strutturaCheckup) e la preselezione +// delle alternative (alternativaSelezionata) — tutto in memoria, nessun singleton. I test +// dell'API (campi scartati, nota->giornale) sono in checkup-alternative-api.test.ts: stesso +// motivo di correzione-checkup-api.test.ts, l'endpoint usa il singleton getLongevityDb(). +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; +import { + strutturaCheckup, alternativaSelezionata, valoriUltimoCheckup, salvaCheckup, + type SezioneCheckup, +} from '../../src/lib/longevity/gestionale'; + +function dbPronto(clientCode = 'ISL-0001', sesso: 'M' | 'F' = 'M') { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso); + return db; +} + +describe('strutturaCheckup: ordine e contenuto delle sezioni', () => { + it('le sezioni compaiono nell\'ordine del manuale: Pre-test, Mobilità, Equilibrio, Presa, Core, Forza spinta, Forza trazione, Arti inferiori, Aerobico, Strumenti', () => { + const db = dbPronto(); + const modulo = strutturaCheckup(db, '2026-09-28'); + expect(modulo.sezioni.map((s) => s.titolo)).toEqual([ + 'Pre-test', 'Mobilità', 'Equilibrio', 'Presa', 'Core', 'Forza spinta', + 'Forza trazione', 'Arti inferiori', 'Aerobico', 'Strumenti (Wellness Tower, Stress Index)', + ]); + }); + + it('pressione e SpO2 non compaiono in nessuna sezione né in Altre misure', () => { + const db = dbPronto(); + const modulo = strutturaCheckup(db, '2026-09-28'); + const tuttiGliId = modulo.sezioni.flatMap((s) => + s.tipo === 'campi' ? s.campi.map((c) => c.id) + : s.alternative.flatMap((a) => [...a.campi.map((c) => c.id), ...(a.dettagli?.map((c) => c.id) ?? [])]) + ); + for (const id of ['f_pa_sistolica', 'f_pa_diastolica', 'f_spo2_pct']) { + expect(tuttiGliId).not.toContain(id); + expect(modulo.altreMisure.map((c) => c.id)).not.toContain(id); + } + }); + + it('su una data vecchia (prima del 28/09) un test dismesso finisce in Altre misure, mai perso', () => { + const db = dbPronto(); + // 2026-09-27: i vecchi test_id storici sono ancora attivi (attivo_a: 2026-09-28), + // e non compaiono in nessuna sezione di STRUTTURA_RAW (che usa solo i nuovi id). + const modulo = strutturaCheckup(db, '2026-09-27'); + const altreIds = modulo.altreMisure.map((c) => c.id); + expect(altreIds).toContain('f_handgrip_kg'); + expect(altreIds).toContain('f_back_scratch_cm'); + expect(altreIds).toContain('f_flamingo_cadute'); + expect(altreIds).toContain('f_hrr_1min_bpm'); + }); + + it('su una data recente (dopo il 28/09) i test dismessi non sono più attivi: spariscono anche da Altre misure', () => { + const db = dbPronto(); + const modulo = strutturaCheckup(db, '2026-09-28'); + const altreIds = modulo.altreMisure.map((c) => c.id); + expect(altreIds).not.toContain('f_handgrip_kg'); + }); + + it('il MUTT porta i suoi otto step FC come dettagli, non come campi normali', () => { + const db = dbPronto(); + const modulo = strutturaCheckup(db, '2026-09-28'); + const aerobico = modulo.sezioni.find((s) => s.titolo === 'Aerobico'); + expect(aerobico?.tipo).toBe('alternative'); + if (aerobico?.tipo !== 'alternative') throw new Error('atteso alternative'); + const mutt = aerobico.alternative.find((a) => a.chiave === 'mutt')!; + expect(mutt.dettagli?.length).toBe(8); + expect(mutt.dettagli?.map((c) => c.id)).toContain('f_mutt_fc_7_5'); + expect(mutt.campi.map((c) => c.id)).not.toContain('f_mutt_fc_7_5'); + }); +}); + +describe('alternativaSelezionata: quale alternativa preselezionare', () => { + it('senza corrispondenza in valori, vince la prima alternativa elencata (neofita)', () => { + const db = dbPronto(); + const modulo = strutturaCheckup(db, '2026-09-28'); + const spinta = modulo.sezioni.find((s) => s.titolo === 'Forza spinta') as Extract; + expect(alternativaSelezionata(spinta, new Map())).toBe('manubri'); + }); + + it('con un valore di un\'alternativa più avanti nell\'elenco, quella vince', () => { + const db = dbPronto(); + const modulo = strutturaCheckup(db, '2026-09-28'); + const spinta = modulo.sezioni.find((s) => s.titolo === 'Forza spinta') as Extract; + const valori = new Map([['f_panca_bil_carico_kg', 60]]); + expect(alternativaSelezionata(spinta, valori)).toBe('bilanciere'); + }); + + it('valoriUltimoCheckup: vuota se il cliente non ha ancora un check-up, altrimenti le misure dell\'ultimo', () => { + const db = dbPronto(); + expect(valoriUltimoCheckup(db, 'ISL-0001').size).toBe(0); + + salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-01-10', eta: 40, operatore: 'nicola', + misure: [{ test_id: 'f_panca_bil_carico_kg', valore_num: 50 }, { test_id: 'f_panca_bil_rip', valore_num: 5 }], + }); + const ultima = valoriUltimoCheckup(db, 'ISL-0001'); + expect(ultima.get('f_panca_bil_carico_kg')).toBe(50); + + // un secondo check-up più recente sovrascrive quale sia "l'ultimo" + salvaCheckup(db, { + client_code: 'ISL-0001', data: '2026-03-10', eta: 40, operatore: 'nicola', + misure: [{ test_id: 'f_pushup_rip', valore_num: 12 }], + }); + const piuRecente = valoriUltimoCheckup(db, 'ISL-0001'); + expect(piuRecente.get('f_pushup_rip')).toBe(12); + expect(piuRecente.has('f_panca_bil_carico_kg')).toBe(false); + }); +});