diff --git a/test/helpers/roma.ts b/test/helpers/roma.ts new file mode 100644 index 0000000..9ee1af3 --- /dev/null +++ b/test/helpers/roma.ts @@ -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 = 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; +} diff --git a/test/integration/mvp-badge.test.ts b/test/integration/mvp-badge.test.ts index 65c330e..4a72a97 100644 --- a/test/integration/mvp-badge.test.ts +++ b/test/integration/mvp-badge.test.ts @@ -14,7 +14,7 @@ */ import assert from "node:assert/strict"; 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 { statoLocale } from "../helpers/locale"; import { prova, riepilogo, salta } from "../helpers/prova"; @@ -52,11 +52,11 @@ if (!locale) { } /** Rilegge esattamente come `useVotiMvp()`. */ - async function leggiVoti(): Promise { + async function leggiVoti(): Promise { 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`. */ @@ -68,8 +68,8 @@ if (!locale) { await prova( "il badge MVP passa da bronzo ad argento con vittorie vere lette dal database", async () => { - // Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in - // m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo, + // Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), in m3 ha un solo + // 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. await upsert({ match_id: `${PREFISSO}-m1`, @@ -107,17 +107,15 @@ if (!locale) { votato_id: "vg1", votato_nome: "Uno", }); - await upsert({ - match_id: `${PREFISSO}-m3`, - votante_id: "vb", - votato_id: "vg2", - votato_nome: "Due", - }); const voti = await leggiVoti(); const vinti = mvpVintiPerGiocatore(voti); - assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)"); - assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente"); + assert.equal( + 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)); assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta"); diff --git a/test/integration/permessi-allenatore.test.ts b/test/integration/permessi-allenatore.test.ts index 4f12c69..654b0d1 100644 --- a/test/integration/permessi-allenatore.test.ts +++ b/test/integration/permessi-allenatore.test.ts @@ -8,6 +8,7 @@ * 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`. */ +import { romaTra } from "../helpers/roma"; import assert from "node:assert/strict"; import { statoLocale } from "../helpers/locale"; 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. await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { 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, { method: "POST", body: JSON.stringify({ diff --git a/test/integration/permessi.test.ts b/test/integration/permessi.test.ts index 4a46dbd..a2cf021 100644 --- a/test/integration/permessi.test.ts +++ b/test/integration/permessi.test.ts @@ -16,6 +16,7 @@ */ import assert from "node:assert/strict"; import { statoLocale } from "../helpers/locale"; +import { giornoSenzaOraAperto, romaTra } from "../helpers/roma"; import { prova, riepilogo, salta } from "../helpers/prova"; const locale = statoLocale(); @@ -310,12 +311,47 @@ if (!locale) { }), }); 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, + tipo = "partita", + ): Promise { + 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, { method: "POST", - headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + headers: { Prefer: "return=representation" }, body: JSON.stringify({ - match_id: EVENTO, + match_id: EVENTO_MVP, votante_id: "g1", votato_id: "g5", votato_nome: "Cinque", @@ -323,11 +359,55 @@ if (!locale) { }); 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, { method: "POST", - headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + headers: { Prefer: "return=representation" }, body: JSON.stringify({ - match_id: EVENTO, + match_id: EVENTO_MVP, votante_id: "g5", votato_id: "g1", votato_nome: "Uno", @@ -336,6 +416,265 @@ if (!locale) { 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[]; + 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 { + 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"), "x"].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 () => { const mie = await rest("cacche_partita", tokenGiocatore, { method: "POST", @@ -455,8 +794,8 @@ if (!locale) { id: EVENTO_CON_G1, tipo: "partita", titolo: "Partita con g1 convocato, g2 no", - data: "2026-01-02", - ora: "20:00", + data: romaTra(-3).data, + ora: romaTra(-3).ora, luogo: "Palestra", convocati: ["g1", "g5"], }), @@ -482,9 +821,17 @@ if (!locale) { // 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. + // 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, { method: "POST", - headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + headers: { Prefer: "return=representation" }, body: JSON.stringify({ match_id: EVENTO_CON_G1, votante_id: "g1", @@ -503,8 +850,8 @@ if (!locale) { id: EVENTO_CHIUSO, tipo: "partita", titolo: "Partita con pagelle chiuse", - data: "2026-01-03", - ora: "20:00", + data: romaTra(-3).data, + ora: romaTra(-3).ora, luogo: "Palestra", pagelle_chiuse: true, }), @@ -529,9 +876,17 @@ if (!locale) { // 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. + // 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, { method: "POST", - headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + headers: { Prefer: "return=representation" }, body: JSON.stringify({ match_id: EVENTO_CHIUSO, votante_id: "g1", diff --git a/test/integration/s-tiebreak-badge.test.ts b/test/integration/s-tiebreak-badge.test.ts index af9986d..e004b9c 100644 --- a/test/integration/s-tiebreak-badge.test.ts +++ b/test/integration/s-tiebreak-badge.test.ts @@ -20,7 +20,7 @@ */ import assert from "node:assert/strict"; 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 { giocatori, type Giocatore } from "@/lib/crapp-data"; 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()}`); } - async function leggiMvp(): Promise { + async function leggiMvp(): Promise { 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 { diff --git a/test/unit/mvp-voti.test.ts b/test/unit/mvp-voti.test.ts index 2cadc97..de0093f 100644 --- a/test/unit/mvp-voti.test.ts +++ b/test/unit/mvp-voti.test.ts @@ -1,11 +1,18 @@ /** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { conteggioPartita, + elencoNomi, mioVoto, + mvpPartita, mvpVintiPerGiocatore, + DURATA_VOTO_MVP_ORE, + ORE_ATTESA_MVP, vincitoriMvp, votoMvpAperto, + votoMvpChiuso, + votoMvpInCorso, VOTI_MINIMI_MVP, type VotoMvp, } from "@/lib/mvp-voti"; @@ -44,9 +51,13 @@ assert.deepEqual( ["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(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([]), {}); // --- vincitoriMvp: quorum minimo di voti ------------------------------------- @@ -69,13 +80,38 @@ const triplaPari = [ 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( + vincitoriMvp(triplaPari), + { m6: "Anna, Bea e Zeno" }, + "tre pari in testa: tre MVP, in ordine alfabetico", +); assert.deepEqual( mvpVintiPerGiocatore(triplaPari), - {}, - "stessa parità: nessuna vittoria netta da contare", + { g9: 1, g8: 1, g7: 1 }, + "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 ----------------------------------------------------------------- assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno"); assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto"); @@ -89,8 +125,8 @@ assert.deepEqual( ); assert.deepEqual( mvpVintiPerGiocatore([...partita, ...pari]), - { g2: 1, g5: 1 }, - "la parità non assegna", + { g2: 1, g5: 1, g9: 1, g8: 1 }, + "m3 è pari: i due pari merito prendono un titolo ciascuno", ); assert.deepEqual( 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(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");