diff --git a/docs/modules/badge.md b/docs/modules/badge.md index e3ff262..1f7ff0d 100644 --- a/docs/modules/badge.md +++ b/docs/modules/badge.md @@ -36,13 +36,12 @@ badge assegnati per voto dai compagni. - **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](palloni.md), non ripetuto qui. Un punto rilevante per il badge, **non ovvio**: - il conteggio include anche le proposte automatiche di `completaTurni()` **non ancora - confermate** da nessuno (solo per partite/eventi, mai per allenamenti), non solo i turni - salvati esplicitamente in `turni_palloni` — un giocatore può ricevere credito per un turno - che non ha mai confermato di aver fatto, semplicemente perché l'algoritmo di rotazione lo ha - proposto per un evento ormai passato. Conta solo per eventi già trascorsi (`e.data < oggi`, - stesso criterio delle presenze). + [palloni.md](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 @@ -91,7 +90,7 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro. | --- | --- | --- | --- | | `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 fatto (o ti è stato proposto, vedi sopra) il turno palloni (`g.palloni`) | 3 / 6 / 10 | +| `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 | @@ -205,22 +204,22 @@ dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"): 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 e proposte non confermate** (analisi -dedicata: nessun bug trovato, ma il comportamento "le proposte contano" — già in -`palloni.md` — non era mai stato dimostrato con dati veri): +**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` (nuovo) — end-to-end reale: scrive eventi e turni **solo - parzialmente confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa - `fetchTurni()`/`daRiga()`, passa i dati per `completaTurni()` → `conteggioTurni()` fino a - `statoBadge()`: dimostra che un evento passato senza turno confermato riceve comunque una - proposta automatica che **conta per il badge di chi viene scelto**, anche se non ha mai - confermato nulla; verifica anche che un evento futuro non conti, pur avendo già - un'assegnazione. + - `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 @@ -318,14 +317,19 @@ Trovati in audit, nessuno bloccante (nessun bug nella logica di calcolo): 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. -- **Sherpa dei palloni conta anche le proposte non confermate**: `g.palloni` include i turni - che `completaTurni()` propone in automatico per un evento passato senza assegnazione - esplicita, non solo quelli confermati in `turni_palloni` — comportamento voluto (documentato - in [palloni.md](palloni.md)), dimostrato con dati veri in `palloni-badge.test.ts`, ma non - intuitivo: un giocatore può vedere avanzare il badge senza aver mai cliccato nulla. - 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](palloni.md) aggiornato di +conseguenza. + **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 diff --git a/docs/modules/palloni.md b/docs/modules/palloni.md index 8fbcc63..bf344ae 100644 --- a/docs/modules/palloni.md +++ b/docs/modules/palloni.md @@ -33,11 +33,12 @@ compaiono. - `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con upsert su `evento_id`. - Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è - ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora - confermate** (partite/eventi) — non è uno storico in tabella dedicata. Conta solo gli - eventi già passati (`e.data < oggi`, stesso criterio delle presenze): un turno assegnato - in anticipo per un allenamento futuro non è ancora "portato", quindi non sale finché quel - giorno non arriva. + ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati` + in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte + automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione, + `TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data < + oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento + futuro non è ancora "portato", quindi non sale finché quel giorno non arriva. - `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx` è il banner in Home per il giocatore di turno. @@ -62,8 +63,6 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi - **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno. -- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche - possono contare turni mai effettivamente convalidati da nessuno. - La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto "assente" o "infortunato" per quell'evento. diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index 68e5dc1..f671c8c 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -61,7 +61,7 @@ export function useRosa(): Giocatore[] { const voti = useVotiMvp(); const { voti: pagelle } = usePagelle(); const { righe: cacche } = useCacche(); - const { turni } = useTurniPalloni(); + const { salvati: turniSalvati } = useTurniPalloni(); const { infortuni, ritardi } = useInfortuniERitardi(); const { eventi } = useEventi(); const { presenze: mappaPresenze, tempi } = useRispostePresenze(); @@ -71,7 +71,10 @@ export function useRosa(): Giocatore[] { return useMemo(() => { const medie = mediePagelle(pagelle); const statCacche = statisticheCacche(cacche); - const palloni = conteggioTurni(turni, eventi); + // Solo i turni confermati, non le proposte automatiche di completaTurni(): il badge deve + // premiare chi ha davvero portato i palloni, non chi l'algoritmo di rotazione ha + // scelto per un evento passato senza che nessuno confermasse nulla. + const palloni = conteggioTurni(turniSalvati, eventi); const mvpVinti = mvpVintiPerGiocatore(votiMvp); return squadra @@ -98,7 +101,18 @@ export function useRosa(): Giocatore[] { infortuni: infortuni[g.id] ?? 0, ritardi: ritardi[g.id] ?? 0, })); - }, [squadra, votiMvp, pagelle, cacche, turni, infortuni, ritardi, eventi, mappaPresenze, tempi]); + }, [ + squadra, + votiMvp, + pagelle, + cacche, + turniSalvati, + infortuni, + ritardi, + eventi, + mappaPresenze, + tempi, + ]); } /** Il giocatore selezionato sul dispositivo, con le statistiche complete. */ diff --git a/test/integration/palloni-badge.test.ts b/test/integration/palloni-badge.test.ts index 8427e05..ef294a3 100644 --- a/test/integration/palloni-badge.test.ts +++ b/test/integration/palloni-badge.test.ts @@ -3,24 +3,26 @@ * `bun test/integration/palloni-badge.test.ts`. * * I test unitari (`test/unit/palloni-core.test.ts`, `test/unit/badges.test.ts`) verificano - * `completaTurni()`/`conteggioTurni()` e `statoBadge()` come funzioni pure, con eventi e turni - * costruiti a mano. Qui invece si scrivono eventi e turni veri su `eventi_app`/`turni_palloni`, - * si rileggono via REST con la stessa forma di `fetchTurni()`/`daRiga()`, e si passa il - * risultato attraverso `completaTurni()` → `conteggioTurni()` fino a `statoBadge()` sul badge - * `palloni`: 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. + * `conteggioTurni()` e `statoBadge()` come funzioni pure, con eventi e turni costruiti a mano. + * Qui invece si scrivono eventi e turni veri su `eventi_app`/`turni_palloni`, si rileggono via + * REST con la stessa forma di `fetchTurni()`/`daRiga()`, e si passa il risultato attraverso + * `conteggioTurni()` fino a `statoBadge()` sul badge `palloni`: 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. * - * Copre in particolare un comportamento **documentato ma non ovvio** (`docs/modules/ - * palloni.md` § Limiti noti): il conteggio usato dal badge include anche le proposte - * automatiche non ancora confermate da nessuno, non solo i turni salvati esplicitamente. Qui - * lo si dimostra con dati veri, non solo con l'affermazione in doc. + * Verifica in particolare che il badge conti **solo i turni confermati** + * (`Giocatore.palloni` in `rosa.ts` usa `turniSalvati`, non l'output di `completaTurni()`): + * un evento passato senza turno confermato non deve avanzare il badge di nessuno, anche se + * `completaTurni()` (usato altrove, per la UI di rotazione) lo proporrebbe automaticamente a + * qualcuno. Prima di questo fix il badge includeva anche le proposte non confermate — vedi + * `docs/modules/badge.md` § Limiti noti (risolto) per il dettaglio del bug e della correzione. * * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: * usa id con il prefisso `test-palloni-badge`, che nessun dato vero può avere. */ import assert from "node:assert/strict"; import { badgeDefs, statoBadge } from "@/lib/badges"; -import { completaTurni, conteggioTurni, type CandidatoTurno } from "@/lib/palloni-core"; +import { conteggioTurni } from "@/lib/palloni-core"; import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi"; import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { statoLocale } from "../helpers/locale"; @@ -41,11 +43,6 @@ if (!locale) { // così restano "passati" (e quindi contati) a prescindere da quando gira il test. const OGGI = "2099-01-01"; - const ROSA: CandidatoTurno[] = [ - { id: "pv1", nome: "Uno" }, - { id: "pv2", nome: "Due" }, - ]; - const rest = (percorso: string, init?: RequestInit) => fetch(`${URL_BASE}/rest/v1/${percorso}`, { ...init, @@ -86,6 +83,7 @@ if (!locale) { return righe.map(daRiga); } + /** Rilegge esattamente come `fetchTurni()`: solo i turni confermati, nessuna proposta. */ async function leggiTurniSalvati(): Promise> { const res = await rest( `turni_palloni?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id`, @@ -103,12 +101,11 @@ if (!locale) { try { await prova( - "il badge palloni passa da bronzo ad argento con turni reali, incluse le proposte non confermate", + "il badge palloni passa da bronzo ad argento con turni reali, ignorando gli eventi mai confermati", async () => { // m1-m3: turno confermato esplicitamente a pv1 (3 turni -> soglia bronzo). - // m4: NESSUN turno salvato -> completaTurni() deve proporlo in automatico a chi ha - // fatto meno turni (pv2, a zero) -> conta comunque per il badge di pv2, pur non - // essendo mai stato confermato da nessuno (comportamento documentato in palloni.md). + // m4: NESSUN turno confermato -> non deve contare per nessuno, anche se un algoritmo + // di rotazione (usato altrove per la UI) proporrebbe qualcuno in automatico. // m5-m7: confermati di nuovo a pv1 (6 turni totali -> soglia argento). const eventi = [ [`${PREFISSO}-m1`, "2020-01-01", "pv1"], @@ -129,36 +126,28 @@ if (!locale) { const salvati = await leggiTurniSalvati(); assert.equal(Object.keys(salvati).length, 6, "6 turni confermati esplicitamente, m4 no"); - const turniCompleti = completaTurni(salvati, eventiLetti, ROSA); - assert.equal( - turniCompleti[`${PREFISSO}-m4`], - "pv2", - "m4 senza turno salvato: la proposta automatica sceglie pv2 (meno turni)", - ); - - const conteggio = conteggioTurni(turniCompleti, eventiLetti, OGGI); + const conteggio = conteggioTurni(salvati, eventiLetti, OGGI); assert.equal(conteggio["pv1"], 6, "6 turni confermati per pv1"); assert.equal( - conteggio["pv2"], - 1, - "pv2 non ha mai confermato nulla, ma la proposta per m4 conta comunque", + conteggio["pv2"] ?? 0, + 0, + "pv2 non ha mai confermato nulla: m4 senza conferma non conta per nessuno", ); const badgePv1 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv1"]!)); assert.equal(badgePv1.grado, "argento", "6 turni: soglia argento raggiunta"); - const badgePv2 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv2"]!)); - assert.equal(badgePv2.grado, null, "1 turno (solo proposto): sotto la soglia bronzo (3)"); + const badgePv2 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv2"] ?? 0)); + assert.equal(badgePv2.grado, null, "zero turni confermati: nessun badge"); }, ); - await prova("un evento futuro non riceve conteggio, anche se già assegnato", async () => { - // Stesso scenario di sopra ma con "oggi" prima di tutti gli eventi: nessuno dei turni, - // confermati o proposti, deve ancora contare per il badge. + await prova("un evento futuro non riceve conteggio, anche se già confermato", async () => { + // Stesso scenario di sopra ma con "oggi" prima di tutti gli eventi: nessun turno + // confermato deve ancora contare per il badge. const eventiLetti = await leggiEventi(); const salvati = await leggiTurniSalvati(); - const turniCompleti = completaTurni(salvati, eventiLetti, ROSA); - const conteggio = conteggioTurni(turniCompleti, eventiLetti, "2019-01-01"); + const conteggio = conteggioTurni(salvati, eventiLetti, "2019-01-01"); assert.deepEqual(conteggio, {}, "tutti gli eventi sono nel futuro rispetto a 'oggi'"); }); } finally {