diff --git a/docs/modules/badge.md b/docs/modules/badge.md index edbd029..a98197b 100644 --- a/docs/modules/badge.md +++ b/docs/modules/badge.md @@ -92,7 +92,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 | -| `presenze` | Presenza fissa | totale presenze a eventi/partite in stagione (`g.presenze`) | 5 / 15 / 30 | +| `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, 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 | @@ -151,13 +151,16 @@ categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge. - `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`, `serie-allenamenti`, `serie-conferme`: stessa funzione di soglia già testata a - fondo su `mvp`/`pagella`/`palloni`, 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 3: non toccano il database, le statistiche sorgente - (`presenze.test.ts`, ecc.) sono già coperte nei rispettivi moduli. `mvp`, `pagella` e - `palloni` fanno eccezione (sotto) perché la loro fonte passa da una tabella di voto/turni. +- `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`, `serie-conferme`: stessa funzione di soglia già testata a fondo sugli + altri badge normali, 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 2: non toccano il database, le statistiche sorgente + (`serie.test.ts`) sono già coperte nel modulo Serie. `mvp`, `pagella`, `palloni` e + `presenze` fanno eccezione (sotto) perché la loro fonte passa da una tabella di voto/turni/ + presenze, non da un contatore già calcolato altrove. **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` @@ -214,6 +217,25 @@ dedicata: nessun bug trovato, ma il comportamento "le proposte contano" — già confermato nulla; verifica anche che un evento futuro non conti, pur avendo già un'assegnazione. +**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 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 @@ -232,7 +254,7 @@ meccanismo: | 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 | ✅ | non necessario | +| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) | | 5 | `serie-allenamenti` | normale | ✅ (limite noto sotto) | non necessario | | 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | non necessario | | 7 | `s-tiebreak` | segreto | ✅ | non necessario | diff --git a/test/integration/presenze-badge.test.ts b/test/integration/presenze-badge.test.ts new file mode 100644 index 0000000..0c362cd --- /dev/null +++ b/test/integration/presenze-badge.test.ts @@ -0,0 +1,197 @@ +/** + * Badge Presenza fissa end-to-end contro il database locale: + * `bun test/integration/presenze-badge.test.ts`. + * + * I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano + * `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure, con eventi e risposte + * costruiti a mano. Qui invece si scrivono eventi e risposte veri su `eventi_app`/ + * `risposte_presenze`, si rileggono via REST con la stessa forma di `fetchPresenze()`/ + * `daRiga()`, e si passa il risultato attraverso `contaPresenzeGiocatore()` fino a + * `statoBadge()` sul badge `presenze`: 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 anche un punto verificato nell'analisi ma non ovvio: **non serve nessun controllo a + * database sui convocati** per questo badge (a differenza di MVP/pagelle/badge social, vedi + * M13 in `docs/modules/badge.md`) perché `contaPresenzeGiocatore()` filtra già gli eventi per + * `convocati` lato applicazione — una risposta scritta per un evento a cui non si era + * convocati non conta comunque, anche se la riga esiste nel database. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-presenze-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeDefs, statoBadge } from "@/lib/badges"; +import { contaPresenzeGiocatore } from "@/lib/presenze"; +import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi"; +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 presenze sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("presenze-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge presenze su ${URL_BASE}`); + + const PREFISSO = "test-presenze-badge"; + const presenzeDef = badgeDefs.find((b) => b.id === "presenze")!; + // "Oggi" fissato nel futuro: gli eventi scritti sotto sono datati nel passato remoto, così + // restano "passati" (quindi contati) a prescindere da quando gira il test. + const OGGI = "2099-01-01"; + + 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 ?? {}), + }, + }); + + /** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */ + const dataEvento = (i: number) => { + const d = new Date(Date.UTC(2020, 0, 1)); + d.setUTCDate(d.getUTCDate() + i - 1); + return d.toISOString().slice(0, 10); + }; + + async function creaEvento(id: string, i: number, convocati?: string[]) { + const res = await rest("eventi_app", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + id, + tipo: "allenamento", + titolo: `Allenamento ${id}`, + data: dataEvento(i), + ...(convocati ? { convocati } : {}), + }), + }); + if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`); + } + + async function rispondi(eventoId: string, giocatoreId: string, stato: string) { + const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, stato }), + }); + if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`); + } + + /** Rilegge esattamente come fa l'app: `daRiga()` per gli eventi. */ + async function leggiEventi(): Promise { + const res = await rest( + "eventi_app?id=like." + + PREFISSO + + "*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il", + ); + const righe = (await res.json()) as RigaEvento[]; + return righe.map(daRiga); + } + + /** Rilegge esattamente come `fetchPresenze()`. */ + async function leggiPresenze(): Promise>> { + const res = await rest( + `risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`, + ); + const righe = (await res.json()) as Array<{ + evento_id: string; + giocatore_id: string; + stato: string; + }>; + const mappa: Record> = {}; + for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato; + return mappa; + } + + /** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */ + function giocatoreAzzerato(presenze: number): Giocatore { + return { ...giocatori[0]!, presenze, mvp: 0, mediaVoto: 0, votiPagella: 0, palloni: 0 }; + } + + try { + await prova( + "il badge presenze passa da bronzo a oro con presenze vere lette dal database", + async () => { + // 30 allenamenti passati. "pp1" risponde a tutti e 30 (i primi 5 "presente", un + // "ritardo" nel mezzo per verificare che conti come presenza, il resto "presente"), + // così una sola scrittura basta a verificare le tre soglie 5/15/30. + for (let i = 1; i <= 30; i += 1) { + await creaEvento(`${PREFISSO}-a${String(i).padStart(2, "0")}`, i); + } + for (let i = 1; i <= 30; i += 1) { + const stato = i === 10 ? "ritardo" : "presente"; + await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp1", stato); + } + // "pp2" risponde solo ai primi 5: resta a bronzo, non oltre. + for (let i = 1; i <= 5; i += 1) { + await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp2", "presente"); + } + // "pp2" risponde anche "assente" a un evento: non deve contare. + await rispondi(`${PREFISSO}-a06`, "pp2", "assente"); + + const eventi = await leggiEventi(); + assert.equal(eventi.length, 30, "tutti gli eventi scritti si rileggono"); + const presenze = await leggiPresenze(); + + const contoPp1 = contaPresenzeGiocatore("pp1", eventi, presenze, OGGI); + assert.equal(contoPp1, 30, "30 risposte, incluso un ritardo contato come presenza"); + assert.equal( + statoBadge(presenzeDef, giocatoreAzzerato(contoPp1)).grado, + "oro", + "30 presenze: soglia oro raggiunta", + ); + + const contoPp2 = contaPresenzeGiocatore("pp2", eventi, presenze, OGGI); + assert.equal(contoPp2, 5, "5 presenti, l'assente su a06 non conta"); + const badgePp2 = statoBadge(presenzeDef, giocatoreAzzerato(contoPp2)); + assert.equal(badgePp2.grado, "bronzo", "5 presenze: soglia bronzo raggiunta, non oltre"); + assert.equal(badgePp2.prossimo, "argento"); + assert.equal(badgePp2.prossimaSoglia, 15); + + // Un pp2 a metà strada (15 presenze) sblocca l'argento. + for (let i = 7; i <= 16; i += 1) { + await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp2", "presente"); + } + const eventiDopo = await leggiEventi(); + const presenzeDopo = await leggiPresenze(); + const contoPp2Dopo = contaPresenzeGiocatore("pp2", eventiDopo, presenzeDopo, OGGI); + assert.equal(contoPp2Dopo, 15, "5 + 10 nuove presenze"); + assert.equal( + statoBadge(presenzeDef, giocatoreAzzerato(contoPp2Dopo)).grado, + "argento", + "15 presenze: soglia argento raggiunta", + ); + }, + ); + + await prova( + "una risposta a un evento senza convocazione non conta per il badge, anche se la riga esiste", + async () => { + // "pp3" non è tra i convocati di questo evento (lista esplicita che lo esclude), ma + // scrive comunque una risposta "presente" — bypassando l'interfaccia, come farebbe + // chi parla direttamente con PostgREST. Deve restare a zero: la difesa è nella + // funzione pura, non in una policy RLS (a differenza di MVP/pagelle/badge social). + await creaEvento(`${PREFISSO}-ristretto`, 40, ["pp1"]); + await rispondi(`${PREFISSO}-ristretto`, "pp3", "presente"); + + const eventi = await leggiEventi(); + const presenze = await leggiPresenze(); + const conto = contaPresenzeGiocatore("pp3", eventi, presenze, OGGI); + assert.equal(conto, 0, "pp3 non era convocato: la risposta non conta"); + }, + ); + } finally { + await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + } + + riepilogo("presenze-badge"); +} diff --git a/test/unit/badges.test.ts b/test/unit/badges.test.ts index eecbc87..e9b9093 100644 --- a/test/unit/badges.test.ts +++ b/test/unit/badges.test.ts @@ -105,6 +105,16 @@ assert.equal( "5 turni su 6 per l'argento: 83%", ); +// --- presenze: soglie 5 / 15 / 30 --------------------------------------------- +const presenzeDef = badgeDefs.find((b) => b.id === "presenze")!; +assert.equal(gradoRaggiunto(presenzeDef, 4), null, "sotto la prima soglia nessun grado"); +assert.equal(gradoRaggiunto(presenzeDef, 5), "bronzo", "la soglia è inclusiva"); +assert.equal(gradoRaggiunto(presenzeDef, 14), "bronzo"); +assert.equal(gradoRaggiunto(presenzeDef, 15), "argento"); +assert.equal(gradoRaggiunto(presenzeDef, 29), "argento"); +assert.equal(gradoRaggiunto(presenzeDef, 30), "oro"); +assert.equal(gradoRaggiunto(presenzeDef, 999), "oro", "oltre l'oro resta oro"); + // --- badgeGiocatore ---------------------------------------------------------- assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti"); assert.ok(