longevity: correzioni passo 3 su #13 unità B — scarto alternative e nota

testIdScartatiDalleAlternative: una sezione senza scelta in `scelte` (radio
assente dal form) non scarta più nulla, invece di ripiegare in silenzio
sulla prima alternativa (perdeva campi come f_plank_sec senza alt_core).

API checkup.ts: la nota operatore si valida (lunghezza max 5000, TESTO_MAX
esportato da giornale.ts) PRIMA di salvare il check-up, non più dopo — una
nota troppo lunga non faceva più fallire aggiungiVoce in silenzio ma
perdeva comunque la nota col check-up già scritto. maxlength sulla
textarea in checkup.astro.

Suite: 314 verdi, 4 rossi di piattaforma (baseline, invariati). tsc e
build puliti.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 22:59:19 +02:00
parent 81021d18f3
commit b0f30f9e4c
5 changed files with 48 additions and 19 deletions
@@ -228,8 +228,11 @@ export function valoriUltimoCheckup(db: Database.Database, clientCode: string):
/** /**
* I test_id da scartare perché appartengono a un'alternativa NON scelta di una sezione (#13): * I test_id da scartare perché appartengono a un'alternativa NON scelta di una sezione (#13):
* `scelte` mappa la chiave di sezione alla chiave dell'alternativa scelta (dalla radio nel * `scelte` mappa la chiave di sezione alla chiave dell'alternativa scelta (dalla radio nel
* form) — assente = vale la prima alternativa (comportamento del form senza JS/radio * form). Una sezione ASSENTE da `scelte` (nessuna radio arrivata: form senza JS che non ha
* mancante). Una sezione a campi fissi non contribuisce: nulla da scartare lì. * ancora marcato nulla, o client non-browser) non scarta NIENTE — ripiegare sulla prima
* alternativa butterebbe via in silenzio campi compilati di un'altra (es. f_plank_sec senza
* alt_core), correzione del passo 3 su #13. Una sezione a campi fissi non contribuisce: nulla
* da scartare lì.
*/ */
export function testIdScartatiDalleAlternative( export function testIdScartatiDalleAlternative(
sezioni: SezioneCheckup[], scelte: Map<string, string> sezioni: SezioneCheckup[], scelte: Map<string, string>
@@ -237,7 +240,8 @@ export function testIdScartatiDalleAlternative(
const scartati = new Set<string>(); const scartati = new Set<string>();
for (const s of sezioni) { for (const s of sezioni) {
if (s.tipo !== 'alternative') continue; if (s.tipo !== 'alternative') continue;
const scelta = scelte.get(s.chiave) ?? s.alternative[0]?.chiave; const scelta = scelte.get(s.chiave);
if (scelta === undefined) continue; // nessuna radio per questa sezione: non si scarta nulla
for (const alt of s.alternative) { for (const alt of s.alternative) {
if (alt.chiave === scelta) continue; if (alt.chiave === scelta) continue;
for (const c of alt.campi) scartati.add(c.id); for (const c of alt.campi) scartati.add(c.id);
+3 -1
View File
@@ -7,7 +7,9 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/; const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
const TESTO_MAX = 5000; // Esportato (#13, passo 3): l'API del check-up valida la nota PRIMA di salvare, con lo
// stesso limite di aggiungiVoce — un numero duplicato qui e lì marcirebbe silenziosamente.
export const TESTO_MAX = 5000;
export type VoceGiornale = { export type VoceGiornale = {
id: number; id: number;
@@ -6,7 +6,7 @@ import {
salvaCheckup, correggiCheckup, strutturaCheckup, testIdScartatiDalleAlternative, salvaCheckup, correggiCheckup, strutturaCheckup, testIdScartatiDalleAlternative,
type SezioneCheckup, type SezioneCheckup,
} from '../../../../lib/longevity/gestionale'; } from '../../../../lib/longevity/gestionale';
import { aggiungiVoce } from '../../../../lib/longevity/giornale'; import { aggiungiVoce, TESTO_MAX as NOTA_TESTO_MAX } from '../../../../lib/longevity/giornale';
import type { MisuraIn } from '../../../../lib/longevity/misure'; import type { MisuraIn } from '../../../../lib/longevity/misure';
export const prerender = false; export const prerender = false;
@@ -59,8 +59,9 @@ export function misureDalForm(
/** /**
* Le scelte del trainer nelle sezioni ad alternative del modulo (#13): legge il radio * Le scelte del trainer nelle sezioni ad alternative del modulo (#13): legge il radio
* `alt_<chiave sezione>` dal form. Una sezione senza radio nel form (JS disattivato che non * `alt_<chiave sezione>` dal form. Una sezione senza radio nel form (JS disattivato che non
* ha ancora impostato nulla, o un refuso) resta assente dalla mappa — la sceglie * ha ancora marcato nulla, o un refuso) resta assente dalla mappa: `testIdScartatiDalleAlternative`
* `testIdScartatiDalleAlternative` per default sulla prima alternativa dell'elenco. * non scarta NULLA per quella sezione, invece di ripiegare sulla prima alternativa e buttare
* via in silenzio campi compilati di un'altra — correzione del passo 3 su #13.
*/ */
function sceltePerAlternative(sezioni: SezioneCheckup[], form: FormData): Map<string, string> { function sceltePerAlternative(sezioni: SezioneCheckup[], form: FormData): Map<string, string> {
const scelte = new Map<string, string>(); const scelte = new Map<string, string>();
@@ -140,6 +141,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Nota operatore (#13, #16): il form la mostra solo per un check-up nuovo (checkup.astro), // Nota operatore (#13, #16): il form la mostra solo per un check-up nuovo (checkup.astro),
// quindi in correzione la chiave è assente e questo resta vuoto — nessuna voce si aggiunge. // quindi in correzione la chiave è assente e questo resta vuoto — nessuna voce si aggiunge.
const nota = String(form.get('nota') ?? '').trim(); const nota = String(form.get('nota') ?? '').trim();
// Validata PRIMA di salvare (correzione del passo 3 su #13): se aggiungiVoce fallisse DOPO
// il check-up (nota troppo lunga), il check-up resterebbe salvato ma la nota andrebbe persa
// in silenzio — stesso limite di aggiungiVoce (giornale.ts), non un numero duplicato qui.
if (nota.length > NOTA_TESTO_MAX) {
return erroreAllaScheda(`Il testo della nota non può superare ${NOTA_TESTO_MAX} caratteri.`);
}
if (sessioneId !== null) { if (sessioneId !== null) {
const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure }); const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure });
@@ -151,9 +158,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure }); const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure });
if (!esito.ok) return erroreAllaScheda(esito.errore); if (!esito.ok) return erroreAllaScheda(esito.errore);
// Voce di giornale SOLO dopo il salvataggio riuscito (#13): se la nota fosse rifiutata // Voce di giornale SOLO dopo il salvataggio riuscito (#13): la lunghezza è già validata
// (troppo lunga, data futura — non dovrebbe succedere, stessa data già validata sopra) // sopra, la data è la stessa già validata per il check-up — aggiungiVoce qui non dovrebbe
// il check-up resta comunque salvato, non si perde per un problema della nota. // più rifiutare nulla.
if (nota !== '') aggiungiVoce(db, { clientCode, data, autore: operatore, testo: nota }); if (nota !== '') aggiungiVoce(db, { clientCode, data, autore: operatore, testo: nota });
const params = new URLSearchParams({ salvato: String(esito.sessioneId) }); const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
@@ -125,7 +125,7 @@ const errore = Astro.url.searchParams.get('errore');
<h2>Note operatore</h2> <h2>Note operatore</h2>
<label class="lg-field"> <label class="lg-field">
<span>Nota per il giornale di bordo (facoltativa)</span> <span>Nota per il giornale di bordo (facoltativa)</span>
<textarea name="nota" rows="4"></textarea> <textarea name="nota" rows="4" maxlength="5000"></textarea>
</label> </label>
</section> </section>
)} )}
@@ -31,7 +31,7 @@ beforeAll(() => {
lg = getLongevityDb(); lg = getLongevityDb();
seedRegistro(lg); seedRegistro(lg);
seedPesi(lg, MODEL_VERSION); seedPesi(lg, MODEL_VERSION);
for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004', 'ISL-0005']) { for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004', 'ISL-0005', 'ISL-0006']) {
lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code); lg.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code);
} }
}); });
@@ -67,23 +67,21 @@ describe('API checkup.ts, sezioni ad alternative e nota operatore (#13)', () =>
expect(ids).not.toContain('f_panca_bil_rip'); 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 () => { it('senza radio inviata (nessun alt_*), non si scarta nulla: un campo di un\'alternativa non-prima resta salvato (correzione passo 3, #13)', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup'); const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData(); const form = new FormData();
form.set('client_code', 'ISL-0002'); form.set('client_code', 'ISL-0002');
form.set('data', OGGI); form.set('data', OGGI);
// nessun alt_spinta: la prima alternativa di "Forza spinta" è 'manubri' // nessun alt_core: 'ginocchia' è la prima alternativa di "Core", f_plank_sec è 'pieno'
form.set('f_panca_man_carico_kg', '20'); // (la seconda) — prima della correzione questo campo veniva scartato in silenzio.
form.set('f_panca_man_rip', '5'); form.set('f_plank_sec', '90');
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 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 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 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 }[]) const ids = (lg.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(sessioneId) as { test_id: string }[])
.map((r) => r.test_id); .map((r) => r.test_id);
expect(ids).toContain('f_panca_man_carico_kg'); expect(ids).toContain('f_plank_sec');
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 () => { it('nota operatore non vuota: aggiunge una voce di giornale con la data del check-up e l\'operatore come autore', async () => {
@@ -137,4 +135,22 @@ describe('API checkup.ts, sezioni ad alternative e nota operatore (#13)', () =>
await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(vociDelGiornale(lg, 'ISL-0005').length).toBe(0); expect(vociDelGiornale(lg, 'ISL-0005').length).toBe(0);
}); });
it('nota oltre 5000 caratteri: errore PRIMA di salvare, nessuna sessione né voce di giornale (correzione passo 3, #13)', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0006');
form.set('data', OGGI);
form.set('f_peso_kg', '70');
form.set('nota', 'x'.repeat(5001));
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);
expect(res.headers.get('Location')).toMatch(/errore=/);
const sessioni = lg.prepare(`SELECT COUNT(*) AS n FROM sessioni WHERE client_code = ?`).get('ISL-0006') as { n: number };
expect(sessioni.n).toBe(0); // niente scritto: né la sessione né la misura f_peso_kg
expect(vociDelGiornale(lg, 'ISL-0006').length).toBe(0);
});
}); });