diff --git a/docs/modules/badge.md b/docs/modules/badge.md index a98197b..e3ff262 100644 --- a/docs/modules/badge.md +++ b/docs/modules/badge.md @@ -93,7 +93,7 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro. | `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 (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-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) @@ -153,14 +153,19 @@ categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge. (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`, `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. +- `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`: stessa funzione di soglia già testata a fondo sugli altri badge normali, + coperta 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: non tocca direttamente il database, e la sua + fonte (`serieConferme()`) ha comunque il limite noto sui dati precedenti a `m9` descritto + sotto in "Limiti noti" — un test end-to-end aggiuntivo non lo cambierebbe. + +`mvp`, `pagella`, `palloni`, `presenze` e `serie-allenamenti` 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 (`badges.test.ts:77-109`): `s-tiebreak` (mediaVoto 7.9 non basta, serve 8), `s-mai-forfait` @@ -236,6 +241,24 @@ M13 — vedi sotto): 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 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 @@ -255,7 +278,7 @@ meccanismo: | 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 | ✅ (limite noto sotto) | non necessario | +| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) | | 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | non necessario | | 7 | `s-tiebreak` | segreto | ✅ | non necessario | | 8 | `s-mai-forfait` | segreto | ✅ | non necessario | diff --git a/test/integration/serie-allenamenti-badge.test.ts b/test/integration/serie-allenamenti-badge.test.ts new file mode 100644 index 0000000..e1659c9 --- /dev/null +++ b/test/integration/serie-allenamenti-badge.test.ts @@ -0,0 +1,206 @@ +/** + * Badge Sempre in palestra end-to-end contro il database locale: + * `bun test/integration/serie-allenamenti-badge.test.ts`. + * + * I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano + * `serieConsecutiva()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a + * mano. Qui invece si scrivono allenamenti 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 `serieConsecutiva()` fino a `statoBadge()` sul + * badge `serie-allenamenti`: 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 due comportamenti non ovvi con dati veri, non solo con numeri scelti a mano: + * - un buco nella serie (assente o nessuna risposta) la azzera; + * - un infortunio invece la **congela** (non la azzera): l'allenamento saltato per infortunio + * non conta né a favore né contro, e la serie riparte da dove si era fermata. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-serie-allenamenti-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeDefs, statoBadge } from "@/lib/badges"; +import { serieConsecutiva } 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 Sempre in palestra sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("serie-allenamenti-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge Sempre in palestra su ${URL_BASE}`); + + const PREFISSO = "test-serie-allenamenti-badge"; + const serieDef = badgeDefs.find((b) => b.id === "serie-allenamenti")!; + // "Oggi" fissato nel futuro: gli allenamenti 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 creaAllenamento(id: string, i: number) { + 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), + }), + }); + 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()}`); + } + + 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); + } + + 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; + } + + function giocatoreAzzerato(serieAllenamenti: number): Giocatore { + return { + ...giocatori[0]!, + serieAllenamenti, + mvp: 0, + mediaVoto: 0, + votiPagella: 0, + palloni: 0, + presenze: 0, + }; + } + + try { + await prova( + "il badge passa da bronzo a oro con presenze consecutive vere, e un buco lo azzera", + async () => { + // 10 allenamenti passati, tutti presenti per "sa1": bronzo (3), poi argento (6), poi + // oro (10) man mano che si rileggono i dati. + for (let i = 1; i <= 10; i += 1) { + await creaAllenamento(`${PREFISSO}-a${String(i).padStart(2, "0")}`, i); + await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "sa1", "presente"); + } + const eventi10 = await leggiEventi(); + const presenze10 = await leggiPresenze(); + const serieA3 = serieConsecutiva( + "sa1", + eventi10.slice(0, 3), + presenze10, + "allenamento", + OGGI, + ); + assert.equal(serieA3, 3); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado, + "bronzo", + "3 allenamenti di fila: soglia bronzo", + ); + + const serieCompleta = serieConsecutiva("sa1", eventi10, presenze10, "allenamento", OGGI); + assert.equal(serieCompleta, 10, "presente a tutti e 10, nessun buco"); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado, + "oro", + "10 allenamenti di fila: soglia oro", + ); + + // Un undicesimo allenamento con un buco (assente) azzera la serie. + await creaAllenamento(`${PREFISSO}-a11`, 11); + await rispondi(`${PREFISSO}-a11`, "sa1", "assente"); + const eventiConBuco = await leggiEventi(); + const presenzeConBuco = await leggiPresenze(); + const serieDopoBuco = serieConsecutiva( + "sa1", + eventiConBuco, + presenzeConBuco, + "allenamento", + OGGI, + ); + assert.equal(serieDopoBuco, 0, "un'assenza azzera la serie, anche dopo 10 di fila"); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serieDopoBuco)).grado, + null, + "serie azzerata: nessun grado, si riparte da zero", + ); + }, + ); + + await prova("un infortunio congela la serie invece di azzerarla", async () => { + // "sa2": presente ai primi due allenamenti, poi infortunato al terzo (saltato, non + // conta né a favore né contro), poi di nuovo presente: la serie deve restare a 3, non + // ripartire da 1. + const eventi = [`${PREFISSO}-b01`, `${PREFISSO}-b02`, `${PREFISSO}-b03`, `${PREFISSO}-b04`]; + for (let i = 0; i < eventi.length; i += 1) { + await creaAllenamento(eventi[i]!, 20 + i); + } + await rispondi(eventi[0]!, "sa2", "presente"); + await rispondi(eventi[1]!, "sa2", "presente"); + await rispondi(eventi[2]!, "sa2", "infortunato"); + await rispondi(eventi[3]!, "sa2", "presente"); + + const eventiLetti = await leggiEventi(); + const presenzeLette = await leggiPresenze(); + const serie = serieConsecutiva("sa2", eventiLetti, presenzeLette, "allenamento", OGGI); + assert.equal(serie, 3, "l'infortunio è saltato: 3 presenze restano consecutive"); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serie)).grado, + "bronzo", + "3 di fila (infortunio escluso dal conteggio): soglia bronzo", + ); + }); + } finally { + await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + } + + riepilogo("serie-allenamenti-badge"); +} diff --git a/test/unit/badges.test.ts b/test/unit/badges.test.ts index e9b9093..995c2a1 100644 --- a/test/unit/badges.test.ts +++ b/test/unit/badges.test.ts @@ -115,6 +115,16 @@ assert.equal(gradoRaggiunto(presenzeDef, 29), "argento"); assert.equal(gradoRaggiunto(presenzeDef, 30), "oro"); assert.equal(gradoRaggiunto(presenzeDef, 999), "oro", "oltre l'oro resta oro"); +// --- serie-allenamenti: soglie 3 / 6 / 10 ------------------------------------- +const serieAllenamentiDef = badgeDefs.find((b) => b.id === "serie-allenamenti")!; +assert.equal(gradoRaggiunto(serieAllenamentiDef, 2), null, "sotto la prima soglia nessun grado"); +assert.equal(gradoRaggiunto(serieAllenamentiDef, 3), "bronzo", "la soglia è inclusiva"); +assert.equal(gradoRaggiunto(serieAllenamentiDef, 5), "bronzo"); +assert.equal(gradoRaggiunto(serieAllenamentiDef, 6), "argento"); +assert.equal(gradoRaggiunto(serieAllenamentiDef, 9), "argento"); +assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro"); +assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro"); + // --- badgeGiocatore ---------------------------------------------------------- assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti"); assert.ok(