Completa la copertura test del badge Sherpa dei palloni e ne documenta il comportamento

Nessun bug nella logica di calcolo: soglie 3/6/10 corrette, gradoRaggiunto()/
statoBadge() si comportano come per gli altri badge da contatore. Trovato però un
comportamento reale ma non ovvio (già accennato in palloni.md, mai dimostrato con
dati veri): il conteggio del badge include anche le proposte automatiche di
completaTurni() non ancora confermate da nessuno, non solo i turni salvati in
turni_palloni.

Aggiunti test unit sulle soglie (badges.test.ts) e un nuovo end-to-end
(palloni-badge.test.ts) che scrive eventi/turni parzialmente confermati sul
database locale e verifica l'intera pipeline fetchTurni() -> completaTurni() ->
conteggioTurni() -> statoBadge(), incluso il caso "evento futuro non conta".
docs/modules/badge.md aggiornato con la pipeline, la copertura test e il limite
noto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 14:26:41 +02:00
co-authored by Claude Sonnet 5
parent 1fdc6720a0
commit e6a222f196
3 changed files with 228 additions and 8 deletions
+43 -8
View File
@@ -32,7 +32,17 @@ badge assegnati per voto dai compagni.
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
Pagellone, l'MVP e i badge social.
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
[palloni.md](palloni.md), non ripetuto qui. Un punto rilevante per il badge, **non ovvio**:
il conteggio include anche le proposte automatiche di `completaTurni()` **non ancora
confermate** da nessuno (solo per partite/eventi, mai per allenamenti), non solo i turni
salvati esplicitamente in `turni_palloni` — un giocatore può ricevere credito per un turno
che non ha mai confermato di aver fatto, semplicemente perché l'algoritmo di rotazione lo ha
proposto per un evento ormai passato. Conta solo per eventi già trascorsi (`e.data < oggi`,
stesso criterio delle presenze).
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
@@ -81,7 +91,7 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| --- | --- | --- | --- |
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
| `palloni` | Sherpa dei palloni | quante volte ti sei incaricato di portare la sacca palloni (`g.palloni`) | 3 / 6 / 10 |
| `palloni` | Sherpa dei palloni | quante volte hai fatto (o ti è stato proposto, vedi sopra) il turno palloni (`g.palloni`) | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze a eventi/partite in stagione (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti, senza saltarne uno (`g.serieAllenamenti`) | 3 / 6 / 10 |
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
@@ -138,13 +148,16 @@ categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge.
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
bronzo". Più la soglia minima di voti (vedi sotto).
- `palloni`, `presenze`, `serie-allenamenti`, `serie-conferme`: stessa funzione di soglia già
testata a fondo su `mvp`/`pagella`, coperti dagli invarianti generali
- `palloni`: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e
oltre l'oro resta oro, `badges.test.ts:94-104`), oltre a un caso di progresso non tondo
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
- `presenze`, `serie-allenamenti`, `serie-conferme`: stessa funzione di soglia già testata a
fondo su `mvp`/`pagella`/`palloni`, coperti dagli invarianti generali
(`badges.test.ts:154-159`: soglie crescenti, testi presenti, id unici) e da
`collezioneBadge`/`prossimoTraguardo` con valori al massimo (`:119-144`).
- Nessun integration dedicato per questi 4: non toccano il database, le statistiche sorgente
(`presenze.test.ts`, `palloni-core.test.ts`, ecc.) sono già coperte nei rispettivi moduli.
`mvp` e `pagella` fanno eccezione (sotto) perché la loro fonte passa da una tabella di voto.
- Nessun integration dedicato per questi 3: non toccano il database, le statistiche sorgente
(`presenze.test.ts`, ecc.) sono già coperte nei rispettivi moduli. `mvp`, `pagella` e
`palloni` fanno eccezione (sotto) perché la loro fonte passa da una tabella di voto/turni.
**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`
@@ -184,6 +197,23 @@ dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
applichi le soglie normali sopra soglia.
**Badge Sherpa dei palloni — pipeline end-to-end e proposte non confermate** (analisi
dedicata: nessun bug trovato, ma il comportamento "le proposte contano" — già in
`palloni.md` — non era mai stato dimostrato con dati veri):
- Unit: `badges.test.ts:93-104` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
`palloni-core.test.ts`, già completo prima di questa sessione (`completaTurni()`,
`conteggioTurni()`, rotazione bilanciata su un giro completo di partite, allenamenti mai
proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
- `palloni-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi e turni **solo
parzialmente confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa
`fetchTurni()`/`daRiga()`, passa i dati per `completaTurni()``conteggioTurni()` fino a
`statoBadge()`: dimostra che un evento passato senza turno confermato riceve comunque una
proposta automatica che **conta per il badge di chi viene scelto**, anche se non ha mai
confermato nulla; verifica anche che un evento futuro non conti, pur avendo già
un'assegnazione.
**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
@@ -201,7 +231,7 @@ meccanismo:
| - | --- | --- | --- | --- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | non necessario |
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
| 4 | `presenze` | normale | ✅ | non necessario |
| 5 | `serie-allenamenti` | normale | ✅ (limite noto sotto) | non necessario |
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | non necessario |
@@ -243,6 +273,11 @@ Trovati in audit, nessuno bloccante (nessun bug nella logica di calcolo):
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente.
- **Sherpa dei palloni conta anche le proposte non confermate**: `g.palloni` include i turni
che `completaTurni()` propone in automatico per un evento passato senza assegnazione
esplicita, non solo quelli confermati in `turni_palloni` — comportamento voluto (documentato
in [palloni.md](palloni.md)), dimostrato con dati veri in `palloni-badge.test.ts`, ma non
intuitivo: un giocatore può vedere avanzare il badge senza aver mai cliccato nulla.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo.
+170
View File
@@ -0,0 +1,170 @@
/**
* Badge Sherpa dei palloni end-to-end contro il database locale:
* `bun test/integration/palloni-badge.test.ts`.
*
* I test unitari (`test/unit/palloni-core.test.ts`, `test/unit/badges.test.ts`) verificano
* `completaTurni()`/`conteggioTurni()` e `statoBadge()` come funzioni pure, con eventi e turni
* costruiti a mano. Qui invece si scrivono eventi e turni veri su `eventi_app`/`turni_palloni`,
* si rileggono via REST con la stessa forma di `fetchTurni()`/`daRiga()`, e si passa il
* risultato attraverso `completaTurni()` → `conteggioTurni()` fino a `statoBadge()` sul badge
* `palloni`: se una colonna cambia nome o la mappatura si rompe, qui il grado del badge torna
* sbagliato anche se i test unitari restano verdi, perché quelli non toccano mai il database.
*
* Copre in particolare un comportamento **documentato ma non ovvio** (`docs/modules/
* palloni.md` § Limiti noti): il conteggio usato dal badge include anche le proposte
* automatiche non ancora confermate da nessuno, non solo i turni salvati esplicitamente. Qui
* lo si dimostra con dati veri, non solo con l'affermazione in doc.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-palloni-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { completaTurni, conteggioTurni, type CandidatoTurno } from "@/lib/palloni-core";
import { 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 palloni sul database", "stack locale non attivo (npx supabase start)");
riepilogo("palloni-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge palloni su ${URL_BASE}`);
const PREFISSO = "test-palloni-badge";
const palloniDef = badgeDefs.find((b) => b.id === "palloni")!;
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono tutti datati nel passato remoto,
// così restano "passati" (e quindi contati) a prescindere da quando gira il test.
const OGGI = "2099-01-01";
const ROSA: CandidatoTurno[] = [
{ id: "pv1", nome: "Uno" },
{ id: "pv2", nome: "Due" },
];
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function creaEvento(id: string, data: string) {
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 }),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function confermaTurno(eventoId: string, giocatoreId: string) {
const res = await rest("turni_palloni?on_conflict=evento_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId }),
});
if (!res.ok) throw new Error(`upsert su turni_palloni: ${res.status} ${await res.text()}`);
}
/** Rilegge esattamente come fa l'app: `daRiga()` per gli eventi, una mappa per i turni. */
async function leggiEventi(): Promise<Evento[]> {
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 leggiTurniSalvati(): Promise<Record<string, string>> {
const res = await rest(
`turni_palloni?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id`,
);
const righe = (await res.json()) as Array<{ evento_id: string; giocatore_id: string }>;
const mappa: Record<string, string> = {};
for (const r of righe) mappa[r.evento_id] = r.giocatore_id;
return mappa;
}
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
function giocatoreAzzerato(palloni: number): Giocatore {
return { ...giocatori[0]!, palloni, mvp: 0, mediaVoto: 0, votiPagella: 0, presenze: 0 };
}
try {
await prova(
"il badge palloni passa da bronzo ad argento con turni reali, incluse le proposte non confermate",
async () => {
// m1-m3: turno confermato esplicitamente a pv1 (3 turni -> soglia bronzo).
// m4: NESSUN turno salvato -> completaTurni() deve proporlo in automatico a chi ha
// fatto meno turni (pv2, a zero) -> conta comunque per il badge di pv2, pur non
// essendo mai stato confermato da nessuno (comportamento documentato in palloni.md).
// m5-m7: confermati di nuovo a pv1 (6 turni totali -> soglia argento).
const eventi = [
[`${PREFISSO}-m1`, "2020-01-01", "pv1"],
[`${PREFISSO}-m2`, "2020-01-02", "pv1"],
[`${PREFISSO}-m3`, "2020-01-03", "pv1"],
[`${PREFISSO}-m4`, "2020-01-04", null],
[`${PREFISSO}-m5`, "2020-01-05", "pv1"],
[`${PREFISSO}-m6`, "2020-01-06", "pv1"],
[`${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);
const eventiLetti = await leggiEventi();
assert.equal(eventiLetti.length, 7, "tutti gli eventi scritti si rileggono");
const salvati = await leggiTurniSalvati();
assert.equal(Object.keys(salvati).length, 6, "6 turni confermati esplicitamente, m4 no");
const turniCompleti = completaTurni(salvati, eventiLetti, ROSA);
assert.equal(
turniCompleti[`${PREFISSO}-m4`],
"pv2",
"m4 senza turno salvato: la proposta automatica sceglie pv2 (meno turni)",
);
const conteggio = conteggioTurni(turniCompleti, eventiLetti, OGGI);
assert.equal(conteggio["pv1"], 6, "6 turni confermati per pv1");
assert.equal(
conteggio["pv2"],
1,
"pv2 non ha mai confermato nulla, ma la proposta per m4 conta comunque",
);
const badgePv1 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv1"]!));
assert.equal(badgePv1.grado, "argento", "6 turni: soglia argento raggiunta");
const badgePv2 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv2"]!));
assert.equal(badgePv2.grado, null, "1 turno (solo proposto): sotto la soglia bronzo (3)");
},
);
await prova("un evento futuro non riceve conteggio, anche se già assegnato", async () => {
// Stesso scenario di sopra ma con "oggi" prima di tutti gli eventi: nessuno dei turni,
// confermati o proposti, deve ancora contare per il badge.
const eventiLetti = await leggiEventi();
const salvati = await leggiTurniSalvati();
const turniCompleti = completaTurni(salvati, eventiLetti, ROSA);
const conteggio = conteggioTurni(turniCompleti, eventiLetti, "2019-01-01");
assert.deepEqual(conteggio, {}, "tutti gli eventi sono nel futuro rispetto a 'oggi'");
});
} finally {
await rest(`turni_palloni?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("palloni-badge");
}
+15
View File
@@ -90,6 +90,21 @@ assert.equal(
"sopra la soglia minima, valgono le normali soglie di grado",
);
// --- 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");
assert.equal(gradoRaggiunto(palloniDef, 3), "bronzo", "la soglia è inclusiva");
assert.equal(gradoRaggiunto(palloniDef, 5), "bronzo");
assert.equal(gradoRaggiunto(palloniDef, 6), "argento");
assert.equal(gradoRaggiunto(palloniDef, 9), "argento");
assert.equal(gradoRaggiunto(palloniDef, 10), "oro");
assert.equal(gradoRaggiunto(palloniDef, 99), "oro", "oltre l'oro resta oro");
assert.equal(
statoBadge(palloniDef, g({ palloni: 5 })).progresso,
83,
"5 turni su 6 per l'argento: 83%",
);
// --- badgeGiocatore ----------------------------------------------------------
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
assert.ok(