Files
davideandClaude Sonnet 5 3b305b0f8e Completa la copertura test di tutti i badge e corregge due bug trovati in audit
Audit dedicato su tutti i 16 badge: s-tiebreak non applicava la soglia minima di
voti pagella di Pagellone sullo stesso campo mediaVoto (corretto), s-cacche
prometteva "partite di campionato" senza che il codice lo verificasse mai
(corretta la descrizione, comportamento invariato). Aggiunti test unit e
integration end-to-end mancanti su badge segreti e social, con dati scritti a
database anche per i cinque segreti che prima ne erano privi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:36:33 +02:00

34 KiB

Modulo — Badge

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


Obiettivo

Gamification: sbloccare badge (gradi bronzo/argento/oro, più badge "segreti") in base a statistiche personali reali del giocatore, per motivare la partecipazione senza penalizzare i ruoli con meno statistiche "spettacolari" (DD-008).


Dati

Nessuna tabella dedicata ai badge sbloccati: calcolati interamente a runtime dall'oggetto Giocatore (DD-007). L'unica tabella coinvolta è badge_social_voti, per i badge assegnati per voto dai compagni.


Implementazione

  • badgeDefs/badgeSegreti (badges.ts) definiscono ogni badge con una funzione valore(g) e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in "Elenco badge" sotto). Il grado è calcolato da gradoRaggiunto()/statoBadge(): soglie inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è già pronto: Giocatore arriva da useRosa() con presenze, palloni, serie, infortuni, ritardi, cacche e media pagelle già calcolati da altri moduli — badges.ts si limita a confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
  • Badge Sherpa dei palloni (palloni, in badgeDefs): g.palloni non è un contatore incrementato a ogni evento, ma ricalcolato da conteggioTurni() (palloni-core.ts) su Giocatore.palloni (rosa.ts) — meccanismo di turni/rotazione descritto per intero in palloni.md, non ripetuto qui. rosa.ts passa a conteggioTurni() solo i turni confermati (turniSalvati da useTurniPalloni()), non l'output di completaTurni(): le proposte automatiche di rotazione (usate altrove, per la UI di TurnoPalloni.tsx) non contano per il badge, che premia solo chi ha davvero confermato di aver portato i palloni. Conta solo per eventi già trascorsi (e.data < oggi, stesso criterio delle presenze).
  • Badge Pagellone (pagella, in badgeDefs): a differenza degli altri badge da contatore, richiede un numero minimo di voti (VOTI_MINIMI_PAGELLA = 5, badges.ts) prima che g.mediaVoto conti — sotto soglia valore(g) è forzato a 0 (badge bloccato), anche con una media altissima. Aggiunto perché senza minimo un singolo voto poteva sbloccare/far sparire il badge senza nessuna significatività statistica (vedi pagelle.md per la pipeline voto → media, qui non ripetuta). Il numero di voti ricevuti arriva in Giocatore.votiPagella (rosa.ts), popolato insieme a mediaVoto dalla stessa mediePagelle().
  • Badge social (badge-social.ts, tabella badge_social_voti): 5 categorie fisse per partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita (modificabile), con auto-voto escluso in interfaccia (VotoSocial.tsx) e rifiutato dal database (vincolo badge_social_no_autovoto, migration m12_niente_autovoto). A differenza delle Pagelle, 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 non è un contatore già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota chi, apertura, autovoto, RLS) descritto per intero in mvp.md, non ripetuto qui. Quello che serve per capire il badge: mvpVintiPerGiocatore() (mvp-voti.ts) conta, per ogni giocatore, quante partite ha vinto con un vantaggio netto sul secondo (non il totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). 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.
  • Il rilevamento "nuovo" (notifiche-smart.ts) confronta id deterministici con quelli già visti, salvati in localStorage — quindi locale al dispositivo, non sincronizzato tra dispositivi dello stesso giocatore.

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), solo se ne ha ricevuti almeno VOTI_MINIMI_PAGELLA (5) 6.5 / 7.5 / 8.5
palloni Sherpa dei palloni quante volte hai confermato il turno palloni (g.palloni) — le proposte automatiche non ancora confermate non contano 3 / 6 / 10
presenze Presenza fissa totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (g.presenze) 5 / 15 / 30
serie-allenamenti Sempre in palestra allenamenti consecutivi presenti (g.serieAllenamenti); un infortunio non spezza la serie, un'assenza sì 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. Attenzione se si tocca gradoRaggiunto(): con le tre soglie tutte uguali a 1, il grado effettivo che risulta una volta sbloccato è sempre "oro" (l'ultimo che il ciclo for sovrascrive), mai "bronzo" — l'unica cosa che conta davvero per questi badge è grado !== null, non il suo valore, ed è così che li legge badgeSegretiSbloccati().

id nome condizione esatta
s-tiebreak Uomo tie-break almeno 2 MVP e media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && 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 (campionato o amichevole) 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 esistenti.
  • DD-008: nessun BadgeDef usa dati di reparto (punti/ace/muri); solo statistiche raggiungibili da qualunque ruolo.

Copertura test

Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per categoria). Due bug trovati in una sessione di audit dedicata su tutti i 16 badge (dettagli nelle sezioni sotto e in "Problemi noti"): s-tiebreak non applicava la soglia minima di voti di Pagellone (corretto), s-cacche prometteva "partite di campionato" senza che il codice lo verificasse mai (la descrizione è stata corretta, il comportamento — qualunque partita conta — era già quello voluto). Tutti e 16 i badge hanno ora copertura unit e integration end-to-end completa.

Badge normalibadges.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).
  • 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". Più la soglia minima di voti (vedi sotto).
  • palloni: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e oltre l'oro resta oro, badges.test.ts:94-104), oltre a un caso di progresso non tondo (5/6 → 83%). Pipeline end-to-end sotto, come mvp/pagella.
  • presenze: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro, badges.test.ts:108-116). Pipeline end-to-end sotto, come mvp/pagella/palloni.
  • serie-allenamenti: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro resta oro, badges.test.ts:118-126). Pipeline end-to-end sotto, come gli altri badge da tabella.
  • serie-conferme: soglie 3/8/15 testate esplicitamente (confine incluso, oltre l'oro resta oro, badges.test.ts:128-136), oltre agli invarianti generali e a collezioneBadge/prossimoTraguardo con valori al massimo. Pipeline end-to-end sotto, come gli altri badge da tabella.

mvp, pagella, palloni, presenze, serie-allenamenti e serie-conferme sono le eccezioni con integration dedicato (sotto) perché la loro fonte passa da una tabella di voto/turni/presenze letta e ricalcolata dal vivo, non da un contatore già pronto altrove.

Badge segreti — ognuno testato con la propria condizione esatta e il confine appena sotto: s-tiebreak (mvp:1 non basta, mediaVoto 7.9 non basta, sotto VOTI_MINIMI_PAGELLA voti non basta nemmeno con media alta — vedi il bug fix sotto), s-mai-forfait (ogni soglia isolata al confine, non solo "entrambe servono"), s-infermeria (2 infortuni non bastano), s-ritardi (4 ritardi non bastano — gap colmato in questa sessione), s-cacche (2 cacche non bastano). Copertura unit completa e integration dedicato per tutti e 5 (aggiunto in questa sessione, vedi sotto): i dati sorgente hanno già i propri test di integrazione nei rispettivi moduli, ma nessuno prima arrivava fino a statoBadge() sul segreto stesso con dati scritti a database.

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); RLS di m13 (sotto): un votante o un votato non convocati vengono rifiutati.
    • 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 Pagellone — pipeline end-to-end e soglia minima di voti (stessa sessione di sopra, dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):

  • Unit: badges.test.ts:69-88 — sotto VOTI_MINIMI_PAGELLA (5) il badge resta bloccato anche con mediaVoto: 10; esattamente a 5 la media torna a contare; sopra soglia valgono le normali soglie di grado (mediaVoto: 6.5 con 5 voti → bronzo, non oro).
  • Integration (npx supabase start richiesto):
    • scritture.test.ts — semantica dell'upsert di pagelle_voti e rifiuto dell'autovoto (pagelle_no_autovoto), già presente prima di questa sessione.
    • permessi.test.ts — RLS di m13: un votante o un votato non convocati vengono rifiutati (per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo pagelle_chiuse viene rifiutato anche a database, non solo nascosto in UI.
    • pagella-badge.test.ts (nuovo) — end-to-end reale: scrive voti su pagelle_voti, rilegge via REST come fa usePagelle(), calcola mediePagelle() e verifica che statoBadge() tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e applichi le soglie normali sopra soglia.

Badge Sherpa dei palloni — pipeline end-to-end, ora senza contare le proposte non confermate (analisi dedicata: trovato e sistemato il gap "le proposte contano", che gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da sistemare"):

  • Unit: badges.test.ts:93-104 — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) + palloni-core.test.ts, già completo prima di questa sessione (completaTurni(), conteggioTurni(), rotazione bilanciata su un giro completo di partite, allenamenti mai proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
  • Integration (npx supabase start richiesto):
    • scritture.test.ts — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
    • palloni-badge.test.ts — end-to-end reale: scrive eventi e turni solo parzialmente confermati su eventi_app/turni_palloni, rilegge via REST come fa fetchTurni()/ daRiga() e passa turniSalvati (solo confermati, mai l'output di completaTurni()) a conteggioTurni() fino a statoBadge(): dimostra che un evento passato senza turno confermato non conta per nessuno, anche se un algoritmo di rotazione (usato altrove per la UI) lo proporrebbe automaticamente; verifica anche che un evento futuro non conti, pur avendo già una conferma.

Badge Presenza fissa — pipeline end-to-end (analisi dedicata: nessun bug trovato; a differenza di MVP/pagelle/badge social, per questo badge non serve l'estensione RLS di M13 — vedi sotto):

  • Unit: badges.test.ts:108-116 — soglie 5/15/30 (confine incluso, oltre l'oro resta oro) + presenze.test.ts, già molto completo prima di questa sessione (contaPresenzeGiocatore() con ritardo che conta come presenza, denominatore uguale per tutti, eventi futuri esclusi, filtro sui convocati, solo partite/allenamenti).
  • Integration (npx supabase start richiesto):
    • obiettivi.test.ts — copre già contaPresenzeGiocatore() end-to-end per l'obiettivo "250 presenze complessive" (o3), la stessa funzione usata dal badge.
    • presenze-badge.test.ts (nuovo) — end-to-end reale sul badge: scrive eventi e risposte su eventi_app/risposte_presenze, rilegge via REST come fa fetchPresenze()/daRiga() e verifica che statoBadge() attraversi le tre soglie con dati veri (incluso un ritardo che conta come presenza e un'assenza che non conta). Dimostra anche che una risposta scritta per un evento senza convocazione non conta comunque, perché contaPresenzeGiocatore() filtra già per convocati lato applicazione — a differenza di MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella funzione pura che il badge consuma, non solo in UI.

Badge Sempre in palestra — pipeline end-to-end (analisi dedicata: nessun bug trovato). Stessa fonte dati di presenze (risposte_presenze) ma logica diversa: non un totale, una serie consecutiva che un buco azzera e un infortunio congela. Anche qui, come per presenze, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione pura.

  • Unit: badges.test.ts:118-126 — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) + presenze.test.ts, già completo prima di questa sessione su serieConsecutiva() (buco che azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco, convocati che non spezzano la serie di chi non era coinvolto).
  • Integration (npx supabase start richiesto):
    • serie-allenamenti-badge.test.ts (nuovo) — end-to-end reale: scrive allenamenti e risposte su eventi_app/risposte_presenze, rilegge via REST e verifica che statoBadge() attraversi bronzo/argento/oro con presenze consecutive vere, che un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente — che un infortunio non azzeri la serie ma la lasci congelata (3 presenze vere, un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3, non riparte da 1).

Badge Risposta lampo — pipeline end-to-end (analisi dedicata: nessun bug trovato nella logica di calcolo; l'unico limite è quello già noto e documentato sui dati pre-m9, vedi "Limiti noti"). Il badge dipende da serieConferme(), che passa da due colonne facili da confondere fra loro (creato_il/risposto_il, vedi serie-presenze.md): un integration test aggiunto per verificare che la mappatura verso creatoIl/tempi regga con dati reali, non solo con timestamp scelti a mano — cosa che i test unitari, che non toccano il database, non possono garantire.

  • Unit: badges.test.ts:128-136 — soglie 3/8/15 (confine incluso, oltre l'oro resta oro) + presenze.test.ts, esteso in questa sessione su serieConferme(): oltre al buco che azzera e all'evento senza creatoIl che viene saltato (già presenti), ora anche un evento convocato solo per un altro giocatore che non spezza la serie, partite e allenamenti sommati nella stessa serie, il confronto inclusivo esattamente a 24h (dentro conta, un secondo oltre azzera), e un evento futuro che non entra ancora nel calcolo.
  • Integration (npx supabase start richiesto):
    • serie-conferme-badge.test.ts (nuovo) — end-to-end reale: scrive eventi con creato_il esplicito e risposte con risposto_il esplicito su eventi_app/risposte_presenze, rilegge via REST come fa daRiga()/fetchPresenze() e verifica che statoBadge() attraversi bronzo/argento/oro con conferme rapide vere, che una risposta arrivata oltre le 24h azzeri tutto anche dopo 15 conferme di fila, e — separatamente — che partite e allenamenti si sommino nella stessa serie senza bisogno di un filtro per tipo.

Badge Cliente VIP dell'Infermeria e Aspettate, arrivo! — pipeline end-to-end (analisi dedicata: nessun bug trovato). Stessa fonte (contaInfortuni()/contaRitardi() in src/lib/infortuni.ts, entrambe sopra la stessa contaStato() privata) e stessa struttura di serie-allenamenti/serie-conferme, ma senza serie: un contatore semplice di eventi passati.

  • Unit: badges.test.ts (soglie 3 e 5, confine appena sotto) + infortuni.test.ts, esteso in questa sessione con un giocatore che ha sia un infortunio sia un ritardo (su eventi diversi): i due conteggi restano indipendenti, nessuno "ruba" voci all'altro.
  • Integration (npx supabase start richiesto):
    • s-infermeria-badge.test.ts / s-ritardi-badge.test.ts (nuovi) — end-to-end reali: scrivono eventi e risposte "infortunato"/"ritardo" su eventi_app/risposte_presenze, rileggono via REST e verificano che il segreto resti bloccato appena sotto soglia e si sblocchi esattamente al confine (3 infortuni, 5 ritardi).

Badge Trono di ferro — pipeline end-to-end, descrizione corretta (analisi dedicata: trovato un disallineamento fra descrizione e codice, risolto aggiornando il testo, non la logica — vedi "Problemi noti" più sotto per il perché). statisticheCacche() (src/lib/cacche.ts) non ha mai distinto partite di campionato da amichevoli: contava (e conta ancora) qualunque partita con 3+ cacche dichiarate. La vecchia descrizione del badge prometteva "partite di campionato", cosa che il codice non ha mai verificato — corretta in "partite (campionato o amichevole)".

  • Unit: badges.test.ts (soglia 3, confine appena sotto — gap colmato in questa sessione) + cacche.test.ts (già completo su giornateTop).
  • Integration (npx supabase start richiesto):
    • s-cacche-badge.test.ts (nuovo) — end-to-end reale: scrive 2 giornate da record su partite di campionato e una su un'amichevole, dimostrando con dati veri che l'amichevole conta esattamente come le altre — pin del comportamento attuale, così chi in futuro reintroduce un filtro sul campionato deve accorgersene qui, non scoprirlo in produzione.

Badge Uomo tie-break — pipeline end-to-end, bug corretto (analisi dedicata: trovato e sistemato il gap "un voto pagella solo sblocca il segreto insieme a 2 MVP"). Il segreto usa g.mediaVoto, lo stesso campo del badge normale pagella — che però lo azzera sotto VOTI_MINIMI_PAGELLA (5) voti ricevuti, proprio per evitare che un singolo voto sblocchi/tolga il badge senza significatività statistica. s-tiebreak non applicava lo stesso filtro: ora sì (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8).

  • Unit: badges.test.ts — sotto la soglia minima di voti il segreto resta bloccato anche con media 8 e 2 MVP; un solo MVP non basta (isolato dal resto).
  • Integration (npx supabase start richiesto):
    • s-tiebreak-badge.test.ts (nuovo) — end-to-end reale: scrive voti MVP e pagella veri, dimostra che un solo voto pagella (media alta, 2 MVP) NON sblocca il segreto, e che il quinto voto lo sblocca — il fix verificato con la stessa pipeline mvp_voti/pagelle_voti → REST → mvpVintiPerGiocatore()/mediePagelle()statoBadge() che userebbe l'app.

Badge Mai un forfait — pipeline end-to-end (analisi dedicata: nessun bug trovato). Unico segreto a combinare due statistiche indipendenti (serieConferme() e contaPresenzeGiocatore()), entrambe già testate a fondo nei rispettivi moduli.

  • Unit: badges.test.ts, esteso in questa sessione con ogni soglia isolata al confine (serieConferme appena sotto con presenze abbondanti, e viceversa), non solo "insieme non bastano".
  • Integration (npx supabase start richiesto):
    • s-mai-forfait-badge.test.ts (nuovo) — end-to-end reale: scrive eventi con creato_il e risposte con risposto_il veri, verifica che il segreto resti bloccato a 9/9 e si sblocchi a 15/15, e che una risposta lenta azzeri la serie di conferme senza azzerare le presenze già accumulate (le due statistiche restano indipendenti anche a database).

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). Estesi in questa sessione: un voto totale solo basta a vincere, una parità a 3 candidati (i primi due pari, il terzo staccato) resta senza vincitore, categorie diverse nella stessa partita non si mischiano in badgeSocialVinti().
  • 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).
    • badge-social.test.ts (nuovo, in test/integration/) — end-to-end reale sulle 5 categorie effettive di categorieSocial (non più solo 2-3, e non più le categorie inventate di scritture.test.ts): scrive voti veri su badge_social_voti, dimostra che tutte e 5 si contano e si vincono allo stesso modo, e che una parità su una categoria non tocca il conteggio delle altre 4 nella stessa partita.

Riepilogo per badge

# id tipo test unit test integration
1 mvp normale (scritture, permessi, mvp-badge)
2 pagella normale (incl. soglia minima voti) (scritture, permessi, pagella-badge)
3 palloni normale (scritture, palloni-badge)
4 presenze normale (obiettivi, presenze-badge)
5 serie-allenamenti normale (serie-allenamenti-badge)
6 serie-conferme normale (limite noto sotto) (serie-conferme-badge)
7 s-tiebreak segreto (bug corretto, vedi sotto) (s-tiebreak-badge)
8 s-mai-forfait segreto (s-mai-forfait-badge)
9 s-infermeria segreto (s-infermeria-badge)
10 s-ritardi segreto (s-ritardi-badge)
11 s-cacche segreto (descrizione corretta, vedi sotto) (s-cacche-badge)
12 affidabile social (scritture, permessi, badge-social)
13 spirito social (meccanismo generico) (meccanismo generico, badge-social)
14 fairplay social (meccanismo generico) (meccanismo generico, badge-social)
15 meme social (badge-social)
16 cuore social (autovoto, badge-social)

Problemi noti da sistemare

  • 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.
  • conteggioTurni() non filtra per tipo evento (palloni-core.ts:70-82), a differenza di eventiPalloni() che scarta i compleanni. Un turno registrato per errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio teorico basso (l'UI non offre questa combinazione), comportamento pinnato da un test dedicato in palloni-core.test.ts così che un domani, se serve stringere, non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.

Limiti noti

  • Dipendenza dal modulo Serie: i badge "Sempre in palestra", "Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le serie sono calcolate sui dati reali dalla migration m9 in avanti, ma "Risposta lampo" e "Mai un forfait" dipendono da serieConferme, e risposto_il non è ricostruibile per le risposte precedenti a m9: su quelle righe la serie è un'approssimazione.
  • Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già "ottenuto" può sparire o apparire retroattivamente.
  • Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando browser o dispositivo.

Risolto (analisi del badge Sherpa dei palloni): prima g.palloni (rosa.ts) includeva anche i turni che completaTurni() propone in automatico per un evento passato senza assegnazione esplicita, non solo quelli confermati in turni_palloni — un giocatore poteva vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di rotazione l'aveva proposto. Ora rosa.ts passa a conteggioTurni() solo turniSalvati (i turni confermati), non l'output di completaTurni(): quest'ultimo resta in uso solo per la UI di rotazione (TurnoPalloni.tsx, PromemoriaPalloni.tsx), mai per il conteggio del badge. Dimostrato con dati veri in palloni-badge.test.ts. palloni.md aggiornato di conseguenza.

Risolto (audit completo dei 16 badge): s-tiebreak (badges.ts:139) usava g.mediaVoto senza applicare VOTI_MINIMI_PAGELLA, a differenza del badge normale pagella che usa lo stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il segreto senza che la media fosse statisticamente significativa. Ora s-tiebreak richiede anche g.votiPagella >= VOTI_MINIMI_PAGELLA, dimostrato con dati reali in s-tiebreak-badge.test.ts. Il badge s-cacche prometteva invece "partite di campionato" nella descrizione senza che nessuna funzione della pipeline lo verificasse mai (statisticheCacche() conta qualunque partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento "qualunque partita conta" resta quello voluto, pinnato in s-cacche-badge.test.ts.

Risolto (M13, 20260908120000_m13_convocati_e_pagelle_chiuse.sql): prima la policy di M11 garantiva solo che il voto fosse firmato con il proprio votante_id, non che il votato (né il votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo direttamente su PostgREST. Ora evento_permette_voto() lo verifica anche a database per pagelle_voti, mvp_voti e badge_social_voti (convocati vuoto = tutta la rosa, stessa convenzione di convocatiEvento()), e per le sole pagelle verifica anche che eventi_app.pagelle_chiuse sia falso — prima un voto "fuori tempo" restava tecnicamente possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può ancora correggere un voto anche fuori convocazione o dopo la chiusura.


Evoluzioni possibili

  • 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.
  • Se un domani serve restringere conteggioTurni() per tipo evento (vedi "Problemi noti"), aggiornare anche il test che oggi ne pinna il comportamento permissivo.