Sistema i buchi del badge Pagellone: soglia minima voti e voto limitato ai convocati
Il badge Pagellone si sblocca ora solo con almeno 5 pagelle ricevute (VOTI_MINIMI_PAGELLA): un voto solo poteva sbloccarlo o farlo sparire senza significatività statistica. Aggiunto Giocatore.votiPagella per farlo funzionare. La migration m13 (DD-027) estende le policy RLS di M11 su pagelle_voti, mvp_voti e badge_social_voti: votante e votato devono essere convocati all'evento (prima solo un filtro applicativo), e per le pagelle anche pagelle_chiuse=false. Le policy admin restano permissive di proposito. Applicata anche al progetto cloud. Copertura test completa: unit sulla soglia minima, integration sulle nuove policy RLS (permessi.test.ts) e un end-to-end reale (pagella-badge.test.ts) sul modello di mvp-badge.test.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Badge Pagellone end-to-end contro il database locale: `bun test/integration/pagella-badge.test.ts`.
|
||||
*
|
||||
* I test unitari (`test/unit/pagelle.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||
* `mediePagelle()` e `statoBadge()` come funzioni pure, con voti costruiti a mano. Qui invece
|
||||
* si scrivono voti veri su `pagelle_voti`, si rileggono via REST con la stessa selezione di
|
||||
* `usePagelle()`, e si passa il risultato attraverso `mediePagelle()` fino a `statoBadge()` sul
|
||||
* badge `pagella`: 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 la soglia minima di voti (`VOTI_MINIMI_PAGELLA`, aggiunta per il gap
|
||||
* "un solo voto sblocca/toglie il badge" segnalato in `docs/modules/badge.md`): con voti reali
|
||||
* letti dal database, non solo con numeri scelti a mano.
|
||||
*
|
||||
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||
* usa id con il prefisso `test-pagella-badge`, che nessun dato vero può avere.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { badgeDefs, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
|
||||
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 Pagellone sul database", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("pagella-badge");
|
||||
} else {
|
||||
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||
console.log(`badge Pagellone su ${URL_BASE}`);
|
||||
|
||||
const PREFISSO = "test-pagella-badge";
|
||||
const pagellaDef = badgeDefs.find((b) => b.id === "pagella")!;
|
||||
|
||||
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 upsert(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()}`);
|
||||
}
|
||||
|
||||
/** Rilegge esattamente come `usePagelle()`. */
|
||||
async function leggiVoti(): Promise<VotoPagella[]> {
|
||||
const res = await rest(
|
||||
`pagelle_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,voto`,
|
||||
);
|
||||
return (await res.json()) as VotoPagella[];
|
||||
}
|
||||
|
||||
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
|
||||
function giocatoreAzzerato(mediaVoto: number, votiPagella: number): Giocatore {
|
||||
return { ...giocatori[0]!, mediaVoto, votiPagella, mvp: 0, palloni: 0, presenze: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
await prova(
|
||||
`sotto ${VOTI_MINIMI_PAGELLA} voti il badge resta bloccato anche con media alta`,
|
||||
async () => {
|
||||
// "pg1" riceve 4 voti da 9-10 (media altissima) ma sotto la soglia minima di voti:
|
||||
// il badge non deve sbloccarsi nonostante la media sarebbe oro.
|
||||
for (const [i, votante] of ["va", "vb", "vc", "vd"].entries()) {
|
||||
await upsert({
|
||||
match_id: `${PREFISSO}-m${i + 1}`,
|
||||
votante_id: votante,
|
||||
votato_id: "pg1",
|
||||
voto: 9,
|
||||
});
|
||||
}
|
||||
|
||||
const voti = await leggiVoti();
|
||||
const medie = mediePagelle(voti);
|
||||
assert.equal(medie["pg1"]?.voti, 4, "4 voti scritti, 4 riletti");
|
||||
assert.equal(medie["pg1"]?.media, 9, "media alta");
|
||||
|
||||
const badgePg1 = statoBadge(
|
||||
pagellaDef,
|
||||
giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti),
|
||||
);
|
||||
assert.equal(badgePg1.grado, null, "4 voti < 5: il badge resta bloccato");
|
||||
},
|
||||
);
|
||||
|
||||
await prova(
|
||||
`al ${VOTI_MINIMI_PAGELLA}° voto il badge Pagellone si sblocca con il grado giusto`,
|
||||
async () => {
|
||||
// Un quinto voto a "pg1" (stesso giocatore del test precedente) fa scattare la
|
||||
// soglia minima: la media (9) sblocca subito l'oro (soglia 8.5).
|
||||
await upsert({
|
||||
match_id: `${PREFISSO}-m5`,
|
||||
votante_id: "ve",
|
||||
votato_id: "pg1",
|
||||
voto: 9,
|
||||
});
|
||||
|
||||
const medie = mediePagelle(await leggiVoti());
|
||||
assert.equal(medie["pg1"]?.voti, 5);
|
||||
const badgePg1 = statoBadge(
|
||||
pagellaDef,
|
||||
giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti),
|
||||
);
|
||||
assert.equal(badgePg1.grado, "oro", "5 voti raggiunti: la media conta, è oro");
|
||||
},
|
||||
);
|
||||
|
||||
await prova("le soglie di grado normali si applicano sopra il minimo di voti", async () => {
|
||||
// "pg2" riceve 5 voti che fanno una media di bronzo (6.5), non oro: verifica che sopra
|
||||
// la soglia minima il grado dipenda ancora dalla media, non solo dal numero di voti.
|
||||
for (const [i, votante] of ["va", "vb", "vc", "vd", "ve"].entries()) {
|
||||
await upsert({
|
||||
match_id: `${PREFISSO}-n${i + 1}`,
|
||||
votante_id: votante,
|
||||
votato_id: "pg2",
|
||||
voto: i < 4 ? 6 : 9, // (6*4+9)/5 = 6.6 -> arrotondato 6.6, sopra 6.5
|
||||
});
|
||||
}
|
||||
const medie = mediePagelle(await leggiVoti());
|
||||
assert.equal(medie["pg2"]?.voti, 5);
|
||||
assert.equal(medie["pg2"]?.media, 6.6);
|
||||
const badgePg2 = statoBadge(
|
||||
pagellaDef,
|
||||
giocatoreAzzerato(medie["pg2"]!.media, medie["pg2"]!.voti),
|
||||
);
|
||||
assert.equal(badgePg2.grado, "bronzo", "media 6.6: bronzo, non oro");
|
||||
});
|
||||
} finally {
|
||||
await rest(`pagelle_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
riepilogo("pagella-badge");
|
||||
}
|
||||
@@ -406,6 +406,142 @@ if (!locale) {
|
||||
assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro");
|
||||
});
|
||||
|
||||
// M13: le tabelle di voto controllano anche a database chi può votare chi, non solo
|
||||
// chi firma il voto. Prima di M13 un convocato poteva votare/essere votato in un
|
||||
// evento a cui non aveva partecipato, e un voto pagella restava possibile anche a
|
||||
// `pagelle_chiuse` — entrambi filtri solo applicativi (segnalati in `badge.md`).
|
||||
// Nota: `tokenAdmin` non va usato per queste prove, la policy admin di M11 non ha il
|
||||
// controllo sui convocati (l'admin corregge anche dati fuori convocazione di
|
||||
// proposito) e farebbe passare tutto a prescindere, senza provare niente sulla nuova
|
||||
// policy. Si usa solo `tokenGiocatore` (g1), un votante non-admin vero.
|
||||
const EVENTO_SENZA_G1 = `${PREFISSO}-evento-senza-g1`;
|
||||
const EVENTO_CON_G1 = `${PREFISSO}-evento-con-g1`;
|
||||
const EVENTO_CHIUSO = `${PREFISSO}-evento-chiuso`;
|
||||
|
||||
await prova("un votante non convocato non può votare", async () => {
|
||||
const creato = await rest("eventi_app", tokenAdmin, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
id: EVENTO_SENZA_G1,
|
||||
tipo: "partita",
|
||||
titolo: "Partita senza g1 tra i convocati",
|
||||
data: "2026-01-02",
|
||||
ora: "20:00",
|
||||
luogo: "Palestra",
|
||||
convocati: ["g2", "g5"],
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea");
|
||||
|
||||
const votanteEscluso = await rest("pagelle_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_SENZA_G1,
|
||||
votante_id: "g1",
|
||||
votato_id: "g2",
|
||||
voto: 7,
|
||||
}),
|
||||
});
|
||||
assert.ok(!votanteEscluso.ok, `g1 non era convocato, non vota (${votanteEscluso.status})`);
|
||||
});
|
||||
|
||||
await prova("un votante convocato non può votare chi non lo era", async () => {
|
||||
const creato = await rest("eventi_app", tokenAdmin, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
id: EVENTO_CON_G1,
|
||||
tipo: "partita",
|
||||
titolo: "Partita con g1 convocato, g2 no",
|
||||
data: "2026-01-02",
|
||||
ora: "20:00",
|
||||
luogo: "Palestra",
|
||||
convocati: ["g1", "g5"],
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea");
|
||||
|
||||
const votatoEscluso = await rest("badge_social_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_CON_G1,
|
||||
categoria: "cuore",
|
||||
votante_id: "g1",
|
||||
votato_id: "g2",
|
||||
votato_nome: "Due",
|
||||
}),
|
||||
});
|
||||
assert.ok(
|
||||
!votatoEscluso.ok,
|
||||
`g2 non era convocato, non è votabile (${votatoEscluso.status})`,
|
||||
);
|
||||
|
||||
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
|
||||
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
|
||||
// (es. un evento inesistente) e sembrare comunque corretto.
|
||||
const votoValido = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_CON_G1,
|
||||
votante_id: "g1",
|
||||
votato_id: "g5",
|
||||
votato_nome: "Cinque",
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(votoValido), 1, "votante e votato convocati: il voto passa");
|
||||
});
|
||||
|
||||
await prova("pagelle_chiuse blocca anche a database, non solo in UI", async () => {
|
||||
const creato = await rest("eventi_app", tokenAdmin, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
id: EVENTO_CHIUSO,
|
||||
tipo: "partita",
|
||||
titolo: "Partita con pagelle chiuse",
|
||||
data: "2026-01-03",
|
||||
ora: "20:00",
|
||||
luogo: "Palestra",
|
||||
pagelle_chiuse: true,
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(creato), 1, "l'evento con pagelle chiuse si crea");
|
||||
|
||||
const pagellaFuoriTempo = await rest("pagelle_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_CHIUSO,
|
||||
votante_id: "g1",
|
||||
votato_id: "g5",
|
||||
voto: 7,
|
||||
}),
|
||||
});
|
||||
assert.ok(
|
||||
!pagellaFuoriTempo.ok,
|
||||
`pagelle chiuse: voto rifiutato (${pagellaFuoriTempo.status})`,
|
||||
);
|
||||
|
||||
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
|
||||
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
|
||||
// votabili sullo stesso evento.
|
||||
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_CHIUSO,
|
||||
votante_id: "g1",
|
||||
votato_id: "g5",
|
||||
votato_nome: "Cinque",
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(mvpAncoraAperto), 1, "l'MVP non ha un flag di chiusura");
|
||||
});
|
||||
|
||||
// Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché
|
||||
// nell'interfaccia non hanno nessun gate — il turno palloni se lo passa chiunque, e lo
|
||||
// Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine.
|
||||
|
||||
@@ -25,6 +25,7 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
|
||||
presenze: 0,
|
||||
mvp: 0,
|
||||
mediaVoto: 0,
|
||||
votiPagella: 0,
|
||||
palloni: 0,
|
||||
cacche: 0,
|
||||
cacchePartita: 0,
|
||||
@@ -66,6 +67,29 @@ const pagella = badgeDefs.find((b) => b.id === "pagella")!;
|
||||
assert.equal(gradoRaggiunto(pagella, 6.4), null);
|
||||
assert.equal(gradoRaggiunto(pagella, 6.5), "bronzo");
|
||||
|
||||
// Sotto i voti minimi il badge resta bloccato anche con una media altissima: un voto solo
|
||||
// non deve poter sbloccare/sfilare Pagellone.
|
||||
assert.equal(
|
||||
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 1 })).grado,
|
||||
null,
|
||||
"1 voto su 10: non basta, sotto la soglia minima",
|
||||
);
|
||||
assert.equal(
|
||||
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 4 })).grado,
|
||||
null,
|
||||
"4 voti: ancora sotto la soglia minima (5)",
|
||||
);
|
||||
assert.equal(
|
||||
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 5 })).grado,
|
||||
"oro",
|
||||
"5 voti: la soglia minima è raggiunta, la media conta",
|
||||
);
|
||||
assert.equal(
|
||||
statoBadge(pagella, g({ mediaVoto: 6.5, votiPagella: 5 })).grado,
|
||||
"bronzo",
|
||||
"sopra la soglia minima, valgono le normali soglie di grado",
|
||||
);
|
||||
|
||||
// --- badgeGiocatore ----------------------------------------------------------
|
||||
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
|
||||
assert.ok(
|
||||
@@ -116,7 +140,9 @@ assert.equal(vuota.sbloccati.length, 0);
|
||||
assert.equal(vuota.inProgresso.length, badgeDefs.length);
|
||||
assert.equal(vuota.nascosti, badgeSegreti.length);
|
||||
|
||||
const piena = collezioneBadge(g({ mvp: 5, mediaVoto: 8, presenze: 30, infortuni: 3 }));
|
||||
const piena = collezioneBadge(
|
||||
g({ mvp: 5, mediaVoto: 8, votiPagella: 5, presenze: 30, infortuni: 3 }),
|
||||
);
|
||||
assert.equal(
|
||||
piena.ottenuti,
|
||||
piena.sbloccati.length + piena.segreti.length,
|
||||
@@ -133,6 +159,7 @@ assert.equal(
|
||||
g({
|
||||
mvp: 5,
|
||||
mediaVoto: 10,
|
||||
votiPagella: 5,
|
||||
palloni: 10,
|
||||
presenze: 30,
|
||||
serieAllenamenti: 10,
|
||||
|
||||
Reference in New Issue
Block a user