From 3b305b0f8e61439501a80a99821df4bcef34ae3b Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 8 Sep 2026 15:36:33 +0200 Subject: [PATCH] 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 --- docs/modules/badge.md | 162 +++++++++++---- docs/modules/palloni.md | 7 +- src/lib/badges.ts | 6 +- test/integration/badge-social.test.ts | 161 +++++++++++++++ test/integration/palloni-badge.test.ts | 19 +- test/integration/s-cacche-badge.test.ts | 134 ++++++++++++ test/integration/s-infermeria-badge.test.ts | 150 ++++++++++++++ test/integration/s-mai-forfait-badge.test.ts | 202 +++++++++++++++++++ test/integration/s-ritardi-badge.test.ts | 144 +++++++++++++ test/integration/s-tiebreak-badge.test.ts | 160 +++++++++++++++ test/unit/badge-social.test.ts | 34 ++++ test/unit/badges.test.ts | 56 ++++- test/unit/infortuni.test.ts | 10 + test/unit/mvp-voti.test.ts | 14 ++ test/unit/palloni-core.test.ts | 12 ++ 15 files changed, 1230 insertions(+), 41 deletions(-) create mode 100644 test/integration/badge-social.test.ts create mode 100644 test/integration/s-cacche-badge.test.ts create mode 100644 test/integration/s-infermeria-badge.test.ts create mode 100644 test/integration/s-mai-forfait-badge.test.ts create mode 100644 test/integration/s-ritardi-badge.test.ts create mode 100644 test/integration/s-tiebreak-badge.test.ts diff --git a/docs/modules/badge.md b/docs/modules/badge.md index 7b9d778..9e5bb7c 100644 --- a/docs/modules/badge.md +++ b/docs/modules/badge.md @@ -99,15 +99,18 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro. 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. +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 (`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, 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) @@ -138,7 +141,12 @@ voti ricevuti). ## Copertura test Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per -categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge. +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 normali** — `badges.ts` testa la propria funzione pura (soglia → grado, `badges.test.ts`) sull'output di altri moduli: @@ -166,11 +174,14 @@ eccezioni con integration dedicato (sotto) perché la loro fonte passa da una ta 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` -(unica condizione doppia, testato che **entrambe** servano), `s-infermeria`, `s-ritardi`, -`s-cacche`. Copertura unit completa; integration non necessario per lo stesso motivo dei -normali (le statistiche sorgente sono testate nei rispettivi moduli). +**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): @@ -285,6 +296,63 @@ database, non possono garantire. 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 @@ -292,38 +360,44 @@ 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`). + 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 | ✅ | 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 | ✅ (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 -Trovati in audit, nessuno bloccante (nessun bug nella logica di calcolo): - - **`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). @@ -333,6 +407,12 @@ Trovati in audit, nessuno bloccante (nessun bug nella logica di calcolo): 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. --- @@ -358,6 +438,16 @@ UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteg Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](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 @@ -377,3 +467,5 @@ ancora correggere un voto anche fuori convocazione o dopo la chiusura. 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. diff --git a/docs/modules/palloni.md b/docs/modules/palloni.md index bf344ae..a02ef22 100644 --- a/docs/modules/palloni.md +++ b/docs/modules/palloni.md @@ -37,7 +37,7 @@ compaiono. 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 +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. @@ -65,6 +65,11 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno. - La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto "assente" o "infortunato" per quell'evento. +- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che + scarta i compleanni): guarda solo `e.data < oggi`. Un turno registrato per errore su un + evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei + palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma + il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`. --- diff --git a/src/lib/badges.ts b/src/lib/badges.ts index ba3803c..9d0ff67 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -136,7 +136,9 @@ export const badgeSegreti: BadgeDef[] = [ icon: Ghost, segreto: true, soglie: { bronzo: 1, argento: 1, oro: 1 }, - valore: (g) => (g.mvp >= 2 && g.mediaVoto >= 8 ? 1 : 0), + // Stessa soglia minima di voti del badge Pagellone: sotto VOTI_MINIMI_PAGELLA la media + // non è statisticamente significativa, non deve poter sbloccare nemmeno questo segreto. + valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0), celebrazione: "Nei momenti caldi ci sei sempre.", }, { @@ -184,7 +186,7 @@ export const badgeSegreti: BadgeDef[] = [ id: "s-cacche", nome: "Trono di ferro", descrizione: - "Almeno 3 partite di campionato affrontate con 3 o più cacche pre-gara. Il bagno del PalaCRAP porta il tuo nome 🚽😂", + "Almeno 3 partite affrontate con 3 o più cacche pre-gara (campionato o amichevole, qui non si fanno sconti). Il bagno del PalaCRAP porta il tuo nome 🚽😂", unita: "partite da record", icon: Toilet, segreto: true, diff --git a/test/integration/badge-social.test.ts b/test/integration/badge-social.test.ts new file mode 100644 index 0000000..28f16c2 --- /dev/null +++ b/test/integration/badge-social.test.ts @@ -0,0 +1,161 @@ +/** + * Badge social (5 categorie) end-to-end contro il database locale: + * `bun test/integration/badge-social.test.ts`. + * + * I test unitari (`test/unit/badge-social.test.ts`) verificano `conteggioCategoria()`, + * `vincitoreCategoria()` e `badgeSocialVinti()` come funzioni pure, con categorie inventate + * ("sorriso", "urlo" in `scritture.test.ts`) o con solo 2-3 delle 5 reali. Qui invece si + * scrivono voti veri su `badge_social_voti` con le 5 categorie effettive di `categorieSocial` + * (`affidabile`, `spirito`, `fairplay`, `meme`, `cuore`), si rileggono via REST con la stessa + * selezione di `useVotiSocial()`, e si passa il risultato attraverso `badgeSocialVinti()`: se + * una colonna cambia nome o un id di categoria diverge da quello scritto dall'app, qui si vede, + * perché non c'è nessun vincolo CHECK a database sulla colonna `categoria` (vedi + * `docs/modules/badge.md` § Problemi noti) — l'unica difesa è l'app che manda sempre uno dei 5 + * id validi, e questo test lo dimostra con dati reali su tutte e 5, non solo su un paio. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-badge-social`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { + badgeSocialVinti, + categorieSocial, + vincitoreCategoria, + type VotoSocial, +} from "@/lib/badge-social"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("badge social sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("badge-social"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge social su ${URL_BASE}`); + + const PREFISSO = "test-badge-social"; + + 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 ?? {}), + }, + }); + + async function vota(voto: VotoSocial) { + const res = await rest("badge_social_voti?on_conflict=match_id,categoria,votante_id", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify(voto), + }); + if (!res.ok) throw new Error(`upsert su badge_social_voti: ${res.status} ${await res.text()}`); + } + + async function leggiVoti(): Promise { + const res = await rest( + `badge_social_voti?match_id=like.${PREFISSO}-*&select=match_id,categoria,votante_id,votato_id,votato_nome`, + ); + return (await res.json()) as VotoSocial[]; + } + + try { + await prova( + "tutte e 5 le categorie reali si contano e si vincono indipendentemente", + async () => { + // "bs1" vince nettamente tutte e 5 le categorie reali nella partita m1 (2 voti contro + // 1 ciascuna): dimostra che l'id di categoria non è solo una stringa di comodo nei + // test unitari, ma funziona identico per tutte e 5 quelle vere dell'app. + for (const cat of categorieSocial) { + await vota({ + match_id: `${PREFISSO}-m1`, + categoria: cat.id, + votante_id: "va", + votato_id: "bs1", + votato_nome: "Uno", + }); + await vota({ + match_id: `${PREFISSO}-m1`, + categoria: cat.id, + votante_id: "vb", + votato_id: "bs1", + votato_nome: "Uno", + }); + await vota({ + match_id: `${PREFISSO}-m1`, + categoria: cat.id, + votante_id: "vc", + votato_id: "bs2", + votato_nome: "Due", + }); + } + + const voti = await leggiVoti(); + assert.equal(voti.length, categorieSocial.length * 3, "tutti i voti scritti si rileggono"); + + for (const cat of categorieSocial) { + const vincitore = vincitoreCategoria(voti, `${PREFISSO}-m1`, cat.id); + assert.equal(vincitore?.id, "bs1", `bs1 vince "${cat.id}" con vantaggio netto`); + } + + const vinti = badgeSocialVinti(voti, "bs1"); + assert.deepEqual( + vinti, + Object.fromEntries(categorieSocial.map((c) => [c.id, 1])), + "una vittoria per ciascuna delle 5 categorie reali, nessuna persa per strada", + ); + assert.deepEqual(badgeSocialVinti(voti, "bs2"), {}, "bs2 non vince mai nettamente"); + }, + ); + + await prova("una parità su una categoria reale non assegna il badge, le altre sì", async () => { + // Nella partita m2, "affidabile" finisce in parità (nessun vincitore), le altre 4 le + // vince ancora "bs1": la parità deve bloccare solo la categoria coinvolta. + await vota({ + match_id: `${PREFISSO}-m2`, + categoria: "affidabile", + votante_id: "va", + votato_id: "bs1", + votato_nome: "Uno", + }); + await vota({ + match_id: `${PREFISSO}-m2`, + categoria: "affidabile", + votante_id: "vb", + votato_id: "bs2", + votato_nome: "Due", + }); + for (const cat of categorieSocial.filter((c) => c.id !== "affidabile")) { + await vota({ + match_id: `${PREFISSO}-m2`, + categoria: cat.id, + votante_id: "va", + votato_id: "bs1", + votato_nome: "Uno", + }); + } + + const voti = await leggiVoti(); + assert.equal( + vincitoreCategoria(voti, `${PREFISSO}-m2`, "affidabile"), + null, + "1 voto contro 1: parità, nessun vincitore", + ); + const vintiTotali = badgeSocialVinti(voti, "bs1"); + // m1 (tutte e 5) + m2 (le 4 non in parità): "affidabile" resta a 1 (solo m1), le altre 4 a 2. + assert.equal(vintiTotali["affidabile"], 1, "la parità in m2 non aggiunge una vittoria"); + for (const cat of categorieSocial.filter((c) => c.id !== "affidabile")) { + assert.equal(vintiTotali[cat.id], 2, `"${cat.id}" vinta sia in m1 sia in m2`); + } + }); + } finally { + await rest(`badge_social_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" }); + } + + riepilogo("badge-social"); +} diff --git a/test/integration/palloni-badge.test.ts b/test/integration/palloni-badge.test.ts index ef294a3..2fab9b7 100644 --- a/test/integration/palloni-badge.test.ts +++ b/test/integration/palloni-badge.test.ts @@ -117,8 +117,23 @@ if (!locale) { [`${PREFISSO}-m7`, "2020-01-07", "pv1"], ] as const; - for (const [id, data] of eventi) await creaEvento(id, data); - for (const [id, , giocatore] of eventi) if (giocatore) await confermaTurno(id, giocatore); + for (const [id, data] of eventi.slice(0, 3)) await creaEvento(id, data); + for (const [id, , giocatore] of eventi.slice(0, 3)) + if (giocatore) await confermaTurno(id, giocatore); + + const eventiA3 = await leggiEventi(); + const salvatiA3 = await leggiTurniSalvati(); + const conteggioA3 = conteggioTurni(salvatiA3, eventiA3, OGGI); + assert.equal(conteggioA3["pv1"], 3, "3 turni confermati: soglia bronzo appena raggiunta"); + assert.equal( + statoBadge(palloniDef, giocatoreAzzerato(conteggioA3["pv1"]!)).grado, + "bronzo", + "3 turni: bronzo, non oltre (soglia argento è 6)", + ); + + for (const [id, data] of eventi.slice(3)) await creaEvento(id, data); + for (const [id, , giocatore] of eventi.slice(3)) + if (giocatore) await confermaTurno(id, giocatore); const eventiLetti = await leggiEventi(); assert.equal(eventiLetti.length, 7, "tutti gli eventi scritti si rileggono"); diff --git a/test/integration/s-cacche-badge.test.ts b/test/integration/s-cacche-badge.test.ts new file mode 100644 index 0000000..a1f406a --- /dev/null +++ b/test/integration/s-cacche-badge.test.ts @@ -0,0 +1,134 @@ +/** + * Badge segreto Trono di ferro end-to-end contro il database locale: + * `bun test/integration/s-cacche-badge.test.ts`. + * + * I test unitari (`test/unit/cacche.test.ts`, `test/unit/badges.test.ts`) verificano + * `statisticheCacche()` e `statoBadge()` come funzioni pure. Qui invece si scrivono righe vere + * su `cacche_partita`, si rileggono via REST con la stessa selezione di `useCacche()`, e si + * passa il risultato attraverso `statisticheCacche()` fino a `statoBadge()` sul badge segreto + * `s-cacche`. + * + * Copre in particolare un comportamento verificato in analisi e confermato intenzionale (non + * un bug): **la descrizione del badge non distingue più campionato da amichevoli** ("almeno 3 + * partite affrontate con 3+ cacche pre-gara, campionato o amichevole") perché nessuna funzione + * della pipeline (`statisticheCacche()`, `rosa.ts`, `badges.ts`) filtra mai su + * `eventi_app.campionato` — la vecchia descrizione prometteva "partite di campionato" senza + * che il codice lo verificasse mai. Qui si scrive apposta un evento con `campionato=false` + * (amichevole) e si dimostra che conta lo stesso, così un domani chi reintroduce un filtro sul + * campionato deve toccare anche questo test, non scoprirlo in produzione. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-s-cacche-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeSegreti, statoBadge } from "@/lib/badges"; +import { statisticheCacche, type RigaCacche } from "@/lib/cacche"; +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 Trono di ferro sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("s-cacche-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge Trono di ferro su ${URL_BASE}`); + + const PREFISSO = "test-s-cacche-badge"; + const def = badgeSegreti.find((b) => b.id === "s-cacche")!; + + 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 ?? {}), + }, + }); + + async function creaEvento(id: string, campionato: boolean) { + const res = await rest("eventi_app", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + id, + tipo: "partita", + titolo: `Partita ${id}`, + data: "2020-01-01", + campionato, + }), + }); + if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`); + } + + async function dichiara(eventoId: string, giocatoreId: string, quantita: number) { + const res = await rest("cacche_partita?on_conflict=evento_id,giocatore_id", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, quantita }), + }); + if (!res.ok) throw new Error(`upsert su cacche_partita: ${res.status} ${await res.text()}`); + } + + async function leggiCacche(): Promise { + const res = await rest( + `cacche_partita?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,quantita`, + ); + return (await res.json()) as RigaCacche[]; + } + + function giocatoreAzzerato(cacche: number): Giocatore { + return { + ...giocatori[0]!, + cacche, + mvp: 0, + mediaVoto: 0, + votiPagella: 0, + palloni: 0, + presenze: 0, + }; + } + + try { + await prova( + "il segreto si sblocca con 3 giornate da record, comprese le amichevoli", + async () => { + // sc1: 2 giornate top su partite di campionato, 1 su un'amichevole. Il badge non fa + // distinzione: le 3 contano tutte allo stesso modo. + await creaEvento(`${PREFISSO}-c1`, true); + await creaEvento(`${PREFISSO}-c2`, true); + await creaEvento(`${PREFISSO}-a1`, false); // amichevole + await dichiara(`${PREFISSO}-c1`, "sc1", 3); + await dichiara(`${PREFISSO}-c2`, "sc1", 4); + + const righeA2 = await leggiCacche(); + const statsA2 = statisticheCacche(righeA2); + assert.equal(statsA2["sc1"]?.giornateTop, 2, "solo le due di campionato per ora"); + assert.equal( + statoBadge(def, giocatoreAzzerato(statsA2["sc1"]!.giornateTop)).grado, + null, + "2 giornate top non bastano", + ); + + await dichiara(`${PREFISSO}-a1`, "sc1", 3); + const righe = await leggiCacche(); + const stats = statisticheCacche(righe); + assert.equal(stats["sc1"]?.giornateTop, 3, "l'amichevole conta come le altre due"); + assert.equal( + statoBadge(def, giocatoreAzzerato(stats["sc1"]!.giornateTop)).grado, + "oro", + "3 giornate top, campionato o amichevole: il segreto si sblocca", + ); + }, + ); + } finally { + await rest(`cacche_partita?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + } + + riepilogo("s-cacche-badge"); +} diff --git a/test/integration/s-infermeria-badge.test.ts b/test/integration/s-infermeria-badge.test.ts new file mode 100644 index 0000000..0234bb0 --- /dev/null +++ b/test/integration/s-infermeria-badge.test.ts @@ -0,0 +1,150 @@ +/** + * Badge segreto Cliente VIP dell'Infermeria end-to-end contro il database locale: + * `bun test/integration/s-infermeria-badge.test.ts`. + * + * I test unitari (`test/unit/infortuni.test.ts`, `test/unit/badges.test.ts`) verificano + * `contaInfortuni()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a + * mano. Qui invece si scrivono eventi e risposte "infortunato" veri su `eventi_app`/ + * `risposte_presenze`, si rileggono via REST con la stessa forma di `daRiga()`/ + * `fetchPresenze()`, e si passa il risultato attraverso `contaInfortuni()` fino a + * `statoBadge()` sul badge segreto `s-infermeria`: se una colonna cambia nome o la mappatura + * si rompe, qui il badge torna sbagliato anche se i test unitari restano verdi. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-s-infermeria-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeSegreti, statoBadge } from "@/lib/badges"; +import { contaInfortuni } from "@/lib/infortuni"; +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 Cliente VIP dell'Infermeria sul database", + "stack locale non attivo (npx supabase start)", + ); + riepilogo("s-infermeria-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge Cliente VIP dell'Infermeria su ${URL_BASE}`); + + const PREFISSO = "test-s-infermeria-badge"; + const def = badgeSegreti.find((b) => b.id === "s-infermeria")!; + 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 ?? {}), + }, + }); + + 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) { + const res = await rest("eventi_app", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + id, + tipo: "allenamento", + titolo: `Evento ${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(infortuni: number): Giocatore { + return { + ...giocatori[0]!, + infortuni, + mvp: 0, + mediaVoto: 0, + votiPagella: 0, + palloni: 0, + presenze: 0, + }; + } + + try { + await prova("il segreto si sblocca al terzo infortunio vero, non prima", async () => { + for (let i = 1; i <= 3; i += 1) { + const id = `${PREFISSO}-e${i}`; + await creaEvento(id, i); + await rispondi(id, "si1", "infortunato"); + } + + const eventiA2 = (await leggiEventi()).filter((e) => e.id !== `${PREFISSO}-e3`); + const presenzeA2 = await leggiPresenze(); + const contoA2 = contaInfortuni(presenzeA2, eventiA2, OGGI)["si1"] ?? 0; + assert.equal(contoA2, 2, "solo i primi due eventi contati"); + assert.equal( + statoBadge(def, giocatoreAzzerato(contoA2)).grado, + null, + "2 infortuni non bastano", + ); + + const eventi = await leggiEventi(); + const presenze = await leggiPresenze(); + const conto = contaInfortuni(presenze, eventi, OGGI)["si1"] ?? 0; + assert.equal(conto, 3); + assert.equal( + statoBadge(def, giocatoreAzzerato(conto)).grado, + "oro", + "3 infortuni: il segreto si sblocca (soglia unica)", + ); + }); + } finally { + await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + } + + riepilogo("s-infermeria-badge"); +} diff --git a/test/integration/s-mai-forfait-badge.test.ts b/test/integration/s-mai-forfait-badge.test.ts new file mode 100644 index 0000000..59a7c83 --- /dev/null +++ b/test/integration/s-mai-forfait-badge.test.ts @@ -0,0 +1,202 @@ +/** + * Badge segreto Mai un forfait end-to-end contro il database locale: + * `bun test/integration/s-mai-forfait-badge.test.ts`. + * + * I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano + * `serieConferme()`, `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure. Qui invece + * si scrivono eventi con `creato_il` esplicito e risposte con `risposto_il` esplicito su + * `eventi_app`/`risposte_presenze`, si rileggono via REST e si passa il risultato attraverso + * entrambe le funzioni fino a `statoBadge()` sul badge segreto `s-mai-forfait`, che è l'unico a + * combinare due statistiche indipendenti (serie di conferme rapide **e** presenze totali). + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-s-mai-forfait-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeSegreti, statoBadge } from "@/lib/badges"; +import { serieConferme, 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 Mai un forfait sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("s-mai-forfait-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge Mai un forfait su ${URL_BASE}`); + + const PREFISSO = "test-s-mai-forfait-badge"; + const def = badgeSegreti.find((b) => b.id === "s-mai-forfait")!; + 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 ?? {}), + }, + }); + + 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) { + const res = await rest("eventi_app", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + id, + tipo: "allenamento", + titolo: `Evento ${id}`, + data: dataEvento(i), + creato_il: `${dataEvento(i)}T08:00:00Z`, + }), + }); + if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`); + } + + async function rispondi(eventoId: string, giocatoreId: string, i: number) { + 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: "presente", + risposto_il: `${dataEvento(i)}T08:30:00Z`, + }), + }); + 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 leggiPresenzeETempi(): Promise<{ + presenze: Record>; + tempi: Record>; + }> { + const res = await rest( + `risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato,risposto_il`, + ); + const righe = (await res.json()) as Array<{ + evento_id: string; + giocatore_id: string; + stato: string; + risposto_il: string; + }>; + const presenze: Record> = {}; + const tempi: Record> = {}; + for (const r of righe) { + (presenze[r.evento_id] ??= {})[r.giocatore_id] = r.stato; + (tempi[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il; + } + return { presenze, tempi }; + } + + function giocatoreAzzerato(serieConferme: number, presenze: number): Giocatore { + return { + ...giocatori[0]!, + serieConferme, + presenze, + mvp: 0, + mediaVoto: 0, + votiPagella: 0, + palloni: 0, + }; + } + + try { + await prova( + "il segreto si sblocca solo quando entrambe le condizioni sono vere insieme", + async () => { + // "mf1": 15 eventi passati, presente e confermato in fretta a tutti — soddisfa + // ampiamente sia la serie di conferme (>=10) sia le presenze (>=15). + for (let i = 1; i <= 15; i += 1) { + const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`; + await creaEvento(id, i); + await rispondi(id, "mf1", i); + } + + const eventi15 = await leggiEventi(); + const { presenze: p15, tempi: t15 } = await leggiPresenzeETempi(); + + // Solo 9 eventi: serie di conferme appena sotto soglia, presenze sotto soglia. + const eventi9 = eventi15.slice(0, 9); + const serie9 = serieConferme("mf1", eventi9, t15, OGGI); + const pres9 = contaPresenzeGiocatore("mf1", eventi9, p15, OGGI); + assert.equal(serie9, 9); + assert.equal(pres9, 9); + assert.equal( + statoBadge(def, giocatoreAzzerato(serie9, pres9)).grado, + null, + "9 conferme e 9 presenze: nessuna delle due soglie raggiunta", + ); + + const serie15 = serieConferme("mf1", eventi15, t15, OGGI); + const pres15 = contaPresenzeGiocatore("mf1", eventi15, p15, OGGI); + assert.equal(serie15, 15); + assert.equal(pres15, 15); + assert.equal( + statoBadge(def, giocatoreAzzerato(serie15, pres15)).grado, + "oro", + "serie conferme >=10 e presenze >=15: il segreto si sblocca", + ); + }, + ); + + await prova("una risposta lenta rompe la serie ma non le presenze già accumulate", async () => { + // Un sedicesimo evento con risposta arrivata oltre le 24h: la serie conferme torna a 0, + // ma le presenze (indipendenti) restano a 16 — il segreto deve richiudersi, non serve + // ripartire da zero anche sulle presenze. + const idLento = `${PREFISSO}-a16`; + await creaEvento(idLento, 16); + 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: idLento, + giocatore_id: "mf1", + stato: "presente", + risposto_il: `${dataEvento(18)}T08:00:00Z`, // 2 giorni dopo la convocazione + }), + }); + if (!res.ok) + throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`); + + const eventi = await leggiEventi(); + const { presenze, tempi } = await leggiPresenzeETempi(); + const serie = serieConferme("mf1", eventi, tempi, OGGI); + const conto = contaPresenzeGiocatore("mf1", eventi, presenze, OGGI); + assert.equal(serie, 0, "la risposta lenta azzera la serie di conferme"); + assert.equal(conto, 16, "le presenze restano quelle di sempre, indipendenti dalla serie"); + assert.equal( + statoBadge(def, giocatoreAzzerato(serie, conto)).grado, + null, + "presenze abbondanti ma serie azzerata: il segreto si richiude", + ); + }); + } finally { + await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + } + + riepilogo("s-mai-forfait-badge"); +} diff --git a/test/integration/s-ritardi-badge.test.ts b/test/integration/s-ritardi-badge.test.ts new file mode 100644 index 0000000..3a7fbe4 --- /dev/null +++ b/test/integration/s-ritardi-badge.test.ts @@ -0,0 +1,144 @@ +/** + * Badge segreto Aspettate, arrivo! end-to-end contro il database locale: + * `bun test/integration/s-ritardi-badge.test.ts`. + * + * Stessa struttura di `s-infermeria-badge.test.ts` ma sullo stato "ritardo" invece di + * "infortunato": scrive eventi e risposte veri su `eventi_app`/`risposte_presenze`, rilegge + * via REST e passa il risultato attraverso `contaRitardi()` fino a `statoBadge()` sul badge + * segreto `s-ritardi` (soglia 5, unica). + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-s-ritardi-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeSegreti, statoBadge } from "@/lib/badges"; +import { contaRitardi } from "@/lib/infortuni"; +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 Aspettate, arrivo! sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("s-ritardi-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge Aspettate, arrivo! su ${URL_BASE}`); + + const PREFISSO = "test-s-ritardi-badge"; + const def = badgeSegreti.find((b) => b.id === "s-ritardi")!; + 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 ?? {}), + }, + }); + + 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) { + const res = await rest("eventi_app", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + id, + tipo: "allenamento", + titolo: `Evento ${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(ritardi: number): Giocatore { + return { + ...giocatori[0]!, + ritardi, + mvp: 0, + mediaVoto: 0, + votiPagella: 0, + palloni: 0, + presenze: 0, + }; + } + + try { + await prova("il segreto si sblocca al quinto ritardo vero, non prima", async () => { + for (let i = 1; i <= 5; i += 1) { + const id = `${PREFISSO}-e${i}`; + await creaEvento(id, i); + await rispondi(id, "sr1", "ritardo"); + } + + const eventiA4 = (await leggiEventi()).filter((e) => e.id !== `${PREFISSO}-e5`); + const presenzeA4 = await leggiPresenze(); + const contoA4 = contaRitardi(presenzeA4, eventiA4, OGGI)["sr1"] ?? 0; + assert.equal(contoA4, 4, "solo i primi quattro eventi contati"); + assert.equal( + statoBadge(def, giocatoreAzzerato(contoA4)).grado, + null, + "4 ritardi non bastano", + ); + + const eventi = await leggiEventi(); + const presenze = await leggiPresenze(); + const conto = contaRitardi(presenze, eventi, OGGI)["sr1"] ?? 0; + assert.equal(conto, 5); + assert.equal( + statoBadge(def, giocatoreAzzerato(conto)).grado, + "oro", + "5 ritardi: il segreto si sblocca (soglia unica)", + ); + }); + } finally { + await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + } + + riepilogo("s-ritardi-badge"); +} diff --git a/test/integration/s-tiebreak-badge.test.ts b/test/integration/s-tiebreak-badge.test.ts new file mode 100644 index 0000000..a6b9868 --- /dev/null +++ b/test/integration/s-tiebreak-badge.test.ts @@ -0,0 +1,160 @@ +/** + * Badge segreto Uomo tie-break end-to-end contro il database locale: + * `bun test/integration/s-tiebreak-badge.test.ts`. + * + * I test unitari (`test/unit/mvp-voti.test.ts`, `test/unit/pagelle.test.ts`, + * `test/unit/badges.test.ts`) verificano `mvpVintiPerGiocatore()`, `mediePagelle()` e + * `statoBadge()` come funzioni pure. Qui invece si scrivono voti veri su `mvp_voti` e + * `pagelle_voti`, si rileggono via REST e si passa il risultato attraverso + * `mvpVintiPerGiocatore()`/`mediePagelle()` fino a `statoBadge()` sul badge segreto + * `s-tiebreak`. + * + * Copre in particolare il fix di questa sessione: prima `s-tiebreak` usava `g.mediaVoto` senza + * applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale Pagellone che usa lo stesso + * campo — un solo voto pagella altissimo poteva sbloccare il segreto insieme a 2 MVP, senza + * significatività statistica. Qui si dimostra con dati reali che ora serve lo stesso minimo di + * voti di Pagellone anche per questo segreto. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-s-tiebreak-badge`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges"; +import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; +import { mediePagelle, type VotoPagella } from "@/lib/pagelle"; +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 Uomo tie-break sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("s-tiebreak-badge"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`badge Uomo tie-break su ${URL_BASE}`); + + const PREFISSO = "test-s-tiebreak-badge"; + const def = badgeSegreti.find((b) => b.id === "s-tiebreak")!; + + 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 ?? {}), + }, + }); + + async function votaMvp(riga: VotoMvp) { + const res = await rest("mvp_voti?on_conflict=match_id,votante_id", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify(riga), + }); + if (!res.ok) throw new Error(`upsert su mvp_voti: ${res.status} ${await res.text()}`); + } + + async function votaPagella(riga: VotoPagella) { + const res = await rest("pagelle_voti?on_conflict=match_id,votante_id,votato_id", { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify(riga), + }); + if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`); + } + + async function leggiMvp(): Promise { + const res = await rest( + `mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, + ); + return (await res.json()) as VotoMvp[]; + } + + async function leggiPagelle(): Promise { + const res = await rest( + `pagelle_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,voto`, + ); + return (await res.json()) as VotoPagella[]; + } + + function giocatoreAzzerato(mvp: number, mediaVoto: number, votiPagella: number): Giocatore { + return { ...giocatori[0]!, mvp, mediaVoto, votiPagella, palloni: 0, presenze: 0 }; + } + + try { + await prova( + "sotto la soglia minima di voti pagella, 2 MVP e media alta non bastano", + async () => { + // "tb1" vince nettamente m1 e m2 (2 MVP), e riceve un solo voto pagella da 9 (media + // alta ma su un campione troppo piccolo): il segreto deve restare bloccato. + await votaMvp({ + match_id: `${PREFISSO}-m1`, + votante_id: "va", + votato_id: "tb1", + votato_nome: "Uno", + }); + await votaMvp({ + match_id: `${PREFISSO}-m2`, + votante_id: "va", + votato_id: "tb1", + votato_nome: "Uno", + }); + await votaPagella({ + match_id: `${PREFISSO}-m1`, + votante_id: "va", + votato_id: "tb1", + voto: 9, + }); + + const vinti = mvpVintiPerGiocatore(await leggiMvp()); + const medie = mediePagelle(await leggiPagelle()); + assert.equal(vinti["tb1"], 2, "2 MVP netti"); + assert.equal(medie["tb1"]?.voti, 1, "un solo voto pagella"); + assert.equal(medie["tb1"]?.media, 9); + + const badge = statoBadge( + def, + giocatoreAzzerato(vinti["tb1"]!, medie["tb1"]!.media, medie["tb1"]!.voti), + ); + assert.equal( + badge.grado, + null, + `sotto ${VOTI_MINIMI_PAGELLA} voti: il segreto resta bloccato`, + ); + }, + ); + + await prova(`al ${VOTI_MINIMI_PAGELLA}° voto pagella il segreto si sblocca`, async () => { + // Altri 4 voti pagella allo stesso "tb1", sempre alti: raggiunta la soglia minima, con + // 2 MVP e media alta il segreto si sblocca. + for (const [i, votante] of ["vb", "vc", "vd", "ve"].entries()) { + await votaPagella({ + match_id: `${PREFISSO}-n${i + 1}`, + votante_id: votante, + votato_id: "tb1", + voto: 9, + }); + } + + const vinti = mvpVintiPerGiocatore(await leggiMvp()); + const medie = mediePagelle(await leggiPagelle()); + assert.equal(medie["tb1"]?.voti, 5); + assert.equal(medie["tb1"]?.media, 9); + + const badge = statoBadge( + def, + giocatoreAzzerato(vinti["tb1"]!, medie["tb1"]!.media, medie["tb1"]!.voti), + ); + assert.equal(badge.grado, "oro", "2 MVP, 5 voti, media 9: il segreto si sblocca"); + }); + } finally { + await rest(`mvp_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" }); + await rest(`pagelle_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" }); + } + + riepilogo("s-tiebreak-badge"); +} diff --git a/test/unit/badge-social.test.ts b/test/unit/badge-social.test.ts index e58b009..62fc888 100644 --- a/test/unit/badge-social.test.ts +++ b/test/unit/badge-social.test.ts @@ -40,6 +40,27 @@ assert.equal(vincitoreCategoria(voti, "m1", "cuore"), null, "nessun voto, nessun const pari = [v("m3", "meme", "g1", "g2", "Bruno"), v("m3", "meme", "g2", "g5", "Anna")]; assert.equal(vincitoreCategoria(pari, "m3", "meme"), null, "parità: nessun vincitore"); +// Un solo voto totale: vince comunque, non serve concorrenza per avere un vantaggio netto. +const votoSingolo = [v("m4", "cuore", "g1", "g9", "Zoe")]; +assert.equal( + vincitoreCategoria(votoSingolo, "m4", "cuore")?.nome, + "Zoe", + "un voto solo basta se non c'è nessun altro candidato", +); + +// Tre candidati: i primi due pari in testa, il terzo staccato. Il pareggio conta comunque, +// non basta che qualcun altro sia sotto per assegnare la categoria. +const triplaPari = [ + v("m5", "spirito", "g1", "g8", "Uno"), + v("m5", "spirito", "g2", "g7", "Due"), + v("m5", "spirito", "g3", "g6", "Tre"), +]; +assert.equal( + vincitoreCategoria(triplaPari, "m5", "spirito"), + null, + "primo e secondo pari: nessun vincitore anche con un terzo staccato", +); + // --- mioVotoSocial ----------------------------------------------------------- assert.equal(mioVotoSocial(voti, "m1", "affidabile", "g1")?.votato_id, "g2"); assert.equal(mioVotoSocial(voti, "m1", "meme", "g3"), null, "non ho votato questa categoria"); @@ -51,6 +72,19 @@ assert.deepEqual(badgeSocialVinti(voti, "g9"), {}, "chi non vince non ha badge") assert.deepEqual(badgeSocialVinti(pari, "g2"), {}, "una parità non assegna badge"); assert.deepEqual(badgeSocialVinti([], "g2"), {}); +// Categorie e partite diverse non si mischiano: g2 vince "affidabile" in m1/m2 (già sopra) e +// "fairplay" in m2, un'altra categoria nella stessa partita — i due conteggi restano separati. +const conAltraCategoria: VotoSocial[] = [ + ...voti, + v("m2", "fairplay", "g3", "g2", "Bruno"), + v("m2", "fairplay", "g4", "g2", "Bruno"), +]; +assert.deepEqual( + badgeSocialVinti(conAltraCategoria, "g2"), + { affidabile: 2, fairplay: 1 }, + "vittorie in categorie diverse, anche nella stessa partita, si contano separate", +); + // --- invarianti sulle categorie ---------------------------------------------- assert.equal( new Set(categorieSocial.map((c) => c.id)).size, diff --git a/test/unit/badges.test.ts b/test/unit/badges.test.ts index 6aadf4c..aeaf417 100644 --- a/test/unit/badges.test.ts +++ b/test/unit/badges.test.ts @@ -90,6 +90,27 @@ assert.equal( "sopra la soglia minima, valgono le normali soglie di grado", ); +// Confini argento/oro, non solo bronzo: stesso arrotondamento per difetto. +assert.equal(gradoRaggiunto(pagella, 7.4), "bronzo"); +assert.equal(gradoRaggiunto(pagella, 7.5), "argento"); +assert.equal(gradoRaggiunto(pagella, 8.4), "argento"); +assert.equal(gradoRaggiunto(pagella, 8.5), "oro"); + +// Progresso con soglia decimale: valore/prossimaSoglia, non arrotondato per eccesso. +assert.equal( + statoBadge(pagella, g({ mediaVoto: 7, votiPagella: 5 })).progresso, + 93, + "7/7.5 = 93.3%, arrotondato a 93", +); + +// Sotto la soglia minima di voti il valore è forzato a 0: anche il progresso torna a 0%, +// non alla percentuale che la media reale avrebbe suggerito. +assert.equal( + statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 1 })).progresso, + 0, + "valore azzerato dal gate: progresso azzerato anch'esso, non ingannevole", +); + // --- palloni: soglie 3 / 6 / 10 ----------------------------------------------- const palloniDef = badgeDefs.find((b) => b.id === "palloni")!; assert.equal(gradoRaggiunto(palloniDef, 2), null, "sotto la prima soglia nessun grado"); @@ -147,12 +168,22 @@ const nessunSegreto = g({ mvp: 2, mediaVoto: 7.9 }); assert.equal(badgeSegretiSbloccati(nessunSegreto).length, 0, "serve media 8, non 7.9"); assert.equal(segretiNascosti(nessunSegreto), badgeSegreti.length); -const tiebreak = badgeSegretiSbloccati(g({ mvp: 2, mediaVoto: 8 })); +const tiebreak = badgeSegretiSbloccati(g({ mvp: 2, mediaVoto: 8, votiPagella: 5 })); assert.deepEqual( tiebreak.map((b) => b.def.id), ["s-tiebreak"], "sblocca solo il segreto il cui requisito è soddisfatto", ); +assert.equal( + badgeSegretiSbloccati(g({ mvp: 2, mediaVoto: 8, votiPagella: 4 })).length, + 0, + "come Pagellone: sotto la soglia minima di voti la media non conta, nemmeno qui", +); +assert.equal( + badgeSegretiSbloccati(g({ mvp: 1, mediaVoto: 8, votiPagella: 5 })).length, + 0, + "un solo MVP non basta", +); assert.deepEqual( badgeSegretiSbloccati(g({ infortuni: 3 })).map((b) => b.def.id), @@ -163,10 +194,12 @@ assert.deepEqual( badgeSegretiSbloccati(g({ ritardi: 5 })).map((b) => b.def.id), ["s-ritardi"], ); +assert.equal(badgeSegretiSbloccati(g({ ritardi: 4 })).length, 0, "4 ritardi non bastano"); assert.deepEqual( badgeSegretiSbloccati(g({ cacche: 3 })).map((b) => b.def.id), ["s-cacche"], ); +assert.equal(badgeSegretiSbloccati(g({ cacche: 2 })).length, 0, "2 cacche non bastano"); assert.deepEqual( badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 15 })).map((b) => b.def.id), ["s-mai-forfait"], @@ -176,6 +209,27 @@ assert.equal( 0, "servono entrambe le condizioni", ); +// Confini isolati: ogni soglia testata da sola, con l'altra abbondantemente sopra. +assert.equal( + badgeSegretiSbloccati(g({ serieConferme: 9, presenze: 30 })).length, + 0, + "serieConferme appena sotto soglia, presenze abbondanti: non basta", +); +assert.deepEqual( + badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 30 })).map((b) => b.def.id), + ["s-mai-forfait"], + "serieConferme esattamente al confine, presenze abbondanti: sblocca", +); +assert.equal( + badgeSegretiSbloccati(g({ serieConferme: 30, presenze: 14 })).length, + 0, + "presenze appena sotto soglia, serieConferme abbondante: non basta", +); +assert.deepEqual( + badgeSegretiSbloccati(g({ serieConferme: 30, presenze: 15 })).map((b) => b.def.id), + ["s-mai-forfait"], + "presenze esattamente al confine, serieConferme abbondante: sblocca", +); // --- collezioneBadge --------------------------------------------------------- const vuota = collezioneBadge(g()); diff --git a/test/unit/infortuni.test.ts b/test/unit/infortuni.test.ts index 58c0613..6c8458a 100644 --- a/test/unit/infortuni.test.ts +++ b/test/unit/infortuni.test.ts @@ -99,4 +99,14 @@ assert.equal( 1, ); +// Uno stesso giocatore infortunato in un evento e in ritardo in un altro: i due conteggi +// restano indipendenti, nessuno "ruba" all'altro. +const misto: MappaPresenze = { + e1: { g4: "infortunato" }, + e2: { g4: "ritardo" }, + e3: { g4: "infortunato" }, +}; +assert.deepEqual(contaInfortuni(misto, eventi, OGGI), { g4: 2 }); +assert.deepEqual(contaRitardi(misto, eventi, OGGI), { g4: 1 }); + console.log("infortuni: ok"); diff --git a/test/unit/mvp-voti.test.ts b/test/unit/mvp-voti.test.ts index fef42f0..827b83a 100644 --- a/test/unit/mvp-voti.test.ts +++ b/test/unit/mvp-voti.test.ts @@ -52,6 +52,20 @@ assert.deepEqual( "un solo votante basta se non c'è concorrenza", ); +// Tre candidati: i primi due pari in testa, un terzo staccato. Deve restare senza MVP, +// non basta che il terzo sia sotto: conta solo il confronto fra il primo e il secondo. +const triplaPari = [ + v("m6", "g1", "g9", "Zeno"), + v("m6", "g2", "g8", "Anna"), + v("m6", "g3", "g7", "Bea"), +]; +assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie"); +assert.deepEqual( + mvpVintiPerGiocatore(triplaPari), + {}, + "stessa parità: nessuna vittoria netta da contare", +); + // --- mioVoto ----------------------------------------------------------------- assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno"); assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto"); diff --git a/test/unit/palloni-core.test.ts b/test/unit/palloni-core.test.ts index 09f2eab..7954766 100644 --- a/test/unit/palloni-core.test.ts +++ b/test/unit/palloni-core.test.ts @@ -121,6 +121,18 @@ assert.deepEqual( "nessun turno assegnato: conteggio vuoto", ); +// A differenza di `eventiPalloni()` (che scarta i compleanni), `conteggioTurni()` non filtra +// per tipo: guarda solo `turni` ed `e.data < oggi`. Un turno registrato per errore su un +// evento che il resto del modulo tratterebbe come "non richiede palloni" conterebbe comunque +// per il badge. Comportamento attuale documentato (non l'UI non offre questa combinazione), +// non una correzione: se cambia, questo test deve fallire e ricordarlo. +const conCompleanno: Evento[] = [evento("cb1", "2026-09-01", "compleanno")]; +assert.deepEqual( + conteggioTurni({ cb1: "g1" }, conCompleanno, OGGI_CONTEGGIO), + { g1: 1 }, + "conteggioTurni() non esclude i compleanni come fa eventiPalloni(): nessun filtro per tipo", +); + // --- oggiISO ------------------------------------------------------------------- assert.match(oggiISO(), /^\d{4}-\d{2}-\d{2}$/); // Stesso controllo di dataOggi() in scout-live.test.ts: oggiISO() ne è un alias, il fuso