Test del voto MVP: regole a database, segretezza, nome dalla rosa, chiusura

Integrazione (permessi, permessi-allenatore, mvp-badge, s-tiebreak-badge), unit (mvp-voti) e helper
delle date di Roma. Con le parità l'MVP vale per tutti i pari merito.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-26 09:57:35 +01:00
1 parent 127d5cb5b8
commit 5fed105850
6 files changed
+509 -36

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
/**
* Data e ora (fuso Europe/Rome) di un istante a `ore` ore da adesso, nel formato di `eventi_app`
* (`data` AAAA-MM-GG, `ora` HH:MM). Serve ai test del voto MVP, che dipende dall'orario di inizio.
*/
export function romaTra(ore: number): { data: string; ora: string } {
const p: Record<string, string> = Object.fromEntries(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Rome",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.formatToParts(new Date(Date.now() + ore * 3_600_000))
.map((x) => [x.type, x.value]),
);
return { data: `${p["year"]}-${p["month"]}-${p["day"]}`, ora: `${p["hour"]}:${p["minute"]}` };
}
/**
* Una data con ora vuota vale mezzanotte. Ritorna il giorno la cui mezzanotte cade tra 2 e 26 ore
* fa (il voto è aperto): oggi se sono passate almeno 2 ore dalla mezzanotte, altrimenti ieri.
*/
export function giornoSenzaOraAperto(): string {
const adesso = romaTra(0);
return Number(adesso.ora.slice(0, 2)) >= 2 ? adesso.data : romaTra(-24).data;
}
+12 -14
View File
@@ -14,7 +14,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges"; import { badgeDefs, statoBadge } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
@@ -52,11 +52,11 @@ if (!locale) {
} }
/** Rilegge esattamente come `useVotiMvp()`. */ /** Rilegge esattamente come `useVotiMvp()`. */
async function leggiVoti(): Promise<VotoMvp[]> { async function leggiVoti(): Promise<VotoMvpPubblico[]> {
const res = await rest( const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
); );
return (await res.json()) as VotoMvp[]; return (await res.json()) as VotoMvpPubblico[];
} }
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */ /** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
@@ -68,8 +68,8 @@ if (!locale) {
await prova( await prova(
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database", "il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
async () => { async () => {
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in // Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), in m3 ha un solo
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo, // voto (sotto quorum: nessun vincitore) e in m4 nessuno: dopo m1+m2 ha 2 vittorie -> bronzo,
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge. // non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
await upsert({ await upsert({
match_id: `${PREFISSO}-m1`, match_id: `${PREFISSO}-m1`,
@@ -107,17 +107,15 @@ if (!locale) {
votato_id: "vg1", votato_id: "vg1",
votato_nome: "Uno", votato_nome: "Uno",
}); });
await upsert({
match_id: `${PREFISSO}-m3`,
votante_id: "vb",
votato_id: "vg2",
votato_nome: "Due",
});
const voti = await leggiVoti(); const voti = await leggiVoti();
const vinti = mvpVintiPerGiocatore(voti); const vinti = mvpVintiPerGiocatore(voti);
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)"); assert.equal(
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente"); vinti["vg1"],
2,
"vg1 vince m1 e m2, m3 ha un solo voto (sotto quorum, non conta)",
);
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai");
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0)); const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta"); assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
+28 -1
View File
@@ -8,6 +8,7 @@
* Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato), * Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato),
* lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`. * lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`.
*/ */
import { romaTra } from "../helpers/roma";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
@@ -259,8 +260,34 @@ if (!locale) {
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque. // Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ convocati: [] }), // M36-M37: una partita recente, perché il voto sia aperto e il rifiuto dipenda dal ruolo.
body: JSON.stringify({ convocati: [], ...romaTra(-3) }),
}); });
// M36: perché il rifiuto dipenda solo dal ruolo, tutti sono presenti (lo slot dell'allenatore
// lo segniamo presente a mano: da sé non potrebbe rispondere) e la partita è passata.
for (const g of [GIOCATORE, SLOT, "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: g, stato: "presente" }),
});
}
// Controllo positivo: due giocatori presenti, il voto passa.
const valido = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: GIOCATORE,
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(
await righeToccate(valido),
1,
`un giocatore vota un giocatore (${valido.status})`,
);
const vota = await rest("mvp_voti", tokenAllenatore, { const vota = await rest("mvp_voti", tokenAllenatore, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
+365 -10
View File
@@ -16,6 +16,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { giornoSenzaOraAperto, romaTra } from "../helpers/roma";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale(); const locale = statoLocale();
@@ -310,12 +311,47 @@ if (!locale) {
}), }),
}); });
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`); assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
});
// --- Voto MVP: definitivo (M35), solo tra presenti e dopo 2 ore dall'inizio (M36) ----------
let contatoreMvp = 0;
/** Crea una partita (o altro tipo) con le presenze indicate; ritorna l'id. */
async function eventoMvp(
quando: { data: string; ora: string },
presenze: Record<string, string>,
tipo = "partita",
): Promise<string> {
const id = `${PREFISSO}-mvp-${++contatoreMvp}`;
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ id, tipo, titolo: "Voto MVP", luogo: "Palestra", ...quando }),
});
assert.equal(await righeToccate(creato), 1, "l'evento di prova si crea");
for (const [giocatore_id, stato] of Object.entries(presenze)) {
const r = await rest("risposte_presenze", SERVIZIO, {
method: "POST",
body: JSON.stringify({ evento_id: id, giocatore_id, stato }),
});
assert.ok(r.ok, `presenza di prova ${giocatore_id}: ${r.status}`);
}
return id;
}
const votaMvp = (token: string, match_id: string, votante_id: string, votato_id: string) =>
rest("mvp_voti", token, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ match_id, votante_id, votato_id, votato_nome: votato_id }),
});
const passata = romaTra(-3);
await prova("il voto MVP è uno solo, definitivo e firmato (M35)", async () => {
const EVENTO_MVP = await eventoMvp(passata, { g1: "presente", g5: "presente" });
const mioMvp = await rest("mvp_voti", tokenGiocatore, { const mioMvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO, match_id: EVENTO_MVP,
votante_id: "g1", votante_id: "g1",
votato_id: "g5", votato_id: "g5",
votato_nome: "Cinque", votato_nome: "Cinque",
@@ -323,11 +359,55 @@ if (!locale) {
}); });
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra"); assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
// Il voto MVP è definitivo (M35): né un secondo voto, né una modifica, né la cancellazione.
const secondo = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: EVENTO_MVP,
votante_id: "g1",
votato_id: "g6",
votato_nome: "Sei",
}),
});
assert.ok(
!secondo.ok,
`un secondo voto MVP sulla stessa partita è rifiutato (${secondo.status})`,
);
const modifica = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
tokenGiocatore,
{
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_id: "g6", votato_nome: "Sei" }),
},
);
assert.equal(await righeToccate(modifica), 0, "il proprio voto MVP non si modifica");
const cancella = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
tokenGiocatore,
{
method: "DELETE",
headers: { Prefer: "return=representation" },
},
);
assert.equal(await righeToccate(cancella), 0, "il proprio voto MVP non si cancella");
const dopo = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1&select=votato_id`,
SERVIZIO,
);
assert.deepEqual(
await dopo.json(),
[{ votato_id: "g5" }],
"il voto è rimasto quello iniziale",
);
const mvp = await rest("mvp_voti", tokenGiocatore, { const mvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO, match_id: EVENTO_MVP,
votante_id: "g5", votante_id: "g5",
votato_id: "g1", votato_id: "g1",
votato_nome: "Uno", votato_nome: "Uno",
@@ -336,6 +416,265 @@ if (!locale) {
assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`); assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`);
}); });
await prova("il voto MVP vale solo tra presenti (M36)", async () => {
const q = passata;
// Controllo positivo: presente e in ritardo possono votare e farsi votare.
const ok = await eventoMvp(q, { g1: "presente", g5: "ritardo" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, ok, "g1", "g5")), 1);
const okRitardo = await eventoMvp(q, { g1: "ritardo", g5: "presente" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, okRitardo, "g1", "g5")), 1);
for (const [stato, nome] of [
["forse", "forse"],
["assente", "assente"],
["infortunato", "infortunato"],
[null, "senza risposta"],
] as const) {
const votatoNo = await eventoMvp(q, {
g1: "presente",
...(stato ? { g5: stato } : {}),
});
const r = await votaMvp(tokenGiocatore, votatoNo, "g1", "g5");
assert.ok(!r.ok, `un votato ${nome} non si può votare (${r.status})`);
const votanteNo = await eventoMvp(q, {
g5: "presente",
...(stato ? { g1: stato } : {}),
});
const r2 = await votaMvp(tokenGiocatore, votanteNo, "g1", "g5");
assert.ok(!r2.ok, `un votante ${nome} non può votare (${r2.status})`);
}
});
await prova(
"il voto MVP apre 2 ore dopo l'inizio e chiude 24 ore dopo (M36, M37)",
async () => {
const presenti = { g1: "presente", g5: "presente" };
const tre = await eventoMvp(romaTra(-3), presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, tre, "g1", "g5")),
1,
"3 ore dopo",
);
const una = await eventoMvp(romaTra(-1), presenti);
const rUna = await votaMvp(tokenGiocatore, una, "g1", "g5");
assert.ok(!rUna.ok, `1 ora dopo l'inizio: ancora chiuso (${rUna.status})`);
const venticinque = await eventoMvp(romaTra(-25), presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, venticinque, "g1", "g5")),
1,
"25 ore dopo l'inizio: ancora aperto (chiude a 26)",
);
const ventisette = await eventoMvp(romaTra(-27), presenti);
const rChiuso = await votaMvp(tokenGiocatore, ventisette, "g1", "g5");
assert.ok(!rChiuso.ok, `27 ore dopo l'inizio: chiuso (${rChiuso.status})`);
const futura = await eventoMvp(romaTra(48), presenti);
const rFutura = await votaMvp(tokenGiocatore, futura, "g1", "g5");
assert.ok(!rFutura.ok, `partita non ancora giocata (${rFutura.status})`);
// Un'ora vuota vale mezzanotte, come nell'app.
const senzaOra = await eventoMvp({ data: giornoSenzaOraAperto(), ora: "" }, presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, senzaOra, "g1", "g5")),
1,
"senza ora vale mezzanotte",
);
},
);
await prova("il voto MVP esiste solo per le partite (M36)", async () => {
const presenti = { g1: "presente", g5: "presente" };
for (const tipo of ["allenamento", "evento"]) {
const e = await eventoMvp(passata, presenti, tipo);
const r = await votaMvp(tokenGiocatore, e, "g1", "g5");
assert.ok(!r.ok, `nessun MVP per ${tipo} (${r.status})`);
}
});
await prova("l'admin può registrare un voto MVP anche fuori da queste regole", async () => {
const futura = await eventoMvp(romaTra(48), { g1: "assente" });
const r = await votaMvp(tokenAdmin, futura, "g1", "g5");
assert.equal(await righeToccate(r), 1, "l'admin corregge/inserisce senza vincoli di M36");
const chiusa = await eventoMvp(romaTra(-48), { g1: "assente" });
const rc = await votaMvp(tokenAdmin, chiusa, "g1", "g5");
assert.equal(await righeToccate(rc), 1, "l'admin può votare anche a votazione chiusa");
});
await prova("il voto MVP è segreto: nessuno legge chi ha votato chi (M37)", async () => {
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, e, "g1", "g5")), 1);
// Un secondo voto di un altro votante (inserito dall'admin) per avere due righe.
assert.equal(await righeToccate(await votaMvp(tokenAdmin, e, "g5", "g1")), 1);
const tabella = await rest(
`mvp_voti?match_id=eq.${e}&select=votante_id,votato_id`,
tokenGiocatore,
);
assert.deepEqual(
await tabella.json(),
[{ votante_id: "g1", votato_id: "g5" }],
"dalla tabella un giocatore legge solo il proprio voto",
);
const vista = await rest(`mvp_voti_anonimi?match_id=eq.${e}&select=*`, tokenGiocatore);
assert.ok(vista.ok, `la vista dei conteggi si legge (${vista.status})`);
const righe = (await vista.json()) as Record<string, string>[];
assert.deepEqual(
righe.map((r) => r["votato_id"]).sort(),
["g1", "g5"],
"ci sono tutti i voti",
);
assert.ok(
righe.every((r) => !("votante_id" in r)),
"ma la vista non dice chi li ha dati",
);
const anonimo = await rest(`mvp_voti_anonimi?match_id=eq.${e}`, ANON);
assert.ok(!anonimo.ok, `senza account non si legge nemmeno la vista (${anonimo.status})`);
const anonimoTabella = await rest(`mvp_voti?match_id=eq.${e}`, ANON);
const corpo = anonimoTabella.ok ? ((await anonimoTabella.json()) as unknown[]) : [];
assert.equal(corpo.length, 0, "né la tabella");
const admin = await rest(`mvp_voti?match_id=eq.${e}&select=votante_id`, tokenAdmin);
assert.equal(
((await admin.json()) as unknown[]).length,
2,
"l'admin vede i voti dalla tabella: gli servono per correggerli",
);
});
/** Nome in rosa di uno slot, come lo compone il trigger (nome + cognome, senza spazi in testa). */
async function nomeInRosa(id: string): Promise<string> {
const r = await rest(`giocatori_squadra?id=eq.${id}&select=nome,cognome`, SERVIZIO);
const [g] = (await r.json()) as { nome: string; cognome: string }[];
return `${g!.nome} ${g!.cognome}`.trim();
}
const votoNome = (token: string, match_id: string, votato_id: string, resto: object) =>
rest("mvp_voti", token, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ match_id, votante_id: "g1", votato_id, ...resto }),
});
const nomeRiga = async (res: Response) =>
((await res.json()) as { votato_nome: string }[])[0]?.votato_nome;
await prova("MVP: il nome del votato lo mette il database, non il client (M37)", async () => {
const g5 = await nomeInRosa("g5");
// Nome inventato: ignorato.
const falso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(
await votoNome(tokenGiocatore, falso, "g5", { votato_nome: "Nome Inventato" }),
),
g5,
);
const visto = await rest(
`mvp_voti_anonimi?match_id=eq.${falso}&select=votato_nome`,
tokenGiocatore,
);
assert.deepEqual(await visto.json(), [{ votato_nome: g5 }], "e così lo vede la squadra");
// Nome omesso (la colonna è NOT NULL): lo riempie il trigger, il voto passa.
const omesso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, omesso, "g5", {})),
g5,
"nome omesso",
);
// Nome vuoto, con spazi, con un altro giocatore: sempre quello della rosa.
for (const [i, strano] of ["", " ", await nomeInRosa("g1"), "<b>x</b>"].entries()) {
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, e, "g5", { votato_nome: strano })),
g5,
`caso ${i}`,
);
}
// Nome già corretto: resta lo stesso.
const giusto = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, giusto, "g5", { votato_nome: g5 })),
g5,
);
});
await prova(
"MVP: l'admin che corregge un voto non può far divergere il nome (M37)",
async () => {
const [g5, g6] = [await nomeInRosa("g5"), await nomeInRosa("g6")];
const e = await eventoMvp(passata, { g1: "presente", g5: "presente", g6: "presente" });
await votoNome(tokenGiocatore, e, "g5", {});
const filtro = `mvp_voti?match_id=eq.${e}&votante_id=eq.g1`;
// Cambia solo il nome: torna quello della rosa.
const soloNome = await rest(filtro, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_nome: "Falso" }),
});
assert.equal(await nomeRiga(soloNome), g5, "nome riscritto da solo");
// Cambia il votato: il nome lo segue.
const altro = await rest(filtro, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_id: "g6", votato_nome: "Falso" }),
});
assert.equal(await nomeRiga(altro), g6, "nome che segue il nuovo votato");
},
);
await prova("MVP: un id fuori rosa conserva il nome ricevuto (M37)", async () => {
// Solo la service role (come i test sui badge) può scrivere id che non sono in rosa.
const e = `${PREFISSO}-mvp-fuori-rosa`;
const r = await rest("mvp_voti", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: e,
votante_id: "xx1",
votato_id: "xx2",
votato_nome: "Ospite",
}),
});
assert.equal(await nomeRiga(r), "Ospite", "niente da sostituire: resta il nome ricevuto");
});
await prova("badge social: il nome del votato lo mette il database (M38)", async () => {
const g5 = await nomeInRosa("g5");
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
const base = { match_id: e, categoria: "sorriso", votante_id: "g1", votato_id: "g5" };
const post = (resto: object) =>
rest("badge_social_voti?on_conflict=match_id,categoria,votante_id", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ ...base, ...resto }),
});
assert.equal(
await nomeRiga(await post({ votato_nome: "Nome Inventato" })),
g5,
"nome inventato",
);
// Stesso voto rinviato con un altro nome (upsert): il nome resta quello della rosa.
assert.equal(
await nomeRiga(await post({ votato_nome: "Un Altro" })),
g5,
"upsert con altro nome",
);
const omesso = await rest("badge_social_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ ...base, categoria: "meme" }),
});
assert.equal(await nomeRiga(omesso), g5, "nome omesso");
});
await prova("le cacche le dichiara il diretto interessato", async () => { await prova("le cacche le dichiara il diretto interessato", async () => {
const mie = await rest("cacche_partita", tokenGiocatore, { const mie = await rest("cacche_partita", tokenGiocatore, {
method: "POST", method: "POST",
@@ -455,8 +794,8 @@ if (!locale) {
id: EVENTO_CON_G1, id: EVENTO_CON_G1,
tipo: "partita", tipo: "partita",
titolo: "Partita con g1 convocato, g2 no", titolo: "Partita con g1 convocato, g2 no",
data: "2026-01-02", data: romaTra(-3).data,
ora: "20:00", ora: romaTra(-3).ora,
luogo: "Palestra", luogo: "Palestra",
convocati: ["g1", "g5"], convocati: ["g1", "g5"],
}), }),
@@ -482,9 +821,17 @@ if (!locale) {
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui // Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo // convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
// (es. un evento inesistente) e sembrare comunque corretto. // (es. un evento inesistente) e sembrare comunque corretto.
// M36: il voto MVP richiede che votante e votato siano presenti.
for (const g of ["g1", "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO_CON_G1, giocatore_id: g, stato: "presente" }),
});
}
const votoValido = await rest("mvp_voti", tokenGiocatore, { const votoValido = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO_CON_G1, match_id: EVENTO_CON_G1,
votante_id: "g1", votante_id: "g1",
@@ -503,8 +850,8 @@ if (!locale) {
id: EVENTO_CHIUSO, id: EVENTO_CHIUSO,
tipo: "partita", tipo: "partita",
titolo: "Partita con pagelle chiuse", titolo: "Partita con pagelle chiuse",
data: "2026-01-03", data: romaTra(-3).data,
ora: "20:00", ora: romaTra(-3).ora,
luogo: "Palestra", luogo: "Palestra",
pagelle_chiuse: true, pagelle_chiuse: true,
}), }),
@@ -529,9 +876,17 @@ if (!locale) {
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di // 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 // "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
// votabili sullo stesso evento. // votabili sullo stesso evento.
// M36: il voto MVP richiede che votante e votato siano presenti.
for (const g of ["g1", "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO_CHIUSO, giocatore_id: g, stato: "presente" }),
});
}
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, { const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO_CHIUSO, match_id: EVENTO_CHIUSO,
votante_id: "g1", votante_id: "g1",
+4 -4
View File
@@ -20,7 +20,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges"; import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { mediePagelle, type VotoPagella } from "@/lib/pagelle"; import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
@@ -67,11 +67,11 @@ if (!locale) {
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
} }
async function leggiMvp(): Promise<VotoMvp[]> { async function leggiMvp(): Promise<VotoMvpPubblico[]> {
const res = await rest( const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
); );
return (await res.json()) as VotoMvp[]; return (await res.json()) as VotoMvpPubblico[];
} }
async function leggiPagelle(): Promise<VotoPagella[]> { async function leggiPagelle(): Promise<VotoPagella[]> {
+71 -7
View File
@@ -1,11 +1,18 @@
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */ /** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { import {
conteggioPartita, conteggioPartita,
elencoNomi,
mioVoto, mioVoto,
mvpPartita,
mvpVintiPerGiocatore, mvpVintiPerGiocatore,
DURATA_VOTO_MVP_ORE,
ORE_ATTESA_MVP,
vincitoriMvp, vincitoriMvp,
votoMvpAperto, votoMvpAperto,
votoMvpChiuso,
votoMvpInCorso,
VOTI_MINIMI_MVP, VOTI_MINIMI_MVP,
type VotoMvp, type VotoMvp,
} from "@/lib/mvp-voti"; } from "@/lib/mvp-voti";
@@ -44,9 +51,13 @@ assert.deepEqual(
["Anna", "Zeno"], ["Anna", "Zeno"],
); );
// --- vincitoriMvp: la parità non assegna nessun MVP -------------------------- // --- vincitoriMvp: la parità in testa assegna l'MVP a tutti i pari merito --------------------------
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" }); assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato"); assert.deepEqual(
vincitoriMvp(pari),
{ m3: "Anna e Zeno" },
"due voti pari in testa: sono MVP entrambi, a pari merito",
);
assert.deepEqual(vincitoriMvp([]), {}); assert.deepEqual(vincitoriMvp([]), {});
// --- vincitoriMvp: quorum minimo di voti ------------------------------------- // --- vincitoriMvp: quorum minimo di voti -------------------------------------
@@ -69,13 +80,38 @@ const triplaPari = [
v("m6", "g2", "g8", "Anna"), v("m6", "g2", "g8", "Anna"),
v("m6", "g3", "g7", "Bea"), v("m6", "g3", "g7", "Bea"),
]; ];
assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie"); assert.deepEqual(
vincitoriMvp(triplaPari),
{ m6: "Anna, Bea e Zeno" },
"tre pari in testa: tre MVP, in ordine alfabetico",
);
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore(triplaPari), mvpVintiPerGiocatore(triplaPari),
{}, { g9: 1, g8: 1, g7: 1 },
"stessa parità: nessuna vittoria netta da contare", "ognuno dei pari merito conta un titolo",
); );
// Primo pari col secondo, terzo staccato: il terzo resta fuori.
const duePariUnoSotto = [
v("m7", "g1", "g9", "Zeno"),
v("m7", "g2", "g9", "Zeno"),
v("m7", "g3", "g8", "Anna"),
v("m7", "g4", "g8", "Anna"),
v("m7", "g5", "g7", "Bea"),
];
assert.deepEqual(
mvpPartita(duePariUnoSotto, "m7").map((c) => c.nome),
["Anna", "Zeno"],
);
assert.deepEqual(mvpPartita([v("m8", "g1", "g2", "Solo")], "m8"), [], "sotto quorum: nessuno");
assert.deepEqual(mvpPartita([], "m8"), []);
// --- elencoNomi ---
assert.equal(elencoNomi([]), "");
assert.equal(elencoNomi(["Anna"]), "Anna");
assert.equal(elencoNomi(["Anna", "Bruno"]), "Anna e Bruno");
assert.equal(elencoNomi(["Anna", "Bruno", "Carla"]), "Anna, Bruno e Carla");
// --- mioVoto ----------------------------------------------------------------- // --- mioVoto -----------------------------------------------------------------
assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno"); assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno");
assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto"); assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto");
@@ -89,8 +125,8 @@ assert.deepEqual(
); );
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore([...partita, ...pari]), mvpVintiPerGiocatore([...partita, ...pari]),
{ g2: 1, g5: 1 }, { g2: 1, g5: 1, g9: 1, g8: 1 },
"la parità non assegna", "m3 è pari: i due pari merito prendono un titolo ciascuno",
); );
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]), mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]),
@@ -114,4 +150,32 @@ assert.equal(votoMvpAperto("2026-09-04", "20:30", alle(0, 1)), true, "partita pa
assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte"); assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte");
assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true); assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true);
// --- votoMvpChiuso: 24 ore dopo l'apertura (2 ore dopo l'inizio) -----------------
assert.equal(DURATA_VOTO_MVP_ORE, 24);
const giorno = (g: number, o: number, m = 0) => new Date(2026, 8, g, o, m);
// partita del 5/9 alle 20:30: apre il 5 alle 22:30, chiude il 6 alle 22:30
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 29)), false, "un minuto prima");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 30)), true, "24 ore in punto");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(20, 12)), true, "partita vecchia");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(5, 12)), false, "prima della partita");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 21)), false, "non ancora aperto");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 23)), true, "aperto");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(7, 8)), false, "chiuso");
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 1, 59)), false, "senza ora vale mezzanotte");
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 2)), true);
// --- l'app e il database (M36, M37) hanno la stessa finestra -----------------------
const m37 = readFileSync(
"supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql",
"utf8",
);
assert.ok(
m37.includes(`interval '${ORE_ATTESA_MVP} hours'`),
"ORE_ATTESA_MVP e l'attesa di voto_mvp_consentito() devono coincidere",
);
assert.ok(
m37.includes(`interval '${DURATA_VOTO_MVP_ORE} hours'`),
"DURATA_VOTO_MVP_ORE e la chiusura di voto_mvp_consentito() devono coincidere",
);
console.log("mvp-voti: ok"); console.log("mvp-voti: ok");