Completa la copertura test del badge MVP e riscrive la doc del modulo badge

Aggiunge il caso positivo RLS mancante per mvp_voti in permessi.test.ts e un nuovo
test end-to-end (mvp-badge.test.ts) che verifica l'intera pipeline voti reali ->
mvpVintiPerGiocatore() -> statoBadge(). docs/modules/badge.md ora elenca tutti e 16
i badge con come funzionano, la copertura test badge per badge e i due problemi
minori trovati in audit (badgeSbloccati() morta, categoria senza vincolo DB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:38:30 +02:00
co-authored by Claude Sonnet 5
parent 9b81fcf2fc
commit c7588c5aa2
3 changed files with 325 additions and 2 deletions
+162 -2
View File
@@ -1,6 +1,6 @@
# Modulo — Badge
**Stato:** implementato (v1.0), coerente con DD-007 e DD-008
**Stato:** implementato, coerente con DD-007 e DD-008
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
@@ -37,6 +37,20 @@ badge assegnati per voto dai compagni.
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
`votante_id`/`votato_id` sono entrambi visibili.
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte dato arriva da
un'altra tabella di voto invece che da un contatore semplice. Pipeline completa:
1. Ogni giocatore vota l'MVP della partita su `mvp_voti` (`mvp-voti.ts`), un voto per
partita/votante (`upsert` su `match_id,votante_id`), apribile solo 2 ore dopo l'inizio
match (`votoMvpAperto()`). Autovoto impossibile per due strade indipendenti: RLS di
`m11_scritture_per_ruolo` (`votante_id` legato al proprio `auth.uid()` via
`giocatori_squadra`) e `CHECK (votante_id <> votato_id)` a database
(`mvp_no_autovoto`, migration `m12_niente_autovoto`).
2. `vincitoriMvp()`/`mvpVintiPerGiocatore()` (`mvp-voti.ts`) contano, per ogni partita, chi
ha ricevuto più voti **con un vantaggio netto** sul secondo: in caso di parità nessun MVP
è assegnato per quella partita. Il conteggio finale per giocatore è il numero di partite
vinte nettamente, non il totale dei voti ricevuti.
3. `rosa.ts` (`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
@@ -46,6 +60,58 @@ badge assegnati per voto dai compagni.
---
## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
informazioni verso l'interfaccia.
### Badge normali (gradi bronzo/argento/oro)
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| id | nome | come si guadagna | soglie B/A/O |
| --- | --- | --- | --- |
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`) | 6.5 / 7.5 / 8.5 |
| `palloni` | Sherpa dei palloni | quante volte ti sei incaricato di portare la sacca palloni (`g.palloni`) | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze a eventi/partite in stagione (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti, senza saltarne uno (`g.serieAllenamenti`) | 3 / 6 / 10 |
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
### Badge segreti (booleani, nascosti finché non sbloccati)
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
sbloccati.
| id | nome | condizione esatta |
| --- | --- | --- |
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8 (`g.mvp >= 2 && g.mediaVoto >= 8`) |
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
| `s-cacche` | Trono di ferro | almeno 3 partite di campionato con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
### Badge social (votati dai compagni, 5 categorie per partita)
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
voti ricevuti).
| id | nome | cosa premia |
| --- | --- | --- |
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
| `meme` | Meme della partita | la scena più memorabile della partita |
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
---
## Regole rispettate
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
@@ -55,6 +121,98 @@ badge assegnati per voto dai compagni.
---
## Copertura test
Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per
categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge.
**Badge normali**`badges.ts` testa la propria funzione pura (soglia → grado,
`badges.test.ts`) sull'output di altri moduli:
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
`badges.test.ts:44-48`), progresso a metà (`:52-56`). **Unico badge normale con integration
dedicato** (vedi sotto) perché la sua fonte, a differenza degli altri 5, passa da un'altra
tabella di voto (`mvp_voti`) invece che da un contatore già calcolato altrove.
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
bronzo".
- `palloni`, `presenze`, `serie-allenamenti`, `serie-conferme`: stessa funzione di soglia già
testata a fondo su `mvp`/`pagella`, coperti dagli invarianti generali
(`badges.test.ts:154-159`: soglie crescenti, testi presenti, id unici) e da
`collezioneBadge`/`prossimoTraguardo` con valori al massimo (`:119-144`).
- Nessun integration dedicato per questi 5: non toccano il database, le statistiche sorgente
(`presenze.test.ts`, `palloni-core.test.ts`, ecc.) sono già coperte nei rispettivi moduli.
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto
(`badges.test.ts:77-109`): `s-tiebreak` (mediaVoto 7.9 non basta, serve 8), `s-mai-forfait`
(unica condizione doppia, testato che **entrambe** servano), `s-infermeria`, `s-ritardi`,
`s-cacche`. Copertura unit completa; integration non necessario per lo stesso motivo dei
normali (le statistiche sorgente sono testate nei rispettivi moduli).
**Badge MVP — pipeline end-to-end** (aggiunta in una sessione dedicata a completare la
copertura di questo badge):
- Unit: `badges.test.ts` (soglie/gradi) + `mvp-voti.test.ts` (conteggio partita, vincitore con
vantaggio netto, parità che non assegna, apertura voto 2h dopo il fischio d'inizio).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — semantica dell'`upsert` di `mvp_voti` (un voto per
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
(`mvp_no_autovoto`).
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
si può votare a nome di un altro (caso negativo).
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
deve contare come vittoria.
**Badge social** — nessuna delle 5 categorie ha logica *propria* nel codice: l'id è solo una
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
meccanismo:
- Unit (`badge-social.test.ts`): conteggio isolato per match+categoria (`:29-32`), vantaggio
netto/parità → nessun vincitore (`:38-41`), vittorie multi-partita (`badgeSocialVinti`, g2
vince in `m1` e `m2``{affidabile: 2}`, `:48`), zero voti → zero badge (`:51`).
- Integration: upsert/sostituzione voto per categoria (`scritture.test.ts:170-202`), autovoto
rifiutato — doppia barriera UI + database (`scritture.test.ts:124-148`), RLS `m11` — un
giocatore firma solo il proprio voto (`permessi.test.ts:344-369`).
### Riepilogo per badge
| # | id | tipo | test unit | test integration |
| - | --- | --- | --- | --- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ | non necessario |
| 3 | `palloni` | normale | ✅ | non necessario |
| 4 | `presenze` | normale | ✅ | non necessario |
| 5 | `serie-allenamenti` | normale | ✅ (limite noto sotto) | non necessario |
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | non necessario |
| 7 | `s-tiebreak` | segreto | ✅ | non necessario |
| 8 | `s-mai-forfait` | segreto | ✅ | non necessario |
| 9 | `s-infermeria` | segreto | ✅ | non necessario |
| 10 | `s-ritardi` | segreto | ✅ (parziale, manca "appena sotto") | non necessario |
| 11 | `s-cacche` | segreto | ✅ (parziale, manca "appena sotto") | non necessario |
| 12 | `affidabile` | social | ✅ | ✅ |
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico) |
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico) |
| 15 | `meme` | social | ✅ | ✅ |
| 16 | `cuore` | social | ✅ | ✅ (autovoto) |
---
## Problemi noti da sistemare
Trovati in audit, nessuno bloccante (nessun bug nella logica di calcolo):
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
CHECK o FK verso i 5 id di `categorieSocial`
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
---
## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
@@ -66,7 +224,7 @@ badge assegnati per voto dai compagni.
"ottenuto" può sparire o apparire retroattivamente.
- La policy di M11 garantisce che il voto sia firmato con il proprio `votante_id`, ma non
che il votato sia un giocatore convocato per quella partita: quello resta un filtro solo
applicativo.
applicativo (vale per MVP, pagelle e badge social).
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo.
@@ -77,3 +235,5 @@ badge assegnati per voto dai compagni.
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
ora che le serie sono calcolate.
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
+151
View File
@@ -0,0 +1,151 @@
/**
* Badge MVP end-to-end contro il database locale: `bun test/integration/mvp-badge.test.ts`.
*
* I test unitari (`test/unit/mvp-voti.test.ts`, `test/unit/badges.test.ts`) verificano
* `mvpVintiPerGiocatore()` e `statoBadge()` come funzioni pure, con voti costruiti a mano. Qui
* invece si scrivono voti veri su `mvp_voti`, si rileggono via REST con la stessa selezione di
* `useVotiMvp()`, e si passa il risultato attraverso `mvpVintiPerGiocatore()` fino a
* `statoBadge()` sul badge `mvp`: se una colonna cambia nome o la mappatura si rompe, qui il
* grado del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non
* toccano mai il database.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-mvp-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge MVP sul database", "stack locale non attivo (npx supabase start)");
riepilogo("mvp-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge MVP su ${URL_BASE}`);
const PREFISSO = "test-mvp-badge";
const mvpDef = badgeDefs.find((b) => b.id === "mvp")!;
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function upsert(riga: VotoMvp) {
const res = await rest("mvp_voti?on_conflict=match_id,votante_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`upsert su mvp_voti: ${res.status} ${await res.text()}`);
}
/** Rilegge esattamente come `useVotiMvp()`. */
async function leggiVoti(): Promise<VotoMvp[]> {
const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
);
return (await res.json()) as VotoMvp[];
}
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
function giocatoreAzzerato(mvp: number): Giocatore {
return { ...giocatori[0]!, mvp, mediaVoto: 0, palloni: 0, presenze: 0 };
}
try {
await prova(
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
async () => {
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo,
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
await upsert({
match_id: `${PREFISSO}-m1`,
votante_id: "va",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m1`,
votante_id: "vb",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m1`,
votante_id: "vc",
votato_id: "vg2",
votato_nome: "Due",
});
await upsert({
match_id: `${PREFISSO}-m2`,
votante_id: "va",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m2`,
votante_id: "vb",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m3`,
votante_id: "va",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m3`,
votante_id: "vb",
votato_id: "vg2",
votato_nome: "Due",
});
const voti = await leggiVoti();
const vinti = mvpVintiPerGiocatore(voti);
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)");
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente");
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
assert.equal(badgeVg1.prossimo, "argento");
assert.equal(badgeVg1.prossimaSoglia, 3, "manca 1 vittoria all'argento");
const badgeVg2 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg2"] ?? 0));
assert.equal(badgeVg2.grado, null, "zero vittorie nette: nessun badge sbloccato");
// Una terza vittoria netta porta vg1 da bronzo ad argento (soglia 3).
await upsert({
match_id: `${PREFISSO}-m4`,
votante_id: "va",
votato_id: "vg1",
votato_nome: "Uno",
});
const vintiDopo = mvpVintiPerGiocatore(await leggiVoti());
assert.equal(vintiDopo["vg1"], 3);
assert.equal(
statoBadge(mvpDef, giocatoreAzzerato(vintiDopo["vg1"]!)).grado,
"argento",
"3 vittorie: soglia argento raggiunta",
);
},
);
} finally {
await rest(`mvp_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
}
riepilogo("mvp-badge");
}
+12
View File
@@ -311,6 +311,18 @@ if (!locale) {
});
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
const mioMvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: "g1",
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
const mvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },