diff --git a/docs/modules/badge.md b/docs/modules/badge.md index 1f7ff0d..7b9d778 100644 --- a/docs/modules/badge.md +++ b/docs/modules/badge.md @@ -86,14 +86,14 @@ informazioni verso l'interfaccia. 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 | +| 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) @@ -101,13 +101,13 @@ Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore( 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`) | +| 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) @@ -116,13 +116,13 @@ richiede un vantaggio netto sul secondo classificato, in parità nessun vincitor `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 | +| 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 | --- @@ -142,6 +142,7 @@ 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`). - `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 → @@ -155,15 +156,14 @@ categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge. - `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. +- `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` e `serie-allenamenti` sono le eccezioni con -integration dedicato (sotto) perché la loro fonte passa da una tabella di voto/turni/presenze +`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 @@ -174,6 +174,7 @@ 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): @@ -190,6 +191,7 @@ copertura di questo badge): **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). @@ -207,6 +209,7 @@ dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"): **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 @@ -224,6 +227,7 @@ gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da **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, @@ -245,6 +249,7 @@ Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un **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, @@ -258,10 +263,33 @@ pura. 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 +**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](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 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`). @@ -271,24 +299,24 @@ meccanismo: ### 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) | 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) | +| # | 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 | ✅ | 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) | --- diff --git a/docs/modules/serie-presenze.md b/docs/modules/serie-presenze.md index 4a90ae9..14bf69d 100644 --- a/docs/modules/serie-presenze.md +++ b/docs/modules/serie-presenze.md @@ -5,7 +5,8 @@ `src/components/crapp/SerieCard.tsx` **Migration collegata:** `m9_risposte_presenze_risposto_il` **Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`, -`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`) +`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`), +`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts` --- diff --git a/test/integration/serie-conferme-badge.test.ts b/test/integration/serie-conferme-badge.test.ts new file mode 100644 index 0000000..8b66782 --- /dev/null +++ b/test/integration/serie-conferme-badge.test.ts @@ -0,0 +1,229 @@ +/** + * Badge Risposta lampo end-to-end contro il database locale: + * `bun test/integration/serie-conferme-badge.test.ts`. + * + * I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano + * `serieConferme()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a + * mano. Qui invece si scrivono allenamenti/partite ed eventi veri su `eventi_app`, e risposte + * vere su `risposte_presenze` con `creato_il`/`risposto_il` espliciti, si rileggono via REST + * con la stessa forma di `daRiga()`/`fetchPresenze()`, e si passa il risultato attraverso + * `serieConferme()` fino a `statoBadge()` sul badge `serie-conferme`: se una colonna cambia + * nome, o la mappatura `creato_il`/`risposto_il` -> `creatoIl`/`tempi` 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: + * - una risposta arrivata oltre le 24h dalla convocazione azzera la serie, anche dopo una + * striscia lunga; + * - partite e allenamenti contano nella stessa serie, senza bisogno di un filtro per tipo. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-serie-conferme-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeDefs, statoBadge } from "@/lib/badges"; +import { serieConferme } 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 Risposta lampo sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("serie-conferme-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge Risposta lampo su ${URL_BASE}`); + + const PREFISSO = "test-serie-conferme-badge"; + const serieDef = badgeDefs.find((b) => b.id === "serie-conferme")!; + // "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); + }; + + /** Istante di convocazione: le 8 del mattino del giorno dell'evento. */ + const creazioneEvento = (i: number) => `${dataEvento(i)}T08:00:00Z`; + + async function creaEvento( + id: string, + i: number, + tipo: "allenamento" | "partita" = "allenamento", + ) { + const res = await rest("eventi_app", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + id, + tipo, + titolo: `Evento ${id}`, + data: dataEvento(i), + creato_il: creazioneEvento(i), + }), + }); + if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`); + } + + async function rispondi( + eventoId: string, + giocatoreId: string, + stato: string, + rispostoIl: 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, + risposto_il: rispostoIl, + }), + }); + 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); + } + + /** Stessa forma di `fetchPresenze()`: eventoId -> giocatoreId -> istante della risposta. */ + async function leggiTempi(): Promise>> { + const res = await rest( + `risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,risposto_il`, + ); + const righe = (await res.json()) as Array<{ + evento_id: string; + giocatore_id: string; + risposto_il: string; + }>; + const mappa: Record> = {}; + for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il; + return mappa; + } + + function giocatoreAzzerato(serieConferme: number): Giocatore { + return { + ...giocatori[0]!, + serieConferme, + mvp: 0, + mediaVoto: 0, + votiPagella: 0, + palloni: 0, + presenze: 0, + }; + } + + try { + await prova( + "il badge passa da bronzo a oro con conferme rapide vere, e una risposta lenta lo azzera", + async () => { + // 15 eventi passati, tutti confermati mezz'ora dopo la convocazione per "sc1": + // bronzo (3), poi argento (8), poi oro (15) man mano che si rileggono i dati. + for (let i = 1; i <= 15; i += 1) { + const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`; + await creaEvento(id, i); + await rispondi(id, "sc1", "presente", `${dataEvento(i)}T08:30:00Z`); + } + const eventi15 = await leggiEventi(); + const tempi15 = await leggiTempi(); + + const serieA3 = serieConferme("sc1", eventi15.slice(0, 3), tempi15, OGGI); + assert.equal(serieA3, 3); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado, + "bronzo", + "3 conferme rapide di fila: soglia bronzo", + ); + + const serieA8 = serieConferme("sc1", eventi15.slice(0, 8), tempi15, OGGI); + assert.equal(serieA8, 8); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serieA8)).grado, + "argento", + "8 conferme rapide di fila: soglia argento", + ); + + const serieCompleta = serieConferme("sc1", eventi15, tempi15, OGGI); + assert.equal(serieCompleta, 15, "confermato in fretta a tutti e 15, nessun buco"); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado, + "oro", + "15 conferme rapide di fila: soglia oro", + ); + + // Un sedicesimo evento con una risposta arrivata dopo 2 giorni azzera la serie. + const idLento = `${PREFISSO}-a16`; + await creaEvento(idLento, 16); + await rispondi(idLento, "sc1", "presente", `${dataEvento(18)}T08:00:00Z`); + const eventiConRitardo = await leggiEventi(); + const tempiConRitardo = await leggiTempi(); + const serieDopoRitardo = serieConferme("sc1", eventiConRitardo, tempiConRitardo, OGGI); + assert.equal( + serieDopoRitardo, + 0, + "una risposta oltre le 24h azzera la serie, anche dopo 15 conferme di fila", + ); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serieDopoRitardo)).grado, + null, + "serie azzerata: nessun grado, si riparte da zero", + ); + }, + ); + + await prova( + "partite e allenamenti contano nella stessa serie, senza filtro per tipo", + async () => { + // "sc2": conferma rapida su un allenamento, poi su una partita, poi su un altro + // allenamento: la serie sale a 3 senza bisogno che siano tutti dello stesso tipo. + await creaEvento(`${PREFISSO}-b01`, 30, "allenamento"); + await creaEvento(`${PREFISSO}-b02`, 31, "partita"); + await creaEvento(`${PREFISSO}-b03`, 32, "allenamento"); + await rispondi(`${PREFISSO}-b01`, "sc2", "presente", `${dataEvento(30)}T08:30:00Z`); + await rispondi(`${PREFISSO}-b02`, "sc2", "presente", `${dataEvento(31)}T08:30:00Z`); + await rispondi(`${PREFISSO}-b03`, "sc2", "presente", `${dataEvento(32)}T08:30:00Z`); + + const eventiLetti = await leggiEventi(); + const tempiLetti = await leggiTempi(); + const serie = serieConferme("sc2", eventiLetti, tempiLetti, OGGI); + assert.equal(serie, 3, "partita in mezzo a due allenamenti: la serie resta a 3"); + assert.equal( + statoBadge(serieDef, giocatoreAzzerato(serie)).grado, + "bronzo", + "3 conferme rapide di fila, tipo misto: soglia bronzo", + ); + }, + ); + } finally { + await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + } + + riepilogo("serie-conferme-badge"); +} diff --git a/test/unit/badges.test.ts b/test/unit/badges.test.ts index 995c2a1..6aadf4c 100644 --- a/test/unit/badges.test.ts +++ b/test/unit/badges.test.ts @@ -125,6 +125,16 @@ assert.equal(gradoRaggiunto(serieAllenamentiDef, 9), "argento"); assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro"); assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro"); +// --- serie-conferme: soglie 3 / 8 / 15 ---------------------------------------- +const serieConfermeDef = badgeDefs.find((b) => b.id === "serie-conferme")!; +assert.equal(gradoRaggiunto(serieConfermeDef, 2), null, "sotto la prima soglia nessun grado"); +assert.equal(gradoRaggiunto(serieConfermeDef, 3), "bronzo", "la soglia è inclusiva"); +assert.equal(gradoRaggiunto(serieConfermeDef, 7), "bronzo"); +assert.equal(gradoRaggiunto(serieConfermeDef, 8), "argento"); +assert.equal(gradoRaggiunto(serieConfermeDef, 14), "argento"); +assert.equal(gradoRaggiunto(serieConfermeDef, 15), "oro"); +assert.equal(gradoRaggiunto(serieConfermeDef, 50), "oro", "oltre l'oro resta oro"); + // --- badgeGiocatore ---------------------------------------------------------- assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti"); assert.ok( diff --git a/test/unit/presenze.test.ts b/test/unit/presenze.test.ts index a07534e..f8efc37 100644 --- a/test/unit/presenze.test.ts +++ b/test/unit/presenze.test.ts @@ -176,6 +176,54 @@ assert.equal( ); assert.equal(serieConferme("g2", conConvocazione, tempi, OGGI), 0, "chi non risponde è a zero"); +// Chi non è convocato non spezza la serie conferme di nessun altro (stesso filtro condiviso +// con `serieConsecutiva`, vedi sopra). +const c6Ristretto: Evento = { + ...convocati("c6", "2026-08-08", "2026-08-03T10:00:00Z"), + convocati: ["g9"], +}; +assert.equal( + serieConferme("g1", [conConvocazione[0]!, c6Ristretto], tempi, OGGI), + 1, + "un evento convocato solo per un altro giocatore resta fuori, la serie di g1 non ne risente", +); + +// Le conferme contano partite e allenamenti insieme: nessuna versione per tipo. +const mistiTipo: Evento[] = [ + convocati("m1", "2026-08-06", "2026-08-01T10:00:00Z"), + { ...convocati("m2", "2026-08-13", "2026-08-08T10:00:00Z"), tipo: "partita" }, +]; +const tempiMisti: MappaTempiRisposta = { + m1: { g1: "2026-08-01T11:00:00Z" }, // un'ora dopo + m2: { g1: "2026-08-08T11:00:00Z" }, // un'ora dopo, ma è una partita +}; +assert.equal( + serieConferme("g1", mistiTipo, tempiMisti, OGGI), + 2, + "partite e allenamenti si sommano nella stessa serie", +); + +// Il confronto con le 24h è inclusivo: esattamente al bordo conta, un secondo oltre azzera. +const bordo: Evento[] = [convocati("b1", "2026-08-06", "2026-08-01T10:00:00Z")]; +assert.equal( + serieConferme("g1", bordo, { b1: { g1: "2026-08-02T10:00:00Z" } }, OGGI), + 1, + "esattamente 24h dopo: il confronto è <=, quindi conta", +); +assert.equal( + serieConferme("g1", bordo, { b1: { g1: "2026-08-02T10:00:01Z" } }, OGGI), + 0, + "un secondo oltre le 24h azzera", +); + +// Un evento futuro non conta ancora, anche con una risposta rapidissima già registrata. +const futuro: Evento[] = [convocati("f1", "2026-09-10", "2026-09-05T10:00:00Z")]; +assert.equal( + serieConferme("g1", futuro, { f1: { g1: "2026-09-05T10:05:00Z" } }, OGGI), + 0, + "l'evento di domani non è ancora passato, non entra nel calcolo", +); + // --- cache locale dopo una risposta: il cronometro non riparte ---------------- // Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela. // Qui la cache deve imitarla, altrimenti la serie "Conferme 24h" mente fino al refresh.