Completa la copertura test del badge Sempre in palestra e ne documenta il comportamento
Nessun bug trovato: serieConsecutiva() gestisce correttamente buco (azzera) vs infortunio (congela senza azzerare), lo stesso filtro sui convocati della funzione pura protegge anche questo badge senza bisogno dell'estensione RLS di M13. Confermato che il limite noto "risposto_il non ricostruibile prima di m9" riguarda solo serie-conferme e il segreto s-mai-forfait, non questo badge: serieConsecutiva() guarda solo lo stato della risposta, non il suo istante. Aggiunti test unit sulle soglie (badges.test.ts) e un nuovo end-to-end (serie-allenamenti-badge.test.ts) che scrive allenamenti e risposte reali sul database locale e verifica l'intera pipeline fetchPresenze()/daRiga() -> serieConsecutiva() -> statoBadge() attraverso le tre soglie, incluso il caso "un buco azzera" e, separatamente, "un infortunio congela invece di azzerare". docs/modules/badge.md aggiornato con la pipeline, la copertura test e la descrizione corretta (l'infortunio non spezza la serie). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+33
-10
@@ -93,7 +93,7 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
|
||||
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
|
||||
| `palloni` | Sherpa dei palloni | quante volte hai fatto (o ti è stato proposto, vedi sopra) il turno palloni (`g.palloni`) | 3 / 6 / 10 |
|
||||
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
|
||||
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti, senza saltarne uno (`g.serieAllenamenti`) | 3 / 6 / 10 |
|
||||
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
|
||||
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
|
||||
|
||||
### Badge segreti (booleani, nascosti finché non sbloccati)
|
||||
@@ -153,14 +153,19 @@ categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge.
|
||||
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
|
||||
- `presenze`: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro,
|
||||
`badges.test.ts:108-116`). Pipeline end-to-end sotto, come `mvp`/`pagella`/`palloni`.
|
||||
- `serie-allenamenti`, `serie-conferme`: stessa funzione di soglia già testata a fondo sugli
|
||||
altri badge normali, coperti dagli invarianti generali (`badges.test.ts:154-159`: soglie
|
||||
crescenti, testi presenti, id unici) e da `collezioneBadge`/`prossimoTraguardo` con valori
|
||||
al massimo (`:119-144`).
|
||||
- Nessun integration dedicato per questi 2: non toccano il database, le statistiche sorgente
|
||||
(`serie.test.ts`) sono già coperte nel modulo Serie. `mvp`, `pagella`, `palloni` e
|
||||
`presenze` fanno eccezione (sotto) perché la loro fonte passa da una tabella di voto/turni/
|
||||
presenze, non da un contatore già calcolato altrove.
|
||||
- `serie-allenamenti`: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro
|
||||
resta oro, `badges.test.ts:118-126`). Pipeline end-to-end sotto, come gli altri badge da
|
||||
tabella.
|
||||
- `serie-conferme`: stessa funzione di soglia già testata a fondo sugli altri badge normali,
|
||||
coperta dagli invarianti generali (`badges.test.ts:154-159`: soglie crescenti, testi
|
||||
presenti, id unici) e da `collezioneBadge`/`prossimoTraguardo` con valori al massimo
|
||||
(`:119-144`). Nessun integration dedicato: non tocca direttamente il database, e la sua
|
||||
fonte (`serieConferme()`) ha comunque il limite noto sui dati precedenti a `m9` descritto
|
||||
sotto in "Limiti noti" — un test end-to-end aggiuntivo non lo cambierebbe.
|
||||
|
||||
`mvp`, `pagella`, `palloni`, `presenze` e `serie-allenamenti` sono le eccezioni con
|
||||
integration dedicato (sotto) perché la loro fonte passa da una tabella di voto/turni/presenze
|
||||
letta e ricalcolata dal vivo, non da un contatore già pronto altrove.
|
||||
|
||||
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto
|
||||
(`badges.test.ts:77-109`): `s-tiebreak` (mediaVoto 7.9 non basta, serve 8), `s-mai-forfait`
|
||||
@@ -236,6 +241,24 @@ M13 — vedi sotto):
|
||||
MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella
|
||||
funzione pura che il badge consuma, non solo in UI.
|
||||
|
||||
**Badge Sempre in palestra — pipeline end-to-end** (analisi dedicata: nessun bug trovato).
|
||||
Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un totale, una
|
||||
**serie consecutiva** che un buco azzera e un infortunio congela. Anche qui, come per
|
||||
`presenze`, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione
|
||||
pura.
|
||||
- Unit: `badges.test.ts:118-126` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, già completo prima di questa sessione su `serieConsecutiva()` (buco che
|
||||
azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco,
|
||||
convocati che non spezzano la serie di chi non era coinvolto).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `serie-allenamenti-badge.test.ts` (nuovo) — end-to-end reale: scrive allenamenti e
|
||||
risposte su `eventi_app`/`risposte_presenze`, rilegge via REST e verifica che
|
||||
`statoBadge()` attraversi bronzo/argento/oro con presenze consecutive vere, che
|
||||
un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente
|
||||
— che un infortunio **non** azzeri la serie ma la lasci congelata (3 presenze vere,
|
||||
un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3,
|
||||
non riparte da 1).
|
||||
|
||||
**Badge social** — nessuna delle 5 categorie ha logica *propria* nel codice: l'id è solo una
|
||||
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
|
||||
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
|
||||
@@ -255,7 +278,7 @@ meccanismo:
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ (limite noto sotto) | non necessario |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | non necessario |
|
||||
| 7 | `s-tiebreak` | segreto | ✅ | non necessario |
|
||||
| 8 | `s-mai-forfait` | segreto | ✅ | non necessario |
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Badge Sempre in palestra end-to-end contro il database locale:
|
||||
* `bun test/integration/serie-allenamenti-badge.test.ts`.
|
||||
*
|
||||
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||
* `serieConsecutiva()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
|
||||
* mano. Qui invece si scrivono allenamenti e risposte veri su `eventi_app`/
|
||||
* `risposte_presenze`, si rileggono via REST con la stessa forma di `fetchPresenze()`/
|
||||
* `daRiga()`, e si passa il risultato attraverso `serieConsecutiva()` fino a `statoBadge()` sul
|
||||
* badge `serie-allenamenti`: se una colonna cambia nome o la mappatura si rompe, qui il grado
|
||||
* del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano
|
||||
* mai il database.
|
||||
*
|
||||
* Copre due comportamenti non ovvi con dati veri, non solo con numeri scelti a mano:
|
||||
* - un buco nella serie (assente o nessuna risposta) la azzera;
|
||||
* - un infortunio invece la **congela** (non la azzera): l'allenamento saltato per infortunio
|
||||
* non conta né a favore né contro, e la serie riparte da dove si era fermata.
|
||||
*
|
||||
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||
* usa id con il prefisso `test-serie-allenamenti-badge`, che nessun dato vero può avere.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { badgeDefs, statoBadge } from "@/lib/badges";
|
||||
import { serieConsecutiva } from "@/lib/presenze";
|
||||
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
|
||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
if (!locale) {
|
||||
salta("badge Sempre in palestra sul database", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("serie-allenamenti-badge");
|
||||
} else {
|
||||
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||
console.log(`badge Sempre in palestra su ${URL_BASE}`);
|
||||
|
||||
const PREFISSO = "test-serie-allenamenti-badge";
|
||||
const serieDef = badgeDefs.find((b) => b.id === "serie-allenamenti")!;
|
||||
// "Oggi" fissato nel futuro: gli allenamenti scritti sotto sono datati nel passato remoto,
|
||||
// così restano "passati" (quindi contati) a prescindere da quando gira il test.
|
||||
const OGGI = "2099-01-01";
|
||||
|
||||
const rest = (percorso: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: SERVIZIO,
|
||||
Authorization: `Bearer ${SERVIZIO}`,
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
/** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */
|
||||
const dataEvento = (i: number) => {
|
||||
const d = new Date(Date.UTC(2020, 0, 1));
|
||||
d.setUTCDate(d.getUTCDate() + i - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
async function creaAllenamento(id: string, i: number) {
|
||||
const res = await rest("eventi_app", {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
tipo: "allenamento",
|
||||
titolo: `Allenamento ${id}`,
|
||||
data: dataEvento(i),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function rispondi(eventoId: string, giocatoreId: string, stato: string) {
|
||||
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, stato }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function leggiEventi(): Promise<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 leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||
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<string, Record<string, string>> = {};
|
||||
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||
return mappa;
|
||||
}
|
||||
|
||||
function giocatoreAzzerato(serieAllenamenti: number): Giocatore {
|
||||
return {
|
||||
...giocatori[0]!,
|
||||
serieAllenamenti,
|
||||
mvp: 0,
|
||||
mediaVoto: 0,
|
||||
votiPagella: 0,
|
||||
palloni: 0,
|
||||
presenze: 0,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await prova(
|
||||
"il badge passa da bronzo a oro con presenze consecutive vere, e un buco lo azzera",
|
||||
async () => {
|
||||
// 10 allenamenti passati, tutti presenti per "sa1": bronzo (3), poi argento (6), poi
|
||||
// oro (10) man mano che si rileggono i dati.
|
||||
for (let i = 1; i <= 10; i += 1) {
|
||||
await creaAllenamento(`${PREFISSO}-a${String(i).padStart(2, "0")}`, i);
|
||||
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "sa1", "presente");
|
||||
}
|
||||
const eventi10 = await leggiEventi();
|
||||
const presenze10 = await leggiPresenze();
|
||||
const serieA3 = serieConsecutiva(
|
||||
"sa1",
|
||||
eventi10.slice(0, 3),
|
||||
presenze10,
|
||||
"allenamento",
|
||||
OGGI,
|
||||
);
|
||||
assert.equal(serieA3, 3);
|
||||
assert.equal(
|
||||
statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado,
|
||||
"bronzo",
|
||||
"3 allenamenti di fila: soglia bronzo",
|
||||
);
|
||||
|
||||
const serieCompleta = serieConsecutiva("sa1", eventi10, presenze10, "allenamento", OGGI);
|
||||
assert.equal(serieCompleta, 10, "presente a tutti e 10, nessun buco");
|
||||
assert.equal(
|
||||
statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado,
|
||||
"oro",
|
||||
"10 allenamenti di fila: soglia oro",
|
||||
);
|
||||
|
||||
// Un undicesimo allenamento con un buco (assente) azzera la serie.
|
||||
await creaAllenamento(`${PREFISSO}-a11`, 11);
|
||||
await rispondi(`${PREFISSO}-a11`, "sa1", "assente");
|
||||
const eventiConBuco = await leggiEventi();
|
||||
const presenzeConBuco = await leggiPresenze();
|
||||
const serieDopoBuco = serieConsecutiva(
|
||||
"sa1",
|
||||
eventiConBuco,
|
||||
presenzeConBuco,
|
||||
"allenamento",
|
||||
OGGI,
|
||||
);
|
||||
assert.equal(serieDopoBuco, 0, "un'assenza azzera la serie, anche dopo 10 di fila");
|
||||
assert.equal(
|
||||
statoBadge(serieDef, giocatoreAzzerato(serieDopoBuco)).grado,
|
||||
null,
|
||||
"serie azzerata: nessun grado, si riparte da zero",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await prova("un infortunio congela la serie invece di azzerarla", async () => {
|
||||
// "sa2": presente ai primi due allenamenti, poi infortunato al terzo (saltato, non
|
||||
// conta né a favore né contro), poi di nuovo presente: la serie deve restare a 3, non
|
||||
// ripartire da 1.
|
||||
const eventi = [`${PREFISSO}-b01`, `${PREFISSO}-b02`, `${PREFISSO}-b03`, `${PREFISSO}-b04`];
|
||||
for (let i = 0; i < eventi.length; i += 1) {
|
||||
await creaAllenamento(eventi[i]!, 20 + i);
|
||||
}
|
||||
await rispondi(eventi[0]!, "sa2", "presente");
|
||||
await rispondi(eventi[1]!, "sa2", "presente");
|
||||
await rispondi(eventi[2]!, "sa2", "infortunato");
|
||||
await rispondi(eventi[3]!, "sa2", "presente");
|
||||
|
||||
const eventiLetti = await leggiEventi();
|
||||
const presenzeLette = await leggiPresenze();
|
||||
const serie = serieConsecutiva("sa2", eventiLetti, presenzeLette, "allenamento", OGGI);
|
||||
assert.equal(serie, 3, "l'infortunio è saltato: 3 presenze restano consecutive");
|
||||
assert.equal(
|
||||
statoBadge(serieDef, giocatoreAzzerato(serie)).grado,
|
||||
"bronzo",
|
||||
"3 di fila (infortunio escluso dal conteggio): soglia bronzo",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
riepilogo("serie-allenamenti-badge");
|
||||
}
|
||||
@@ -115,6 +115,16 @@ assert.equal(gradoRaggiunto(presenzeDef, 29), "argento");
|
||||
assert.equal(gradoRaggiunto(presenzeDef, 30), "oro");
|
||||
assert.equal(gradoRaggiunto(presenzeDef, 999), "oro", "oltre l'oro resta oro");
|
||||
|
||||
// --- serie-allenamenti: soglie 3 / 6 / 10 -------------------------------------
|
||||
const serieAllenamentiDef = badgeDefs.find((b) => b.id === "serie-allenamenti")!;
|
||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 2), null, "sotto la prima soglia nessun grado");
|
||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 3), "bronzo", "la soglia è inclusiva");
|
||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 5), "bronzo");
|
||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 6), "argento");
|
||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 9), "argento");
|
||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro");
|
||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro");
|
||||
|
||||
// --- badgeGiocatore ----------------------------------------------------------
|
||||
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
|
||||
assert.ok(
|
||||
|
||||
Reference in New Issue
Block a user