Add a test suite for the domain logic and the server routes.

The project had no automated verification at all, so the "Test" step in
the AGENTS.md workflow rested entirely on clicking through the app.

The suite runs on bun with node:assert and adds no dependency: every file
is a script that exits non-zero when a check fails, and test/run.ts runs
each one in its own process. Unit tests cover the rules that decide what
players see (badges, streaks, ball duty rotation, ratings, MVP ties,
scouting totals, goals, notifications, CSI parsing); integration and
end-to-end tests drive the real dev server. Nothing writes to the
database, so both can be pointed at a live environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 13:07:41 +02:00
co-authored by Claude Opus 5
parent fee3d0b55a
commit af563603f9
22 changed files with 1656 additions and 3 deletions
+62
View File
@@ -0,0 +1,62 @@
/** Check dei badge social: `bun test/unit/badge-social.test.ts`. */
import assert from "node:assert/strict";
import {
badgeSocialVinti,
categorieSocial,
conteggioCategoria,
mioVotoSocial,
vincitoreCategoria,
type VotoSocial,
} from "@/lib/badge-social";
const v = (
match_id: string,
categoria: string,
votante_id: string,
votato_id: string,
votato_nome: string,
): VotoSocial => ({ match_id, categoria, votante_id, votato_id, votato_nome });
const voti: VotoSocial[] = [
v("m1", "affidabile", "g1", "g2", "Bruno"),
v("m1", "affidabile", "g3", "g2", "Bruno"),
v("m1", "affidabile", "g4", "g5", "Anna"),
v("m1", "meme", "g1", "g5", "Anna"),
v("m2", "affidabile", "g1", "g2", "Bruno"),
];
// --- conteggioCategoria: match e categoria isolati ---------------------------
assert.deepEqual(conteggioCategoria(voti, "m1", "affidabile"), [
{ id: "g2", nome: "Bruno", voti: 2 },
{ id: "g5", nome: "Anna", voti: 1 },
]);
assert.deepEqual(conteggioCategoria(voti, "m1", "meme"), [{ id: "g5", nome: "Anna", voti: 1 }]);
assert.deepEqual(conteggioCategoria(voti, "m1", "cuore"), [], "categoria senza voti");
assert.deepEqual(conteggioCategoria(voti, "m9", "affidabile"), []);
// --- vincitoreCategoria: serve un vantaggio netto ----------------------------
assert.equal(vincitoreCategoria(voti, "m1", "affidabile")?.nome, "Bruno");
assert.equal(vincitoreCategoria(voti, "m1", "cuore"), null, "nessun voto, nessun vincitore");
const pari = [v("m3", "meme", "g1", "g2", "Bruno"), v("m3", "meme", "g2", "g5", "Anna")];
assert.equal(vincitoreCategoria(pari, "m3", "meme"), null, "parità: nessun vincitore");
// --- mioVotoSocial -----------------------------------------------------------
assert.equal(mioVotoSocial(voti, "m1", "affidabile", "g1")?.votato_id, "g2");
assert.equal(mioVotoSocial(voti, "m1", "meme", "g3"), null, "non ho votato questa categoria");
// --- badgeSocialVinti: quante volte ho vinto ciascuna categoria --------------
assert.deepEqual(badgeSocialVinti(voti, "g2"), { affidabile: 2 }, "vinta in m1 e m2");
assert.deepEqual(badgeSocialVinti(voti, "g5"), { meme: 1 });
assert.deepEqual(badgeSocialVinti(voti, "g9"), {}, "chi non vince non ha badge");
assert.deepEqual(badgeSocialVinti(pari, "g2"), {}, "una parità non assegna badge");
assert.deepEqual(badgeSocialVinti([], "g2"), {});
// --- invarianti sulle categorie ----------------------------------------------
assert.equal(
new Set(categorieSocial.map((c) => c.id)).size,
categorieSocial.length,
"id categoria unici",
);
for (const c of categorieSocial) assert.ok(c.nome && c.descrizione && c.emoji);
console.log("badge-social: ok");
+166
View File
@@ -0,0 +1,166 @@
/** Check dei badge e dei gradi: `bun src/lib/badges.test.ts`. */
import assert from "node:assert/strict";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import {
badgeDefs,
badgeGiocatore,
badgeSegreti,
badgeSegretiSbloccati,
collezioneBadge,
descrizioneSoglie,
gradiOrdine,
gradoRaggiunto,
mancanoPer,
microcopyBadge,
prossimoTraguardo,
segretiNascosti,
statoBadge,
tuttiBadge,
} from "@/lib/badges";
/** Giocatore azzerato: ogni test dichiara solo i valori che gli servono. */
function g(valori: Partial<Giocatore> = {}): Giocatore {
return {
...giocatori[0]!,
presenze: 0,
mvp: 0,
mediaVoto: 0,
palloni: 0,
cacche: 0,
cacchePartita: 0,
infortuni: 0,
ritardi: 0,
streak: 0,
serieAllenamenti: 0,
seriePartite: 0,
serieConferme: 0,
...valori,
};
}
const mvpDef = badgeDefs.find((b) => b.id === "mvp")!;
// --- gradoRaggiunto: soglie 1 / 3 / 5 ----------------------------------------
assert.equal(gradoRaggiunto(mvpDef, 0), null, "sotto la prima soglia nessun grado");
assert.equal(gradoRaggiunto(mvpDef, 1), "bronzo", "la soglia è inclusiva");
assert.equal(gradoRaggiunto(mvpDef, 2), "bronzo");
assert.equal(gradoRaggiunto(mvpDef, 3), "argento");
assert.equal(gradoRaggiunto(mvpDef, 99), "oro", "oltre l'oro resta oro");
assert.deepEqual(gradiOrdine, ["bronzo", "argento", "oro"]);
// --- statoBadge: progresso verso la soglia successiva ------------------------
const aMeta = statoBadge(mvpDef, g({ mvp: 2 }));
assert.deepEqual(
[aMeta.grado, aMeta.prossimo, aMeta.prossimaSoglia, aMeta.progresso],
["bronzo", "argento", 3, 67],
);
const alMassimo = statoBadge(mvpDef, g({ mvp: 5 }));
assert.deepEqual(
[alMassimo.grado, alMassimo.prossimo, alMassimo.prossimaSoglia],
["oro", null, null],
);
assert.equal(alMassimo.progresso, 100, "raggiunto l'oro il progresso è pieno");
// La media voto usa soglie decimali: non deve arrotondare per eccesso.
const pagella = badgeDefs.find((b) => b.id === "pagella")!;
assert.equal(gradoRaggiunto(pagella, 6.4), null);
assert.equal(gradoRaggiunto(pagella, 6.5), "bronzo");
// --- badgeGiocatore ----------------------------------------------------------
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
assert.ok(
badgeGiocatore(g()).every((b) => b.grado === null),
"un giocatore a zero non ha gradi",
);
// --- badge segreti: invisibili finché non si sbloccano ------------------------
const nessunSegreto = g({ mvp: 2, mediaVoto: 7.9 });
assert.equal(badgeSegretiSbloccati(nessunSegreto).length, 0, "serve media 8, non 7.9");
assert.equal(segretiNascosti(nessunSegreto), badgeSegreti.length);
const tiebreak = badgeSegretiSbloccati(g({ mvp: 2, mediaVoto: 8 }));
assert.deepEqual(
tiebreak.map((b) => b.def.id),
["s-tiebreak"],
"sblocca solo il segreto il cui requisito è soddisfatto",
);
assert.deepEqual(
badgeSegretiSbloccati(g({ infortuni: 3 })).map((b) => b.def.id),
["s-infermeria"],
);
assert.equal(badgeSegretiSbloccati(g({ infortuni: 2 })).length, 0, "2 infortuni non bastano");
assert.deepEqual(
badgeSegretiSbloccati(g({ ritardi: 5 })).map((b) => b.def.id),
["s-ritardi"],
);
assert.deepEqual(
badgeSegretiSbloccati(g({ cacche: 3 })).map((b) => b.def.id),
["s-cacche"],
);
assert.deepEqual(
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 15 })).map((b) => b.def.id),
["s-mai-forfait"],
);
assert.equal(
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 14 })).length,
0,
"servono entrambe le condizioni",
);
// --- collezioneBadge ---------------------------------------------------------
const vuota = collezioneBadge(g());
assert.equal(vuota.ottenuti, 0);
assert.equal(vuota.totali, tuttiBadge.length);
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 }));
assert.equal(
piena.ottenuti,
piena.sbloccati.length + piena.segreti.length,
"gli ottenuti sommano normali e segreti",
);
assert.equal(piena.nascosti, badgeSegreti.length - piena.segreti.length);
assert.ok(piena.ottenuti > 0 && piena.ottenuti < piena.totali);
// --- prossimoTraguardo: il badge in proporzione più vicino -------------------
const vicino = prossimoTraguardo(g({ mvp: 2, presenze: 1 }));
assert.equal(vicino?.def.id, "mvp", "67% batte 20%");
assert.equal(
prossimoTraguardo(
g({
mvp: 5,
mediaVoto: 10,
palloni: 10,
presenze: 30,
serieAllenamenti: 10,
serieConferme: 15,
}),
),
null,
"tutto al massimo: nessun traguardo residuo",
);
// --- microcopy ---------------------------------------------------------------
assert.equal(mancanoPer(statoBadge(mvpDef, g({ mvp: 2 }))), "Ti mancano 1 MVP per il argento");
assert.equal(mancanoPer(alMassimo), "Livello massimo raggiunto");
assert.equal(microcopyBadge(alMassimo), "Hai fatto tutto: badge d'oro in bacheca.");
assert.equal(microcopyBadge(aMeta), "Sei in piena corsa, continua così.");
assert.equal(microcopyBadge(statoBadge(mvpDef, g())), "Ogni partita conta: si parte da qui.");
assert.equal(descrizioneSoglie(mvpDef), "1/3/5 MVP");
// --- invarianti sulle definizioni --------------------------------------------
for (const def of tuttiBadge) {
assert.ok(def.soglie.bronzo <= def.soglie.argento, `${def.id}: soglie crescenti`);
assert.ok(def.soglie.argento <= def.soglie.oro, `${def.id}: soglie crescenti`);
assert.ok(def.nome && def.descrizione && def.unita, `${def.id}: testi presenti`);
}
assert.equal(
new Set(tuttiBadge.map((b) => b.id)).size,
tuttiBadge.length,
"gli id dei badge sono unici",
);
console.log("badges: ok");
+55
View File
@@ -0,0 +1,55 @@
/** Check del sondaggio cacche: `bun test/unit/cacche.test.ts`. */
import assert from "node:assert/strict";
import {
mediaPartita,
mediaStagione,
recordStagione,
statisticheCacche,
type RigaCacche,
} from "@/lib/cacche";
const r = (evento_id: string, giocatore_id: string, quantita: number): RigaCacche => ({
evento_id,
giocatore_id,
quantita,
});
const righe: RigaCacche[] = [
r("e1", "g1", 3),
r("e2", "g1", 4),
r("e1", "g2", 1),
r("e2", "g2", 2),
];
// --- statisticheCacche -------------------------------------------------------
const stat = statisticheCacche(righe);
assert.deepEqual(stat["g1"], { totale: 7, giornate: 2, media: 3.5, record: 4, giornateTop: 2 });
assert.deepEqual(stat["g2"], { totale: 3, giornate: 2, media: 1.5, record: 2, giornateTop: 0 });
assert.deepEqual(statisticheCacche([]), {});
// giornateTop è la soglia del badge segreto: 3 conta, 2 no.
assert.equal(statisticheCacche([r("e1", "g9", 3)])["g9"]?.giornateTop, 1);
assert.equal(statisticheCacche([r("e1", "g9", 2)])["g9"]?.giornateTop, 0);
// Uno zero dichiarato è una giornata censita, non un dato mancante.
const conZero = statisticheCacche([r("e1", "g7", 0)])["g7"]!;
assert.deepEqual([conZero.giornate, conZero.media, conZero.record], [1, 0, 0]);
// --- medie -------------------------------------------------------------------
assert.equal(mediaPartita(righe, "e1"), 2, "(3+1)/2");
assert.equal(mediaPartita(righe, "e2"), 3, "(4+2)/2");
assert.equal(mediaPartita(righe, "inesistente"), 0, "nessun dato: zero, non NaN");
assert.equal(mediaStagione(righe), 2.5);
assert.equal(mediaStagione([]), 0);
assert.equal(mediaStagione([r("e1", "g1", 1), r("e1", "g2", 2), r("e1", "g3", 2)]), 1.7);
// --- recordStagione ----------------------------------------------------------
assert.deepEqual(recordStagione(righe), r("e2", "g1", 4));
assert.equal(recordStagione([]), null);
assert.deepEqual(
recordStagione([r("e1", "g1", 4), r("e2", "g2", 4)]),
r("e1", "g1", 4),
"a parità vince chi l'ha fatto per primo",
);
console.log("cacche: ok");
+85
View File
@@ -0,0 +1,85 @@
/** Check dei dati di base della rosa: `bun test/unit/crapp-data.test.ts`. */
import assert from "node:assert/strict";
import {
adminNomi,
classifica,
formatData,
giocatori,
isAdmin,
statoMeta,
storicoMatch,
} from "@/lib/crapp-data";
// --- rosa --------------------------------------------------------------------
assert.ok(giocatori.length > 0, "la rosa non è vuota");
assert.deepEqual(
giocatori.map((g) => g.id),
giocatori.map((_, i) => `g${i + 1}`),
"gli id sono progressivi g1..gN: le tabelle del database vi si appoggiano",
);
assert.equal(
new Set(giocatori.map((g) => g.numero)).size,
giocatori.length,
"numeri di maglia unici",
);
for (const g of giocatori) {
assert.ok(g.nome.trim().length > 0 && g.ruolo.trim().length > 0, `${g.id}: nome e ruolo`);
assert.match(g.nascita, /^\d{4}-\d{2}-\d{2}$/, `${g.id}: data di nascita valida`);
assert.ok(g.numero > 0, `${g.id}: numero di maglia positivo`);
assert.equal(g.iniziali.length, 2, `${g.id}: due iniziali`);
assert.equal(g.iniziali, g.iniziali.toUpperCase(), `${g.id}: iniziali maiuscole`);
assert.ok(g.mediaVoto >= 0 && g.mediaVoto <= 10, `${g.id}: media voto nel range 1-10`);
assert.ok(g.presenze <= g.totaliEventi, `${g.id}: presenze mai oltre gli eventi totali`);
}
// --- amministratori ----------------------------------------------------------
for (const nome of adminNomi) {
const admin = giocatori.find((g) => g.nome === nome);
assert.ok(admin, `l'amministratore ${nome} è in rosa`);
assert.equal(isAdmin(admin.id), true);
}
const nonAdmin = giocatori.find((g) => !adminNomi.includes(g.nome))!;
assert.equal(isAdmin(nonAdmin.id), false);
assert.equal(isAdmin("g999"), false, "un id inesistente non è amministratore");
assert.equal(isAdmin(""), false);
// --- formatData --------------------------------------------------------------
assert.equal(formatData("2026-09-01"), "mar 01 settembre");
assert.equal(formatData("2026-01-31"), "sab 31 gennaio");
assert.ok(
!formatData("2026-03-29").includes("28"),
"cambio ora legale: la data non slitta al giorno prima",
);
// --- stati presenza ----------------------------------------------------------
assert.deepEqual(Object.keys(statoMeta), [
"presente",
"assente",
"forse",
"ritardo",
"infortunato",
]);
for (const [stato, meta] of Object.entries(statoMeta)) {
assert.ok(meta.label && meta.emoji && meta.className, `${stato}: metadati completi`);
}
// --- dati demo residui -------------------------------------------------------
assert.equal(new Set(storicoMatch.map((m) => m.id)).size, storicoMatch.length, "id partite unici");
for (const m of storicoMatch) {
assert.equal(
m.parziali.length,
m.setNostri + m.setLoro,
`${m.id}: un parziale per ogni set giocato`,
);
assert.ok(m.setNostri === 3 || m.setLoro === 3, `${m.id}: una partita finisce a 3 set vinti`);
}
assert.deepEqual(
classifica.map((r) => r.pos),
classifica.map((_, i) => i + 1),
"la classifica di partenza è numerata in ordine",
);
for (const r of classifica) {
assert.equal(r.vinte + r.perse, r.giocate, `${r.squadra}: vinte + perse = giocate`);
}
console.log("crapp-data: ok");
+113
View File
@@ -0,0 +1,113 @@
/**
* Check del parsing CSI: `bun src/lib/csi-core.test.ts`.
* Con CSI_LIVE=1 verifica anche gli endpoint reali del portale.
*/
import assert from "node:assert/strict";
import {
isNostraSquadra,
parseClassifica,
partiteDaEventi,
partiteGiocate,
urlClassifica,
urlPartite,
} from "@/lib/csi-core";
const htmlClassifica = `
<table class="ranking">
<tr><th>Pos</th><th>Squadra</th></tr>
<tr><td>1</td><td><a href="team_details.php?team_id=1">Altra Squadra Girone A</a></td>
<td><b>40</b></td><td>16</td><td>13</td><td>3</td><td>2</td><td>1</td><td>42</td><td>18</td><td>24</td></tr>
</table>
<table class="ranking">
<tr><th>Pos</th><th>Squadra</th></tr>
<tr><td>1</td><td><a href="team_details.php?team_id=9">Paolo Poggi Volley &amp; C.</a></td>
<td><b>40</b></td><td>16</td><td>13</td><td>3</td><td>2</td><td>1</td><td>43</td><td>17</td><td>26</td></tr>
<tr class="align-middle">
<!-- Posizione --><td>4</td>
<td class="d-flex"><img src="images/teams/logos/CRAP_Volley_3359.jpg" alt="">
<a href="team_details.php?team_id=3359">C.R.A.P. Volley </a></td>
<td><b>30</b></td><td>16</td><td>10</td><td>6</td><td>3</td><td>3</td><td>36</td><td>28</td><td>8</td>
<td>1427</td><td>1329</td><td>98</td><td>0.56</td><td>1.07</td>
<td><svg viewBox="0 0 16 16"><path d="M16 8A8"/></svg></td>
</tr>
</table>`;
const righe = parseClassifica(htmlClassifica);
assert.equal(righe.length, 2, "prende solo il girone che contiene la nostra squadra");
assert.deepEqual(righe[1], {
pos: 4,
squadra: "C.R.A.P. Volley",
punti: 30,
giocate: 16,
vinte: 10,
perse: 6,
setFatti: 36,
setSubiti: 28,
});
assert.equal(righe[0]!.squadra, "Paolo Poggi Volley & C.", "decodifica le entità HTML");
assert.deepEqual(parseClassifica("<p>pagina cambiata</p>"), [], "portale cambiato: nessun crash");
assert.ok(isNostraSquadra("CRAP Volley") && isNostraSquadra("C.R.A.P. Volley "));
assert.ok(!isNostraSquadra("CRAP Volley B"));
const eventi = [
{
id: 1,
start: "2025-11-12T22:00:00",
team1: "C.R.A.P. Volley",
team2: "AMCM",
result: "3 - 1",
partials: "25 - 23</br>23 - 25</br>25 - 18</br>25 - 22</br>",
field: "Sabin Piccolo",
project: "PVM - Campionato Open Misto Eccellenza",
},
{
id: 2,
start: "2025-11-19T22:00:00",
team1: "Gnu Tim SdC",
team2: "C.R.A.P. Volley",
result: "3 - 2",
partials: "25 - 21</br>18 - 25</br>",
field: "BVI",
project: "PVM - Campionato Open Misto Eccellenza",
},
{ id: 3, start: "2026-09-10T21:00:00", team1: "C.R.A.P. Volley", team2: "Esselle", result: "" },
{ id: 4, start: "2025-10-01T21:00:00", team1: "Esselle", team2: "AMCM", result: "3 - 0" },
];
const partite = partiteDaEventi(eventi);
assert.equal(partite.length, 3, "scarta le gare di altre squadre");
assert.equal(partite[0]!.data, "2026-09-10", "ordinate dalla più recente");
assert.equal(partite[0]!.setNostri, null, "gara futura senza risultato");
const casa = partite.find((p) => p.id === "1")!;
assert.deepEqual([casa.casa, casa.avversario, casa.setNostri, casa.setLoro], [true, "AMCM", 3, 1]);
assert.deepEqual(casa.parziali[1], [23, 25]);
assert.equal(casa.ora, "22:00");
const trasferta = partite.find((p) => p.id === "2")!;
assert.deepEqual(
[trasferta.casa, trasferta.avversario, trasferta.setNostri, trasferta.setLoro],
[false, "Gnu Tim SdC", 2, 3],
"in trasferta il punteggio va ribaltato",
);
assert.deepEqual(trasferta.parziali[0], [21, 25], "anche i parziali");
assert.equal(partiteGiocate(partite).length, 2);
assert.deepEqual(partiteDaEventi("non è un array"), [], "risposta inattesa: nessun crash");
if (process.env["CSI_LIVE"]) {
const [html, json] = await Promise.all([
fetch(urlClassifica()).then((r) => r.text()),
fetch(urlPartite()).then((r) => r.json()),
]);
const live = parseClassifica(html);
const nostra = live.find((r) => isNostraSquadra(r.squadra));
assert.ok(nostra, "la nostra squadra è in classifica");
assert.ok(live.length >= 8 && nostra.giocate > 0);
const livePartite = partiteDaEventi(json);
assert.ok(livePartite.length > 0 && partiteGiocate(livePartite).length > 0);
console.log(`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°`);
}
console.log("csi-core: ok");
+64
View File
@@ -0,0 +1,64 @@
/** Check della conversione eventi: `bun test/unit/eventi.test.ts`. */
import assert from "node:assert/strict";
import { categoriaEvento, daCategoria, daRiga, type RigaEvento } from "@/lib/eventi";
const riga: RigaEvento = {
id: "e1",
tipo: "partita",
titolo: "CRAP - Avversari",
luogo: "PalaCRAP",
data: "2026-09-01",
ora: "21:00",
note: "portare la seconda maglia",
convocati: ["g1", "g2"],
campionato: true,
casa: true,
pagelle_chiuse: false,
};
// --- daRiga: i NULL del database diventano valori sicuri ---------------------
assert.deepEqual(daRiga(riga), {
id: "e1",
tipo: "partita",
titolo: "CRAP - Avversari",
luogo: "PalaCRAP",
data: "2026-09-01",
ora: "21:00",
note: "portare la seconda maglia",
convocati: ["g1", "g2"],
campionato: true,
casa: true,
pagelleChiuse: false,
});
const vuota = daRiga({
...riga,
note: null,
convocati: null,
casa: null,
campionato: false,
pagelle_chiuse: false,
});
assert.equal(vuota.note, "", "note NULL → stringa vuota");
assert.deepEqual(vuota.convocati, [], "convocati NULL → tutta la rosa (array vuoto)");
assert.equal(vuota.casa, true, "casa NULL → si gioca in casa");
assert.equal(vuota.pagelleChiuse, false);
// --- categoriaEvento: l'amichevole è una partita fuori campionato ------------
assert.equal(categoriaEvento({ tipo: "partita", campionato: true }), "partita");
assert.equal(categoriaEvento({ tipo: "partita", campionato: false }), "amichevole");
assert.equal(categoriaEvento({ tipo: "allenamento", campionato: false }), "allenamento");
assert.equal(categoriaEvento({ tipo: "evento", campionato: false }), "evento");
assert.equal(categoriaEvento({ tipo: "compleanno", campionato: false }), "evento");
// --- daCategoria: inverso di categoriaEvento ---------------------------------
assert.deepEqual(daCategoria("partita"), { tipo: "partita", campionato: true });
assert.deepEqual(daCategoria("amichevole"), { tipo: "partita", campionato: false });
assert.deepEqual(daCategoria("allenamento"), { tipo: "allenamento", campionato: false });
assert.deepEqual(daCategoria("evento"), { tipo: "evento", campionato: false });
for (const c of ["partita", "amichevole", "allenamento", "evento"] as const) {
assert.equal(categoriaEvento(daCategoria(c)), c, `andata e ritorno stabile per ${c}`);
}
console.log("eventi: ok");
+33
View File
@@ -0,0 +1,33 @@
/** Check di infortuni e ritardi: `bun test/unit/infortuni.test.ts`. */
import assert from "node:assert/strict";
import { conInfortuni, contaInfortuni, contaRitardi } from "@/lib/infortuni";
import { giocatori } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const presenze: MappaPresenze = {
e1: { g1: "infortunato", g2: "presente", g3: "ritardo" },
e2: { g1: "infortunato", g2: "ritardo", g3: "assente" },
e3: { g1: "presente", g2: "forse" },
};
// --- conteggi ----------------------------------------------------------------
assert.deepEqual(contaInfortuni(presenze), { g1: 2 }, "ogni evento vale una volta sola");
assert.deepEqual(contaRitardi(presenze), { g3: 1, g2: 1 });
assert.deepEqual(contaInfortuni({}), {});
assert.deepEqual(contaRitardi({ e1: {} }), {}, "evento senza risposte: nessun conteggio");
// --- conInfortuni: arricchisce il giocatore senza perdere gli altri campi ----
const base = giocatori.find((g) => g.id === "g1")!;
const arricchito = conInfortuni(base, contaInfortuni(presenze), contaRitardi(presenze));
assert.equal(arricchito.infortuni, 2);
assert.equal(arricchito.ritardi, 0, "g1 non ha ritardi");
assert.equal(arricchito.nome, base.nome, "il resto del giocatore resta intatto");
assert.equal(base.infortuni, 0, "l'originale non viene mutato");
const senzaDati = conInfortuni(base, {});
assert.deepEqual([senzaDati.infortuni, senzaDati.ritardi], [0, 0], "assenza di dati = zero");
const g3 = giocatori.find((g) => g.id === "g3")!;
assert.equal(conInfortuni(g3, contaInfortuni(presenze), contaRitardi(presenze)).ritardi, 1);
console.log("infortuni: ok");
+53
View File
@@ -0,0 +1,53 @@
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
import assert from "node:assert/strict";
import { conteggioPartita, mioVoto, vincitoriMvp, type VotoMvp } from "@/lib/mvp-voti";
const v = (
match_id: string,
votante_id: string,
votato_id: string,
votato_nome: string,
): VotoMvp => ({
match_id,
votante_id,
votato_id,
votato_nome,
});
// --- conteggioPartita --------------------------------------------------------
const partita = [
v("m1", "g1", "g2", "Bruno"),
v("m1", "g3", "g2", "Bruno"),
v("m1", "g4", "g5", "Anna"),
v("m2", "g1", "g5", "Anna"),
];
assert.deepEqual(conteggioPartita(partita, "m1"), [
{ id: "g2", nome: "Bruno", voti: 2 },
{ id: "g5", nome: "Anna", voti: 1 },
]);
assert.deepEqual(conteggioPartita(partita, "inesistente"), []);
// A parità di voti l'ordine è alfabetico, così la UI è stabile.
const pari = [v("m3", "g1", "g9", "Zeno"), v("m3", "g2", "g8", "Anna")];
assert.deepEqual(
conteggioPartita(pari, "m3").map((c) => c.nome),
["Anna", "Zeno"],
);
// --- vincitoriMvp: la parità non assegna nessun MVP --------------------------
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato");
assert.deepEqual(vincitoriMvp([]), {});
assert.deepEqual(
vincitoriMvp([v("m4", "g1", "g2", "Solo")]),
{ m4: "Solo" },
"un solo votante basta se non c'è concorrenza",
);
// --- mioVoto -----------------------------------------------------------------
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, "m9", "g1"), null);
console.log("mvp-voti: ok");
+106
View File
@@ -0,0 +1,106 @@
/** Check delle notifiche smart: `bun test/unit/notifiche-smart.test.ts`. */
import assert from "node:assert/strict";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import type { VotoSocial } from "@/lib/badge-social";
import type { ObiettivoSquadra } from "@/lib/obiettivi";
import { calcolaNotifiche } from "@/lib/notifiche-smart";
function g(valori: Partial<Giocatore> = {}): Giocatore {
return {
...giocatori[0]!,
presenze: 0,
mvp: 0,
mediaVoto: 0,
palloni: 0,
cacche: 0,
cacchePartita: 0,
infortuni: 0,
ritardi: 0,
streak: 0,
serieAllenamenti: 0,
seriePartite: 0,
serieConferme: 0,
...valori,
};
}
const ids = (g: Giocatore, voti: VotoSocial[] = [], ob: ObiettivoSquadra[] = []) =>
calcolaNotifiche(g, voti, ob).map((n) => n.id);
// --- niente rumore: a zero solo l'invito al primo traguardo ------------------
assert.deepEqual(ids(g()), ["quasi:mvp:1"], "nessun badge sbloccato, nessuna serie");
// --- badge sbloccati ---------------------------------------------------------
const conMvp = ids(g({ mvp: 1 }));
assert.ok(conMvp.includes("badge:mvp:bronzo"), "il badge appena preso viene annunciato");
assert.ok(
ids(g({ mvp: 3 })).includes("badge:mvp:argento"),
"annuncia il grado corrente, non quelli precedenti",
);
assert.ok(!ids(g({ mvp: 3 })).includes("badge:mvp:bronzo"));
// --- badge segreti -----------------------------------------------------------
assert.ok(ids(g({ infortuni: 3 })).includes("segreto:s-infermeria"));
assert.ok(!ids(g({ infortuni: 2 })).includes("segreto:s-infermeria"));
const segreta = calcolaNotifiche(g({ ritardi: 5 })).find((n) => n.id === "segreto:s-ritardi")!;
assert.equal(segreta.tono, "segreto");
assert.equal(segreta.emoji, "⏰");
assert.equal(segreta.testo, "Forse è il momento di puntare la sveglia 10 minuti prima! 😄");
// --- "sei a un passo": solo entro due unità dalla soglia ---------------------
assert.ok(ids(g({ presenze: 4 })).some((id) => id.startsWith("quasi:presenze")));
assert.ok(
!ids(g({ mvp: 5, presenze: 2 })).some((id) => id.startsWith("quasi:presenze")),
"a 3 di distanza non si notifica",
);
// --- serie: solo sul traguardo esatto ----------------------------------------
assert.ok(ids(g({ serieAllenamenti: 3 })).includes("serie:allenamenti:3"));
assert.ok(!ids(g({ serieAllenamenti: 4 })).includes("serie:allenamenti:4"), "4 non è un traguardo");
assert.ok(ids(g({ serieConferme: 8 })).includes("serie:conferme:8"));
// --- obiettivi di squadra: uno solo, tra il 90% e il 99% --------------------
const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
id,
titolo: `Obiettivo ${id}`,
descrizione: "",
valore,
target: 100,
unita: "%",
emoji: "🎯",
impatto: "",
});
assert.deepEqual(
ids(g(), [], [obiettivo("o1", 95), obiettivo("o2", 92)]).filter((i) => i.startsWith("obiettivo")),
["obiettivo:o1:90"],
"un solo obiettivo per non fare rumore",
);
assert.equal(
ids(g(), [], [obiettivo("o1", 100)]).filter((i) => i.startsWith("obiettivo")).length,
0,
"obiettivo già centrato: niente notifica",
);
assert.equal(
ids(g(), [], [obiettivo("o1", 50)]).filter((i) => i.startsWith("obiettivo")).length,
0,
);
// --- voti social -------------------------------------------------------------
const io = g();
const votiSocial: VotoSocial[] = [
{ match_id: "m1", categoria: "meme", votante_id: "g2", votato_id: io.id, votato_nome: io.nome },
{ match_id: "m1", categoria: "meme", votante_id: "g3", votato_id: io.id, votato_nome: io.nome },
];
assert.ok(ids(io, votiSocial).includes("social:meme:1"));
assert.equal(
calcolaNotifiche(io, votiSocial).find((n) => n.id === "social:meme:1")?.titolo,
"Meme della partita x1",
);
assert.ok(!ids(g({ ...io, id: "g99" }), votiSocial).some((i) => i.startsWith("social:")));
// --- forma delle notifiche ---------------------------------------------------
const tutte = calcolaNotifiche(g({ mvp: 5, infortuni: 3, serieAllenamenti: 3 }), votiSocial);
assert.equal(new Set(tutte.map((n) => n.id)).size, tutte.length, "id univoci: niente doppioni");
for (const n of tutte) assert.ok(n.titolo && n.testo && n.emoji && n.tono, `${n.id} completa`);
console.log("notifiche-smart: ok");
+147
View File
@@ -0,0 +1,147 @@
/** Check degli obiettivi di squadra: `bun test/unit/obiettivi.test.ts`. */
import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import type { VotoPagella } from "@/lib/pagelle";
import type { MappaPresenze } from "@/lib/presenze";
import {
contestoVuoto,
microcopyObiettivo,
obiettiviOrdinati,
obiettiviSquadra,
progressoObiettivo,
type ContestoObiettivi,
type ObiettivoSquadra,
} from "@/lib/obiettivi";
const evento = (id: string, data: string, tipo: Evento["tipo"]): Evento => ({
id,
tipo,
titolo: id,
luogo: "",
data,
ora: "21:00",
note: "",
convocati: [],
campionato: false,
casa: true,
pagelleChiuse: false,
});
const trova = (lista: ObiettivoSquadra[], id: string) => lista.find((o) => o.id === id)!;
// --- contesto vuoto: nessuna divisione per zero ------------------------------
const vuoti = obiettiviSquadra(giocatori, contestoVuoto);
assert.equal(trova(vuoti, "o1").valore, 0, "nessun evento nel mese: 0%, non NaN");
assert.equal(trova(vuoti, "o2").valore, 0);
assert.equal(trova(vuoti, "o12").valore, 0, "nessuna pagella: media 0");
assert.ok(
vuoti.every((o) => Number.isFinite(o.valore)),
"nessun valore NaN o infinito",
);
// --- presenze del mese (agosto 2026) e risposte ------------------------------
const tuttiPresenti: MappaPresenze = {
a1: Object.fromEntries(giocatori.map((g) => [g.id, "presente" as const])),
};
const ctx: ContestoObiettivi = {
eventi: [evento("a1", "2026-08-10", "allenamento")],
presenze: tuttiPresenti,
pagelle: [],
};
assert.equal(trova(obiettiviSquadra(giocatori, ctx), "o1").valore, 100, "rosa al completo = 100%");
const metaRosa: MappaPresenze = {
a1: Object.fromEntries(
giocatori.map((g, i) => [g.id, i % 2 === 0 ? ("presente" as const) : ("assente" as const)]),
),
};
const percentuale = trova(obiettiviSquadra(giocatori, { ...ctx, presenze: metaRosa }), "o1").valore;
assert.ok(percentuale > 40 && percentuale < 60, `metà rosa presente ≈ 50%, era ${percentuale}`);
// Il ritardo conta come presenza, il "forse" no.
const conRitardo: MappaPresenze = { a1: { g1: "ritardo", g2: "forse" } };
assert.equal(
trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }), "o1").valore,
Math.round((1 / giocatori.length) * 100),
"solo il ritardo conta come presente",
);
assert.equal(
trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }), "o2").valore,
Math.round((2 / giocatori.length) * 100),
"per le risposte anche il forse conta",
);
// Un evento fuori mese non sposta l'obiettivo mensile.
const fuoriMese: ContestoObiettivi = {
eventi: [evento("s1", "2026-09-10", "allenamento")],
presenze: { s1: { g1: "presente" } },
pagelle: [],
};
assert.equal(trova(obiettiviSquadra(giocatori, fuoriMese), "o1").valore, 0);
// I compleanni non richiedono risposta.
const soloCompleanni: ContestoObiettivi = {
eventi: [evento("c1", "2026-08-03", "compleanno")],
presenze: {},
pagelle: [],
};
assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni), "o2").valore, 0);
// --- somme sulla rosa --------------------------------------------------------
const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0);
assert.equal(trova(vuoti, "o7").valore, sommaPresenze);
assert.equal(
trova(obiettiviSquadra([], contestoVuoto), "o7").valore,
0,
"rosa vuota: nessuna presenza",
);
const pagelle: VotoPagella[] = [
{ match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },
{ match_id: "m1", votante_id: "g2", votato_id: "g1", voto: 8 },
];
const conPagelle = obiettiviSquadra(giocatori, { ...contestoVuoto, pagelle });
assert.equal(trova(conPagelle, "o12").valore, 7.5);
assert.equal(trova(conPagelle, "o13").valore, 2, "conta i voti compilati");
// --- progressoObiettivo ------------------------------------------------------
const o = (valore: number, target: number): ObiettivoSquadra => ({
id: "x",
titolo: "t",
descrizione: "d",
valore,
target,
unita: "%",
emoji: "🎯",
impatto: "i",
});
assert.equal(progressoObiettivo(o(0, 10)), 0);
assert.equal(progressoObiettivo(o(5, 10)), 50);
assert.equal(progressoObiettivo(o(20, 10)), 100, "il progresso non supera il 100%");
// --- obiettiviOrdinati: i completati vanno in fondo --------------------------
const ordinati = obiettiviOrdinati(giocatori, contestoVuoto);
assert.equal(ordinati.length, vuoti.length, "nessun obiettivo perso nell'ordinamento");
const percentuali = ordinati.map(progressoObiettivo);
const completati = percentuali.filter((p) => p >= 100);
assert.deepEqual(
percentuali.slice(percentuali.length - completati.length),
completati,
"i completati stanno tutti in coda",
);
const inCorso = percentuali.slice(0, percentuali.length - completati.length);
assert.deepEqual(
inCorso,
[...inCorso].sort((a, b) => b - a),
"gli altri dal più avanzato",
);
// --- microcopy ---------------------------------------------------------------
assert.equal(microcopyObiettivo(o(10, 10)), "Obiettivo centrato: grande squadra!");
assert.equal(microcopyObiettivo(o(95, 100)), "Ci siamo quasi: mancano 5 %.");
assert.equal(microcopyObiettivo(o(60, 100)), "Oltre metà strada: ancora 40 %.");
assert.equal(microcopyObiettivo(o(10, 100)), "Si parte: 90 % al traguardo.");
assert.equal(microcopyObiettivo(o(0, 100)), "Tocca a noi far partire questo obiettivo.");
console.log("obiettivi: ok");
+64
View File
@@ -0,0 +1,64 @@
/** Check delle pagelle: `bun test/unit/pagelle.test.ts`. */
import assert from "node:assert/strict";
import {
mediaSquadra,
mediePagelle,
mieiVoti,
pagellePartita,
type VotoPagella,
} from "@/lib/pagelle";
const voto = (
match_id: string,
votante_id: string,
votato_id: string,
voto: number,
): VotoPagella => ({
match_id,
votante_id,
votato_id,
voto,
});
const voti: VotoPagella[] = [
voto("m1", "g1", "g2", 8),
voto("m1", "g3", "g2", 7),
voto("m1", "g2", "g1", 6),
voto("m2", "g1", "g2", 6),
];
// --- mediePagelle ------------------------------------------------------------
const medie = mediePagelle(voti);
assert.deepEqual(medie["g2"], { media: 7, voti: 3 }, "media su tutte le partite");
assert.deepEqual(medie["g1"], { media: 6, voti: 1 });
assert.equal(medie["g99"], undefined, "chi non ha voti non compare");
assert.deepEqual(mediePagelle([]), {}, "nessun voto: nessuna media");
// Arrotondamento a un decimale, senza errori di virgola mobile.
assert.equal(
mediePagelle([voto("m1", "g1", "gx", 7), voto("m1", "g2", "gx", 8)])["gx"]?.media,
7.5,
);
assert.equal(
mediePagelle([voto("m1", "g1", "gy", 7), voto("m1", "g2", "gy", 8), voto("m1", "g3", "gy", 8)])[
"gy"
]?.media,
7.7,
"7.666… diventa 7.7",
);
// --- pagellePartita: isola la singola partita --------------------------------
assert.deepEqual(pagellePartita(voti, "m1")["g2"], { media: 7.5, voti: 2 });
assert.deepEqual(pagellePartita(voti, "m2")["g2"], { media: 6, voti: 1 });
assert.deepEqual(pagellePartita(voti, "inesistente"), {});
// --- mieiVoti: cosa ho già votato in questa partita --------------------------
assert.deepEqual(mieiVoti(voti, "m1", "g1"), { g2: 8 });
assert.deepEqual(mieiVoti(voti, "m1", "g2"), { g1: 6 });
assert.deepEqual(mieiVoti(voti, "m1", "g99"), {}, "chi non ha votato non ha voti");
// --- mediaSquadra ------------------------------------------------------------
assert.equal(mediaSquadra(voti), 6.8, "(8+7+6+6)/4 = 6.75 → 6.8");
assert.equal(mediaSquadra([]), 0, "nessun voto: media zero, non NaN");
console.log("pagelle: ok");
+98
View File
@@ -0,0 +1,98 @@
/** Check dei turni palloni: `bun src/lib/palloni-core.test.ts`. */
import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import {
completaTurni,
conteggioTurni,
eventiDelGiorno,
eventiPalloni,
eventoPrecedente,
eventoSuccessivo,
oggiISO,
} from "@/lib/palloni-core";
const evento = (id: string, data: string, tipo: Evento["tipo"] = "allenamento"): Evento => ({
id,
tipo,
titolo: `Evento ${id}`,
luogo: "PalaCRAP",
data,
ora: "21:00",
note: "",
convocati: [],
campionato: false,
casa: true,
pagelleChiuse: false,
});
const eventi: Evento[] = [
evento("e3", "2026-09-10"),
evento("e1", "2026-09-01"),
evento("compleanno", "2026-09-05", "compleanno"),
evento("e2", "2026-09-05", "partita"),
];
// --- eventiPalloni: ordina per data e scarta i compleanni ---------------------
const ordinati = eventiPalloni(eventi);
assert.deepEqual(
ordinati.map((e) => e.id),
["e1", "e2", "e3"],
"compleanni esclusi e ordine cronologico",
);
assert.equal(eventi[0]!.id, "e3", "l'array in ingresso non viene modificato");
// --- eventoPrecedente / eventoSuccessivo -------------------------------------
assert.equal(eventoPrecedente(eventi, "e2")?.id, "e1");
assert.equal(eventoPrecedente(eventi, "e1"), undefined, "il primo non ha precedente");
assert.equal(eventoSuccessivo(eventi, "e2")?.id, "e3");
assert.equal(eventoSuccessivo(eventi, "e3"), undefined, "l'ultimo non ha successivo");
assert.equal(eventoPrecedente(eventi, "inesistente"), undefined);
// --- eventiDelGiorno ---------------------------------------------------------
assert.deepEqual(
eventiDelGiorno(eventi, "2026-09-05").map((e) => e.id),
["e2"],
"il compleanno dello stesso giorno non richiede palloni",
);
// --- completaTurni: assegna i mancanti, rispetta quelli già decisi -----------
const turni = completaTurni({ e2: "g5" }, eventi);
assert.equal(turni["e2"], "g5", "il turno già assegnato non viene toccato");
assert.equal(Object.keys(turni).length, 3, "tutti gli eventi hanno un incaricato");
assert.ok(
giocatori.some((g) => g.id === turni["e1"]),
"assegna a un giocatore reale",
);
assert.notEqual(turni["e1"], turni["e3"], "non tocca due volte di fila alla stessa persona");
const molti = Array.from({ length: giocatori.length + 2 }, (_, i) =>
evento(`x${i}`, `2026-10-${String(i + 1).padStart(2, "0")}`),
);
const rotazione = conteggioTurni(completaTurni({}, molti));
const carichi = Object.values(rotazione);
assert.equal(
Math.max(...carichi) - Math.min(...carichi),
1,
"su un giro completo il carico resta bilanciato",
);
assert.equal(
Object.keys(rotazione).length,
giocatori.length,
"nessuno viene saltato prima che tutti abbiano fatto un turno",
);
// Un turno salvato per un giocatore non più in rosa non deve rompere il conteggio.
const conFantasma = completaTurni({ e1: "gXX" }, eventi);
assert.equal(conFantasma["e1"], "gXX", "il turno storico resta com'è");
assert.equal(Object.keys(conFantasma).length, 3);
// --- conteggioTurni ----------------------------------------------------------
assert.deepEqual(conteggioTurni({ a: "g1", b: "g1", c: "g2" }), { g1: 2, g2: 1 });
assert.deepEqual(conteggioTurni({}), {});
// --- oggiISO -----------------------------------------------------------------
assert.match(oggiISO(), /^\d{4}-\d{2}-\d{2}$/);
assert.equal(oggiISO(), new Date().toLocaleDateString("sv-SE"), "data locale, non UTC");
console.log("palloni-core: ok");
+81
View File
@@ -0,0 +1,81 @@
/** Check dell'export CSV dello scout: `bun test/unit/scout-export.test.ts`. */
import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data";
import { csvScoutMatch } from "@/lib/scout-export";
import type { Azione, ScoutMatch } from "@/lib/scout-store";
const g1 = giocatori.find((g) => g.id === "g1")!;
const g2 = giocatori.find((g) => g.id === "g2")!;
const a = (tipo: Azione["tipo"], giocatoreId?: string, set = 1): Azione => ({
id: `${tipo}-${set}-${giocatoreId ?? "team"}`,
tipo,
...(giocatoreId ? { giocatoreId } : {}),
set,
ts: 0,
});
const match: ScoutMatch = {
id: "m1",
data: "2026-09-01",
avversario: "Volley; Bologna",
casa: true,
setNostri: 3,
setLoro: 1,
parziali: [
[25, 19],
[23, 25],
[25, 21],
[25, 18],
],
mvp: g1.nome,
azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")],
};
const csv = csvScoutMatch(match);
const righe = csv.split("\n");
// --- intestazione ------------------------------------------------------------
assert.equal(
righe[0],
'Partita;CRAP Volley;vs;"Volley; Bologna"',
"il ; nel nome va tra virgolette",
);
assert.equal(righe[1], "Data;2026-09-01;Set;3-1");
assert.ok(csv.includes("Set;Parziale nostro;Parziale loro"));
assert.ok(csv.includes("1;25;19"), "primo parziale");
assert.ok(csv.includes("4;25;18"), "ultimo parziale");
// --- totali per giocatore ----------------------------------------------------
assert.ok(csv.includes("Numero;Giocatore;Ruolo;Punti;Ace;Muri;Errori"));
assert.ok(
csv.includes(`${g1.numero};${g1.nome};${g1.ruolo};2;1;0;0`),
"attacco + ace = 2 punti di cui 1 ace",
);
assert.ok(csv.includes(`${g2.numero};${g2.nome};${g2.ruolo};0;0;0;1`));
assert.ok(
!righe.some((r) => r.startsWith(`${giocatori[2]!.numero};${giocatori[2]!.nome};`)),
"chi non ha azioni non compare tra i totali",
);
// --- dettaglio azioni --------------------------------------------------------
assert.ok(csv.includes("Set;Giocatore;Azione"));
assert.ok(csv.includes(`1;${g1.nome};Attacco vincente`) || csv.includes(`1;${g1.nome};`));
assert.ok(
righe.some((r) => r.startsWith("1;") && r.includes("—")),
"l'azione senza giocatore usa il trattino",
);
// --- trasferta e match vuoto -------------------------------------------------
const fuori = csvScoutMatch({ ...match, casa: false, avversario: "Ospiti" });
assert.equal(fuori.split("\n")[0], "Partita;Ospiti;vs;CRAP Volley", "in trasferta si invertono");
const vuoto = csvScoutMatch({ ...match, azioni: [], parziali: [] });
assert.ok(vuoto.includes("Data;2026-09-01"), "un match senza azioni resta esportabile");
assert.ok(!vuoto.includes(`;${g1.nome};`), "nessun totale senza azioni");
// Le virgolette nel testo vanno raddoppiate (regola CSV).
const conVirgolette = csvScoutMatch({ ...match, avversario: 'Team "X"' });
assert.ok(conVirgolette.includes('"Team ""X"""'));
console.log("scout-export: ok");
+120
View File
@@ -0,0 +1,120 @@
/** Check dello scout: `bun test/unit/scout-store.test.ts`. */
import assert from "node:assert/strict";
import { classifica, giocatori } from "@/lib/crapp-data";
import {
azioniMeta,
classificaConScout,
giocatoriConScout,
totaliPerGiocatore,
totaliSquadra,
type Azione,
type ScoutMatch,
} from "@/lib/scout-store";
const a = (tipo: Azione["tipo"], giocatoreId?: string, set = 1): Azione => ({
id: `${tipo}-${giocatoreId ?? "team"}-${set}-${Math.random()}`,
tipo,
...(giocatoreId ? { giocatoreId } : {}),
set,
ts: 0,
});
const match = (
id: string,
setNostri: number,
setLoro: number,
azioni: Azione[] = [],
): ScoutMatch => ({
id,
data: "2026-09-01",
avversario: "Avversari",
casa: true,
setNostri,
setLoro,
parziali: [],
mvp: "",
azioni,
});
// --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------
const azioni = [
a("attacco", "g1"),
a("ace", "g1"),
a("muro", "g1"),
a("errore", "g1"),
a("attacco", "g2"),
a("punto_avv"),
a("errore_avv"),
];
const totali = totaliPerGiocatore(azioni);
assert.deepEqual(totali.get("g1"), { punti: 3, ace: 1, muri: 1, errori: 1 });
assert.deepEqual(totali.get("g2"), { punti: 1, ace: 0, muri: 0, errori: 0 });
assert.equal(totali.size, 2, "le azioni senza giocatore non creano righe");
assert.equal(totaliPerGiocatore([]).size, 0);
// --- totaliSquadra -----------------------------------------------------------
assert.deepEqual(totaliSquadra([match("m1", 3, 0, azioni), match("m2", 3, 1, azioni)]), {
punti: 8,
ace: 2,
muri: 2,
errori: 2,
});
assert.deepEqual(totaliSquadra([]), { punti: 0, ace: 0, muri: 0, errori: 0 });
// --- giocatoriConScout: presenze e MVP, nessuna statistica offensiva ---------
const base = giocatori.find((g) => g.id === "g1")!;
const conScout = giocatoriConScout([match("m1", 3, 0, azioni)], { m1: base.nome });
const dopo = conScout.find((g) => g.id === "g1")!;
assert.equal(dopo.presenze, base.presenze + 1, "chi ha azioni risulta presente");
assert.equal(dopo.mvp, base.mvp + 1, "l'MVP eletto viene sommato");
assert.equal(dopo.totaliEventi, base.totaliEventi + 1);
const assente = conScout.find((g) => g.id === "g3")!;
const baseAssente = giocatori.find((g) => g.id === "g3")!;
assert.equal(assente.presenze, baseAssente.presenze, "chi non ha azioni non guadagna presenze");
assert.equal(assente.totaliEventi, baseAssente.totaliEventi + 1, "l'evento conta per tutti");
assert.deepEqual(giocatoriConScout([]), giocatori, "senza partite la rosa resta invariata");
// --- classificaConScout: 3 punti netti, 2 al tie-break vinto, 1 al perso -----
assert.deepEqual(classificaConScout([]), classifica, "senza scout resta la classifica di partenza");
const noi = "CRAP Volley";
const rigaBase = classifica.find((r) => r.squadra === noi)!;
const puntiDopo = (m: ScoutMatch) =>
classificaConScout([m]).find((r) => r.squadra === noi)!.punti - rigaBase.punti;
assert.equal(puntiDopo(match("v1", 3, 0)), 3, "3-0 vale 3 punti");
assert.equal(puntiDopo(match("v2", 3, 1)), 3, "3-1 vale 3 punti");
assert.equal(puntiDopo(match("v3", 3, 2)), 2, "3-2 vale 2 punti");
assert.equal(puntiDopo(match("p1", 2, 3)), 1, "2-3 vale 1 punto");
assert.equal(puntiDopo(match("p2", 0, 3)), 0, "0-3 non vale punti");
const aggiornata = classificaConScout([match("v1", 3, 1)]);
const nostra = aggiornata.find((r) => r.squadra === noi)!;
assert.equal(nostra.giocate, rigaBase.giocate + 1);
assert.equal(nostra.vinte, rigaBase.vinte + 1);
assert.equal(nostra.setFatti, rigaBase.setFatti + 3);
assert.equal(nostra.setSubiti, rigaBase.setSubiti + 1);
assert.deepEqual(
aggiornata.map((r) => r.pos),
aggiornata.map((_, i) => i + 1),
"le posizioni vengono rinumerate dopo il riordino",
);
assert.deepEqual(
aggiornata.map((r) => r.punti),
[...aggiornata.map((r) => r.punti)].sort((x, y) => y - x),
"ordinata per punti decrescenti",
);
assert.equal(
aggiornata.filter((r) => r.squadra !== noi).length,
classifica.length - 1,
"le altre squadre restano invariate",
);
// --- metadati azioni ---------------------------------------------------------
for (const [tipo, meta] of Object.entries(azioniMeta)) {
assert.ok(meta.label && meta.short, `${tipo}: etichette presenti`);
}
console.log("scout-store: ok");
+60
View File
@@ -0,0 +1,60 @@
/** Check delle serie consecutive: `bun test/unit/serie.test.ts`. */
import assert from "node:assert/strict";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie";
function g(valori: Partial<Giocatore> = {}): Giocatore {
return { ...giocatori[0]!, serieAllenamenti: 0, seriePartite: 0, serieConferme: 0, ...valori };
}
const allenamenti = serieDefs.find((d) => d.tipo === "allenamenti")!;
// --- aggiornaSerie: si azzera solo la serie non onorata -----------------------
assert.equal(aggiornaSerie(4, true), 5);
assert.equal(aggiornaSerie(4, false), 0, "un buco azzera la serie");
assert.equal(aggiornaSerie(0, true), 1, "si riparte da uno");
// --- statoSerie: traguardi 3 / 6 / 10 / 15 -----------------------------------
const partenza = statoSerie(allenamenti, g());
assert.deepEqual(
[partenza.valore, partenza.prossimo, partenza.manca, partenza.progresso],
[0, 3, 3, 0],
);
assert.equal(partenza.messaggio, "Serie allenamenti azzerata: riparti dal prossimo.");
const aDue = statoSerie(allenamenti, g({ serieAllenamenti: 2 }));
assert.deepEqual([aDue.prossimo, aDue.manca, aDue.progresso], [3, 1, 67]);
assert.equal(aDue.messaggio, "Manca solo una volta al prossimo traguardo!");
const sulTraguardo = statoSerie(allenamenti, g({ serieAllenamenti: 3 }));
assert.equal(sulTraguardo.prossimo, 6, "raggiunto un traguardo si punta al successivo");
assert.equal(sulTraguardo.progresso, 50);
const veterano = statoSerie(allenamenti, g({ serieAllenamenti: 7 }));
assert.equal(veterano.messaggio, "Che continuità: ancora 3 e sali di livello.");
const fuoriScala = statoSerie(allenamenti, g({ serieAllenamenti: 20 }));
assert.deepEqual([fuoriScala.prossimo, fuoriScala.manca, fuoriScala.progresso], [null, 0, 100]);
assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!");
// --- serieGiocatore / serieMigliore ------------------------------------------
const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 }));
assert.equal(tutte.length, serieDefs.length, "tre serie indipendenti");
assert.deepEqual(
tutte.map((s) => s.valore),
[4, 1, 9],
"ogni serie legge il proprio contatore",
);
const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 }));
assert.equal(migliore.def.tipo, "conferme", "in home si mostra la serie più lunga");
assert.ok(serieMigliore(g()), "anche a zero c'è sempre una serie da mostrare");
// --- invarianti sulle definizioni --------------------------------------------
for (const def of serieDefs) {
const ordinati = [...def.traguardi].sort((a, b) => a - b);
assert.deepEqual(def.traguardi, ordinati, `${def.tipo}: traguardi crescenti`);
assert.ok(def.traguardi.length > 0 && def.label && def.descrizione);
}
console.log("serie: ok");