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
+114
View File
@@ -0,0 +1,114 @@
/**
* Percorsi end-to-end sull'app servita davvero: `bun test/e2e/percorsi.test.ts`.
* Verifica il giro completo route → server → dati → risposta HTTP.
*
* L'app renderizza i contenuti dopo l'idratazione: il server invia il guscio
* (titolo, meta, splash) e il resto arriva nel browser. Qui si verifica quindi
* tutto ciò che è osservabile senza browser; per i contenuti renderizzati
* servirebbe un driver tipo Playwright.
*/
import assert from "node:assert/strict";
import { isNostraSquadra, type DatiCsi } from "@/lib/csi-core";
import { avviaServer, json } from "../helpers/server";
import { prova, riepilogo } from "../helpers/prova";
const server = await avviaServer();
const url = (p: string) => `${server.baseUrl}${p}`;
async function pagina(percorso: string): Promise<string> {
const res = await fetch(url(percorso));
assert.equal(res.status, 200, `${percorso} deve rispondere 200`);
assert.match(res.headers.get("content-type") ?? "", /text\/html/, `${percorso} è una pagina`);
const html = await res.text();
assert.doesNotMatch(html, /Errore imprevisto|h3 swallowed/i, `${percorso}: nessun errore server`);
return html;
}
const titolo = (html: string) => /<title>(.*?)<\/title>/.exec(html)?.[1] ?? "";
console.log(`percorsi end-to-end su ${server.baseUrl}`);
try {
// --- 1. Primo avvio: l'app si carica --------------------------------------
await prova("la home serve il guscio dell'app con il proprio titolo", async () => {
const html = await pagina("/");
assert.match(titolo(html), /CrAPP/, "titolo della home");
assert.match(html, /icon-192\.png/, "splash di caricamento");
assert.match(html, /manifest\.webmanifest/, "manifest collegato: installabile come PWA");
assert.match(html, /<meta name="description"/, "descrizione per la condivisione");
});
// --- 2. Ogni schermata ha la sua identità ----------------------------------
await prova("ogni schermata risponde con il proprio titolo", async () => {
const attesi: Array<[string, RegExp]> = [
["/benvenuto", /Benvenuto/],
["/squadra", /Squadra/],
["/classifica", /Classifica/],
["/calendario", /Calendario|CrAPP/],
["/profilo", /Profilo|CrAPP/],
["/eventi", /Eventi|CrAPP/],
["/scout", /Scout|CrAPP/],
];
for (const [percorso, atteso] of attesi) {
assert.match(titolo(await pagina(percorso)), atteso, `${percorso}: titolo corretto`);
}
});
// --- 3. Campionato: dal portale CSI fino alla pagina -----------------------
await prova("i dati ufficiali CSI arrivano fino alla pagina Campionato", async () => {
const res = await fetch(url("/api/public/csi"));
assert.equal(res.status, 200, "l'endpoint che alimenta la pagina risponde");
const csi = (await json(res)) as DatiCsi;
const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "la nostra squadra arriva dal portale");
assert.ok(
csi.partite.some((p) => p.setNostri !== null),
"ci sono risultati giocati",
);
const html = await pagina("/classifica");
assert.match(titolo(html), /Classifica campionato/);
assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio");
});
// --- 4. Dettaglio di un evento --------------------------------------------
await prova("le pagine di dettaglio reggono un id inesistente", async () => {
for (const percorso of ["/partita/non-esiste", "/allenamento/non-esiste"]) {
const res = await fetch(url(percorso));
assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`);
}
});
// --- 5. Installazione come PWA e notifiche push ---------------------------
await prova("i file necessari alla PWA sono serviti", async () => {
const manifest = await fetch(url("/manifest.webmanifest"));
assert.equal(manifest.status, 200);
const dati = (await json(manifest)) as { name?: string; icons?: unknown[]; start_url?: string };
assert.ok(dati.name?.includes("CrAPP"));
assert.equal(dati.start_url, "/");
assert.ok((dati.icons ?? []).length >= 2, "icone per l'installazione");
for (const file of ["/push-sw.js", "/icon-192.png", "/icon-512.png", "/robots.txt"]) {
assert.equal((await fetch(url(file))).status, 200, `${file} raggiungibile`);
}
});
await prova("il service worker e la sua configurazione si parlano", async () => {
const sw = await (await fetch(url("/push-sw.js"))).text();
assert.match(sw, /\/api\/public\/push-messaggio/, "endpoint invocato dal worker");
assert.match(sw, /showNotification/, "il worker mostra la notifica");
const config = await fetch(url("/api/public/push-config"));
assert.equal(config.status, 200, "la chiave pubblica VAPID è interrogabile");
});
// --- 6. Percorsi inesistenti ----------------------------------------------
await prova("una rotta inesistente risponde 404 senza rompere l'app", async () => {
const res = await fetch(url("/pagina-che-non-esiste"));
assert.equal(res.status, 404);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
});
riepilogo("percorsi end-to-end");
} finally {
server.stop();
}
+32
View File
@@ -0,0 +1,32 @@
/** Mini-harness condiviso: nessuna dipendenza, solo conteggio e uscita non-zero. */
const falliti: string[] = [];
let eseguiti = 0;
let saltati = 0;
export async function prova(nome: string, fn: () => Promise<void> | void) {
eseguiti += 1;
try {
await fn();
console.log(`${nome}`);
} catch (errore) {
falliti.push(nome);
console.error(`${nome}\n ${(errore as Error).message.split("\n")[0]}`);
}
}
export function salta(nome: string, motivo: string) {
saltati += 1;
console.log(` · ${nome} (saltato: ${motivo})`);
}
/** Da chiamare a fine file: stampa il riepilogo e imposta il codice di uscita. */
export function riepilogo(gruppo: string) {
const passati = eseguiti - falliti.length;
console.log(
`${gruppo}: ${passati}/${eseguiti} ok${saltati ? `, ${saltati} saltati` : ""}${
falliti.length ? `, falliti: ${falliti.join(", ")}` : ""
}`,
);
if (falliti.length) process.exit(1);
}
+111
View File
@@ -0,0 +1,111 @@
import { spawn, type ChildProcess } from "node:child_process";
import { readFileSync } from "node:fs";
/**
* Avvia il server di sviluppo per i test che parlano HTTP.
* Con BASE_URL impostata usa un server già attivo e non ne avvia uno nuovo.
*/
export type ServerTest = { baseUrl: string; stop: () => void };
/**
* `.env` contiene solo le variabili `VITE_*`, ma le route server leggono i nomi
* senza prefisso (vedi docs/modules/collegamento-csi.md e client.server.ts):
* qui li deriviamo, così i test girano senza configurazione aggiuntiva.
*/
export function envDaFile(): Record<string, string> {
let testo = "";
try {
testo = readFileSync(new URL("../../.env", import.meta.url), "utf8");
} catch {
return {};
}
const env: Record<string, string> = {};
for (const riga of testo.split("\n")) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(riga);
if (m?.[1]) env[m[1]] = (m[2] ?? "").trim().replace(/^["']|["']$/g, "");
}
if (!env["SUPABASE_URL"] && env["VITE_SUPABASE_URL"])
env["SUPABASE_URL"] = env["VITE_SUPABASE_URL"];
if (!env["SUPABASE_PUBLISHABLE_KEY"] && env["VITE_SUPABASE_PUBLISHABLE_KEY"]) {
env["SUPABASE_PUBLISHABLE_KEY"] = env["VITE_SUPABASE_PUBLISHABLE_KEY"];
}
return env;
}
export function haSupabase(): boolean {
const env = { ...envDaFile(), ...process.env };
return Boolean(env["SUPABASE_URL"] && env["SUPABASE_SERVICE_ROLE_KEY"]);
}
export async function avviaServer(timeoutMs = 120_000): Promise<ServerTest> {
const esistente = process.env["BASE_URL"];
if (esistente) return { baseUrl: esistente.replace(/\/$/, ""), stop: () => {} };
const processo: ChildProcess = spawn("npm", ["run", "dev"], {
cwd: new URL("../..", import.meta.url).pathname,
env: { ...envDaFile(), ...process.env },
stdio: ["ignore", "pipe", "pipe"],
// Gruppo di processi dedicato: npm avvia vite come figlio e un SIGTERM al
// solo npm lascerebbe il server orfano ad occupare la porta.
detached: true,
});
const stop = () => {
if (processo.killed || processo.pid === undefined) return;
try {
process.kill(-processo.pid, "SIGTERM");
} catch {
processo.kill("SIGTERM");
}
};
const baseUrl = await new Promise<string>((resolve, reject) => {
const scadenza = setTimeout(() => {
stop();
reject(new Error(`Il server non è partito entro ${timeoutMs / 1000}s`));
}, timeoutMs);
let uscita = "";
const cerca = (chunk: Buffer) => {
uscita += chunk.toString();
const url = /http:\/\/localhost:\d+/.exec(uscita)?.[0];
if (url) {
clearTimeout(scadenza);
resolve(url);
}
};
processo.stdout?.on("data", cerca);
processo.stderr?.on("data", cerca);
processo.on("exit", (code) => {
clearTimeout(scadenza);
reject(new Error(`Il server è uscito con codice ${code}:\n${uscita}`));
});
});
await attendiPronto(baseUrl);
return { baseUrl, stop };
}
/** Il server annuncia l'URL prima di saper rispondere: attende la prima risposta. */
async function attendiPronto(baseUrl: string, tentativi = 60) {
for (let i = 0; i < tentativi; i += 1) {
try {
const res = await fetch(baseUrl, { signal: AbortSignal.timeout(5_000) });
if (res.status < 500) return;
} catch {
/* non ancora pronto */
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Il server non risponde su ${baseUrl}`);
}
export async function json(res: Response): Promise<unknown> {
const testo = await res.text();
try {
return JSON.parse(testo);
} catch {
throw new Error(`Risposta non JSON (${res.status}): ${testo.slice(0, 200)}`);
}
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Integrazione delle route server: `bun test/integration/api.test.ts`.
* Avvia il server di sviluppo (o usa BASE_URL) e chiama gli endpoint veri.
* Nessun test scrive sul database: solo letture e validazioni.
*/
import assert from "node:assert/strict";
import { isNostraSquadra, type DatiCsi } from "@/lib/csi-core";
import { avviaServer, haSupabase, json } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
const server = await avviaServer();
const url = (p: string) => `${server.baseUrl}${p}`;
const postJson = (p: string, body: unknown) =>
fetch(url(p), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
console.log(`integrazione API su ${server.baseUrl}`);
try {
// --- GET /api/public/csi ---------------------------------------------------
let csi: DatiCsi | undefined;
await prova("GET /api/public/csi restituisce classifica e partite", async () => {
const res = await fetch(url("/api/public/csi"));
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /application\/json/);
csi = (await json(res)) as DatiCsi;
assert.ok(Array.isArray(csi.classifica) && csi.classifica.length > 0);
assert.ok(Array.isArray(csi.partite) && csi.partite.length > 0);
assert.equal(csi.girone, "Girone B");
assert.ok(!Number.isNaN(Date.parse(csi.aggiornato)), "timestamp valido");
});
await prova("la classifica contiene la nostra squadra con dati coerenti", () => {
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "C.R.A.P. Volley presente in classifica");
assert.equal(noi.vinte + noi.perse, noi.giocate, "vinte + perse = giocate");
assert.ok(noi.punti >= 0 && noi.pos > 0);
const posizioni = csi!.classifica.map((r) => r.pos);
assert.deepEqual(
posizioni,
[...posizioni].sort((a, b) => a - b),
"ordinata per posizione",
);
});
await prova("ogni partita è coerente con il formato dell'app", () => {
for (const p of csi?.partite ?? []) {
assert.match(p.data, /^\d{4}-\d{2}-\d{2}$/, `${p.id}: data ISO`);
assert.ok(p.avversario.length > 0, `${p.id}: avversario valorizzato`);
assert.ok(!isNostraSquadra(p.avversario), `${p.id}: non giochiamo contro noi stessi`);
const giocata = p.setNostri !== null && p.setLoro !== null;
if (giocata) assert.equal(Math.max(p.setNostri!, p.setLoro!), 3, `${p.id}: si vince a 3 set`);
else assert.equal(p.parziali.length, 0, `${p.id}: gara futura senza parziali`);
}
});
await prova("la seconda chiamata arriva dalla cache del server", async () => {
const t0 = Date.now();
const secondo = (await json(await fetch(url("/api/public/csi")))) as DatiCsi;
assert.equal(secondo.aggiornato, csi?.aggiornato, "stesso timestamp: nessuna nuova fetch");
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
});
// --- GET /api/public/push-config -------------------------------------------
await prova("GET /api/public/push-config espone solo la chiave pubblica", async () => {
const res = await fetch(url("/api/public/push-config"));
assert.equal(res.status, 200);
const dati = (await json(res)) as Record<string, unknown>;
assert.deepEqual(Object.keys(dati), ["publicKey"], "nessun altro dato esposto");
assert.ok(dati["publicKey"] === null || typeof dati["publicKey"] === "string");
});
// --- validazione degli input (nessuna scrittura) ---------------------------
await prova("push-subscribe rifiuta i payload non validi", async () => {
assert.equal((await postJson("/api/public/push-subscribe", {})).status, 400);
assert.equal(
(
await postJson("/api/public/push-subscribe", {
endpoint: "non-un-url",
giocatoreId: "g1",
p256dh: "x",
auth: "y",
})
).status,
400,
"endpoint deve essere un URL",
);
assert.equal(
(
await postJson("/api/public/push-subscribe", {
endpoint: `https://push.example/${"x".repeat(1100)}`,
giocatoreId: "g1",
p256dh: "x",
auth: "y",
})
).status,
400,
"endpoint troppo lungo",
);
const res = await fetch(url("/api/public/push-subscribe"), {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
assert.equal(res.status, 400, "anche la cancellazione valida l'input");
});
await prova("push-messaggio e sollecita-presenze rifiutano i payload non validi", async () => {
assert.equal((await postJson("/api/public/push-messaggio", {})).status, 400);
assert.equal((await postJson("/api/public/sollecita-presenze", {})).status, 400);
assert.equal(
(await postJson("/api/public/sollecita-presenze", { eventoId: "" })).status,
400,
"eventoId vuoto non è valido",
);
});
// --- endpoint che leggono dal database -------------------------------------
if (haSupabase()) {
await prova("push-messaggio risponde con il messaggio di default", async () => {
const res = await postJson("/api/public/push-messaggio", {
endpoint: `https://push.example/test-${Date.now()}`,
});
assert.equal(res.status, 200);
const dati = (await json(res)) as { title?: string; body?: string };
assert.ok(dati.title && dati.body, "un endpoint sconosciuto riceve comunque un testo");
});
await prova("sollecita-presenze su un evento inesistente risponde 404", async () => {
const res = await postJson("/api/public/sollecita-presenze", { eventoId: "non-esiste" });
assert.equal(res.status, 404);
});
} else {
salta("endpoint con database", "SUPABASE_URL/SERVICE_ROLE_KEY non configurate");
}
// --- metodi non previsti ---------------------------------------------------
await prova("un GET su un endpoint POST non esegue l'handler", async () => {
const res = await fetch(url("/api/public/sollecita-presenze"));
// TanStack Start serve la pagina invece del 405: l'importante è che il
// job non parta e che nessuna notifica venga inviata.
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
assert.doesNotMatch(await res.text(), /"inviate"/);
});
riepilogo("integrazione API");
} finally {
server.stop();
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Esecutore della suite: `bun test/run.ts [unit|integration|e2e|all]`.
* Ogni file gira in un processo separato, così un test non può inquinare gli altri.
* Senza argomenti esegue solo gli unit test (non serve rete né database).
*/
import { spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
const cartelle = { unit: "test/unit", integration: "test/integration", e2e: "test/e2e" };
type Gruppo = keyof typeof cartelle;
const argomento = (process.argv[2] ?? "unit") as Gruppo | "all";
const gruppi: Gruppo[] =
argomento === "all" ? (Object.keys(cartelle) as Gruppo[]) : [argomento as Gruppo];
for (const g of gruppi) {
if (!cartelle[g]) {
console.error(`Gruppo sconosciuto: ${g}. Usa unit, integration, e2e oppure all.`);
process.exit(2);
}
}
const falliti: string[] = [];
let totali = 0;
for (const gruppo of gruppi) {
const cartella = cartelle[gruppo];
const file = readdirSync(cartella)
.filter((f) => f.endsWith(".test.ts"))
.sort();
console.log(`\n── ${gruppo} (${file.length} file)`);
for (const nome of file) {
const percorso = `${cartella}/${nome}`;
totali += 1;
const esito = spawnSync("bun", [percorso], { stdio: "inherit" });
if (esito.status !== 0) falliti.push(percorso);
}
}
console.log(
`\n${totali - falliti.length}/${totali} file ok` +
(falliti.length ? `\nfalliti:\n ${falliti.join("\n ")}` : ""),
);
process.exit(falliti.length ? 1 : 0);
+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");