diff --git a/package.json b/package.json index c328091..3853f77 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "build:dev": "vite build --mode development", "preview": "vite preview", "lint": "eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "bun test/run.ts", + "test:integration": "bun test/run.ts integration", + "test:e2e": "bun test/run.ts e2e", + "test:all": "bun test/run.ts all" }, "dependencies": { "@hookform/resolvers": "^5.2.2", diff --git a/test/e2e/percorsi.test.ts b/test/e2e/percorsi.test.ts new file mode 100644 index 0000000..aea1fbf --- /dev/null +++ b/test/e2e/percorsi.test.ts @@ -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 { + 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>/.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(); +} diff --git a/test/helpers/prova.ts b/test/helpers/prova.ts new file mode 100644 index 0000000..af883dc --- /dev/null +++ b/test/helpers/prova.ts @@ -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); +} diff --git a/test/helpers/server.ts b/test/helpers/server.ts new file mode 100644 index 0000000..5cfb9cd --- /dev/null +++ b/test/helpers/server.ts @@ -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)}`); + } +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts new file mode 100644 index 0000000..09b055f --- /dev/null +++ b/test/integration/api.test.ts @@ -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(); +} diff --git a/test/run.ts b/test/run.ts new file mode 100644 index 0000000..e7010cd --- /dev/null +++ b/test/run.ts @@ -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); diff --git a/test/unit/badge-social.test.ts b/test/unit/badge-social.test.ts new file mode 100644 index 0000000..e58b009 --- /dev/null +++ b/test/unit/badge-social.test.ts @@ -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"); diff --git a/test/unit/badges.test.ts b/test/unit/badges.test.ts new file mode 100644 index 0000000..37b4ed9 --- /dev/null +++ b/test/unit/badges.test.ts @@ -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"); diff --git a/test/unit/cacche.test.ts b/test/unit/cacche.test.ts new file mode 100644 index 0000000..8f8abdd --- /dev/null +++ b/test/unit/cacche.test.ts @@ -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"); diff --git a/test/unit/crapp-data.test.ts b/test/unit/crapp-data.test.ts new file mode 100644 index 0000000..8092981 --- /dev/null +++ b/test/unit/crapp-data.test.ts @@ -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"); diff --git a/src/lib/csi-core.test.ts b/test/unit/csi-core.test.ts similarity index 99% rename from src/lib/csi-core.test.ts rename to test/unit/csi-core.test.ts index 46b7dec..a907b79 100644 --- a/src/lib/csi-core.test.ts +++ b/test/unit/csi-core.test.ts @@ -10,7 +10,7 @@ import { partiteGiocate, urlClassifica, urlPartite, -} from "./csi-core"; +} from "@/lib/csi-core"; const htmlClassifica = ` <table class="ranking"> diff --git a/test/unit/eventi.test.ts b/test/unit/eventi.test.ts new file mode 100644 index 0000000..2bc3da7 --- /dev/null +++ b/test/unit/eventi.test.ts @@ -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"); diff --git a/test/unit/infortuni.test.ts b/test/unit/infortuni.test.ts new file mode 100644 index 0000000..f7f6a4a --- /dev/null +++ b/test/unit/infortuni.test.ts @@ -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"); diff --git a/test/unit/mvp-voti.test.ts b/test/unit/mvp-voti.test.ts new file mode 100644 index 0000000..ffcc140 --- /dev/null +++ b/test/unit/mvp-voti.test.ts @@ -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"); diff --git a/test/unit/notifiche-smart.test.ts b/test/unit/notifiche-smart.test.ts new file mode 100644 index 0000000..72a3245 --- /dev/null +++ b/test/unit/notifiche-smart.test.ts @@ -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"); diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts new file mode 100644 index 0000000..8152f4a --- /dev/null +++ b/test/unit/obiettivi.test.ts @@ -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"); diff --git a/test/unit/pagelle.test.ts b/test/unit/pagelle.test.ts new file mode 100644 index 0000000..cae1b98 --- /dev/null +++ b/test/unit/pagelle.test.ts @@ -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"); diff --git a/test/unit/palloni-core.test.ts b/test/unit/palloni-core.test.ts new file mode 100644 index 0000000..b14e20b --- /dev/null +++ b/test/unit/palloni-core.test.ts @@ -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"); diff --git a/test/unit/scout-export.test.ts b/test/unit/scout-export.test.ts new file mode 100644 index 0000000..bc0ba52 --- /dev/null +++ b/test/unit/scout-export.test.ts @@ -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"); diff --git a/test/unit/scout-store.test.ts b/test/unit/scout-store.test.ts new file mode 100644 index 0000000..3f2cda9 --- /dev/null +++ b/test/unit/scout-store.test.ts @@ -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"); diff --git a/test/unit/serie.test.ts b/test/unit/serie.test.ts new file mode 100644 index 0000000..521c375 --- /dev/null +++ b/test/unit/serie.test.ts @@ -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"); diff --git a/tsconfig.json b/tsconfig.json index a522d21..397e5e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "eslint.config.js"], + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "vite.config.ts", "eslint.config.js"], "compilerOptions": { "target": "ES2022", "jsx": "react-jsx",