diff --git a/docs/modules/badge.md b/docs/modules/badge.md index 46e28a0..7d4769c 100644 --- a/docs/modules/badge.md +++ b/docs/modules/badge.md @@ -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`. diff --git a/test/integration/mvp-badge.test.ts b/test/integration/mvp-badge.test.ts new file mode 100644 index 0000000..8b24155 --- /dev/null +++ b/test/integration/mvp-badge.test.ts @@ -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 { + 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"); +} diff --git a/test/integration/permessi.test.ts b/test/integration/permessi.test.ts index 1eacb07..ce047e8 100644 --- a/test/integration/permessi.test.ts +++ b/test/integration/permessi.test.ts @@ -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" },