Toglie la serie dal profilo, dalla Home e dalle notifiche di traguardo, insieme ai badge «Risposta lampo» e «Mai un forfait» e al calcolo di serieConferme. La colonna risposto_il e il suo trigger restano nel database, inutilizzati. Test e documentazione allineati. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
/** Check delle serie di presenze: `bun test/unit/presenze.test.ts`. */
|
|
import assert from "node:assert/strict";
|
|
import type { Evento } from "@/lib/eventi";
|
|
import {
|
|
conRisposta,
|
|
contaPartiteGiocate,
|
|
contaPresenzeGiocatore,
|
|
destinatariSollecito,
|
|
includeInfortunato,
|
|
serieConsecutiva,
|
|
testoSollecito,
|
|
totaliEventiGiocatore,
|
|
type MappaPresenze,
|
|
type MappaTempiRisposta,
|
|
} from "@/lib/presenze";
|
|
|
|
const ev = (id: string, tipo: Evento["tipo"], data: string, convocati: string[] = []): Evento => ({
|
|
id,
|
|
tipo,
|
|
titolo: id,
|
|
luogo: "",
|
|
data,
|
|
ora: "20:00",
|
|
note: "",
|
|
convocati,
|
|
campionato: false,
|
|
casa: true,
|
|
pagelleChiuse: false,
|
|
});
|
|
|
|
const OGGI = "2026-09-04";
|
|
|
|
// Passati in ordine sparso: a1 sì, a2 no, a3 sì, a4 sì. La serie riparte dopo il buco.
|
|
const eventi: Evento[] = [
|
|
ev("a3", "allenamento", "2026-08-20"),
|
|
ev("a1", "allenamento", "2026-08-06"),
|
|
ev("p1", "partita", "2026-08-10"),
|
|
ev("a2", "allenamento", "2026-08-13"),
|
|
ev("a4", "allenamento", "2026-08-27"),
|
|
ev("a5", "allenamento", "2026-09-10"), // futuro: non conta ancora
|
|
];
|
|
|
|
const presenze: MappaPresenze = {
|
|
a1: { g1: "presente" },
|
|
a2: { g1: "assente" },
|
|
a3: { g1: "ritardo" },
|
|
a4: { g1: "presente" },
|
|
p1: { g1: "presente" },
|
|
a5: { g1: "presente" },
|
|
};
|
|
|
|
assert.equal(serieConsecutiva("g1", eventi, presenze, "allenamento", OGGI), 2, "riparte dal buco");
|
|
assert.equal(serieConsecutiva("g1", eventi, presenze, "partita", OGGI), 1);
|
|
assert.equal(
|
|
serieConsecutiva("g1", eventi, presenze, undefined, OGGI),
|
|
2,
|
|
"senza tipo conta partite e allenamenti insieme, sempre in ordine di data",
|
|
);
|
|
|
|
// Nessuna risposta vale come buco.
|
|
assert.equal(serieConsecutiva("g2", eventi, presenze, "allenamento", OGGI), 0);
|
|
|
|
// Chi non è convocato non spezza la serie di nessun altro.
|
|
const conConvocati = [...eventi, ev("a6", "allenamento", "2026-08-30", ["g9"])];
|
|
assert.equal(serieConsecutiva("g1", conConvocati, presenze, "allenamento", OGGI), 2);
|
|
|
|
// L'infortunio congela la serie invece di azzerarla come farebbe un'assenza.
|
|
const eventiInfortunio: Evento[] = [
|
|
ev("i1", "allenamento", "2026-08-06"),
|
|
ev("i2", "allenamento", "2026-08-13"),
|
|
ev("i3", "allenamento", "2026-08-20"),
|
|
];
|
|
const presenzeInfortunio: MappaPresenze = {
|
|
i1: { g1: "presente" },
|
|
i2: { g1: "infortunato" },
|
|
i3: { g1: "presente" },
|
|
};
|
|
assert.equal(
|
|
serieConsecutiva("g1", eventiInfortunio, presenzeInfortunio, "allenamento", OGGI),
|
|
2,
|
|
"l'infortunio è saltato: i1 e i3 restano consecutivi",
|
|
);
|
|
assert.equal(
|
|
serieConsecutiva("g1", eventiInfortunio.slice(0, 2), presenzeInfortunio, "allenamento", OGGI),
|
|
1,
|
|
"l'infortunio come ultimo evento lascia la serie congelata, non la azzera",
|
|
);
|
|
|
|
// --- conteggio presenze: numeratore e denominatore delle statistiche ----------
|
|
// a1 presente, a2 assente, a3 ritardo, a4 presente, p1 presente: 4 su 5 passati.
|
|
assert.equal(contaPresenzeGiocatore("g1", eventi, presenze, OGGI), 4, "il ritardo conta presente");
|
|
assert.equal(totaliEventiGiocatore("g1", eventi, OGGI), 5, "gli eventi futuri non contano");
|
|
assert.equal(contaPresenzeGiocatore("g2", eventi, presenze, OGGI), 0, "chi non risponde è a zero");
|
|
assert.equal(totaliEventiGiocatore("g2", eventi, OGGI), 5, "il denominatore è uguale per tutti");
|
|
|
|
// contaPartiteGiocate esclude gli allenamenti: su 5 eventi passati (4 allenamenti, 1 partita
|
|
// p1, presente), conta solo p1.
|
|
assert.equal(
|
|
contaPartiteGiocate("g1", eventi, presenze, OGGI),
|
|
1,
|
|
"solo la partita p1: gli allenamenti non contano come partite giocate",
|
|
);
|
|
assert.equal(
|
|
contaPresenzeGiocatore("g1", eventi, presenze, OGGI, "allenamento"),
|
|
3,
|
|
"contaPresenzeGiocatore con tipo esplicito filtra come contaPartiteGiocate ma per allenamenti",
|
|
);
|
|
|
|
// Solo partite e allenamenti: compleanni e altri eventi restano fuori.
|
|
const conAltri: Evento[] = [
|
|
...eventi,
|
|
ev("x1", "evento", "2026-08-15"),
|
|
ev("c1", "compleanno", "2026-08-16"),
|
|
];
|
|
assert.equal(totaliEventiGiocatore("g1", conAltri, OGGI), 5, "solo partite e allenamenti");
|
|
|
|
// Un evento con convocati espliciti conta solo per i convocati.
|
|
const conRistretti: Evento[] = [...eventi, ev("a7", "allenamento", "2026-08-29", ["g9"])];
|
|
assert.equal(totaliEventiGiocatore("g9", conRistretti, OGGI), 6, "il convocato ce l'ha in più");
|
|
assert.equal(totaliEventiGiocatore("g1", conRistretti, OGGI), 5, "chi non è convocato no");
|
|
|
|
// --- destinatari del sollecito: chi non ha risposto, più i "forse" ------------
|
|
const squadra = [
|
|
{ id: "g1", attivo: true, tipo: "giocatore" as const },
|
|
{ id: "g2", attivo: true, tipo: "giocatore" as const },
|
|
{ id: "g3", attivo: true, tipo: "giocatore" as const },
|
|
{ id: "g4", attivo: true, tipo: "giocatore" as const },
|
|
{ id: "g5", attivo: false, tipo: "giocatore" as const }, // uscito: non lo si disturba più
|
|
{ id: "g6", attivo: true, tipo: "allenatore" as const }, // non risponde: mai sollecitato (DD-034)
|
|
];
|
|
|
|
const risposte = [
|
|
{ giocatore_id: "g1", stato: "presente" },
|
|
{ giocatore_id: "g2", stato: "forse" },
|
|
{ giocatore_id: "g4", stato: "assente" },
|
|
{ giocatore_id: "g5", stato: "forse" },
|
|
];
|
|
|
|
assert.deepEqual(
|
|
destinatariSollecito(squadra, risposte),
|
|
["g2", "g3"],
|
|
"il forse va sollecitato come chi non ha risposto; presente e assente no",
|
|
);
|
|
assert.deepEqual(
|
|
destinatariSollecito(squadra, []),
|
|
["g1", "g2", "g3", "g4"],
|
|
"senza nessuna risposta si avvisano tutti gli attivi",
|
|
);
|
|
assert.deepEqual(
|
|
destinatariSollecito(squadra, [
|
|
{ giocatore_id: "g1", stato: "presente" },
|
|
{ giocatore_id: "g2", stato: "ritardo" },
|
|
{ giocatore_id: "g3", stato: "infortunato" },
|
|
{ giocatore_id: "g4", stato: "assente" },
|
|
]),
|
|
[],
|
|
"quando hanno risposto tutti non parte nessuna push",
|
|
);
|
|
assert.deepEqual(destinatariSollecito([], risposte), [], "rosa vuota, nessun destinatario");
|
|
|
|
// --- il sollecito segue i convocati (DD-040) ----------------------------------
|
|
assert.deepEqual(
|
|
destinatariSollecito(squadra, risposte, ["g1", "g2", "g3"]),
|
|
["g2", "g3"],
|
|
"solo i convocati senza risposta o con «forse»: g1 ha risposto, g4 non è convocato",
|
|
);
|
|
assert.deepEqual(
|
|
destinatariSollecito(squadra, risposte, ["g4"]),
|
|
[],
|
|
"l'unico convocato ha risposto: nessun sollecito",
|
|
);
|
|
assert.deepEqual(
|
|
destinatariSollecito(squadra, [], ["g3", "g5", "g6", "g9"]),
|
|
["g3"],
|
|
"convocati inattivi, allenatori o fuori rosa restano esclusi anche se convocati",
|
|
);
|
|
assert.deepEqual(
|
|
destinatariSollecito(squadra, risposte, []),
|
|
["g2", "g3"],
|
|
"nessun convocato indicato vale per tutta la rosa, come nel database",
|
|
);
|
|
|
|
// --- testo del sollecito -----------------------------------------------------
|
|
const eventoSollecito = {
|
|
titolo: "Allenamento del giovedì",
|
|
data: "2026-10-01",
|
|
ora: "21:00",
|
|
luogo: "PalaCRAP",
|
|
};
|
|
const automatico = testoSollecito(eventoSollecito, undefined);
|
|
assert.equal(automatico.titolo, "Conferma di partecipazione richiesta: Allenamento del giovedì");
|
|
assert.equal(
|
|
automatico.testo,
|
|
[
|
|
"Data: 01/10/2026",
|
|
"Ora: 21:00",
|
|
"Luogo: PalaCRAP",
|
|
"Risposta attuale: nessuna",
|
|
"Azione: indicare presente, assente o in ritardo",
|
|
].join("\n"),
|
|
);
|
|
assert.match(testoSollecito(eventoSollecito, "forse").testo, /Risposta attuale: forse/);
|
|
assert.match(
|
|
testoSollecito(eventoSollecito, "assente").testo,
|
|
/Risposta attuale: nessuna/,
|
|
"qualunque stato diverso da «forse» si legge come nessuna risposta",
|
|
);
|
|
const manuale = testoSollecito(eventoSollecito, undefined, "Mario Rossi");
|
|
assert.equal(
|
|
manuale.testo.split("\n")[3],
|
|
"Richiesta di: Mario Rossi",
|
|
"il sollecito manuale nomina chi lo chiede, tra il luogo e la risposta attuale",
|
|
);
|
|
assert.ok(!automatico.testo.includes("Richiesta di"), "l'automatico non ha un mittente");
|
|
assert.ok(
|
|
!testoSollecito({ ...eventoSollecito, luogo: " " }, undefined).testo.includes("Luogo:"),
|
|
"senza luogo la riga manca",
|
|
);
|
|
|
|
// --- cache locale dopo una risposta: il cronometro non riparte ----------------
|
|
// Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela.
|
|
// Qui la cache deve imitarla, altrimenti la cache mente fino al refresh.
|
|
const PRIMA = "2026-08-01T10:00:00Z";
|
|
const POI = "2026-08-08T10:00:00Z";
|
|
|
|
const dopoPrimaRisposta = conRisposta(
|
|
undefined,
|
|
{ eventoId: "e1", giocatoreId: "g1", stato: "presente" },
|
|
PRIMA,
|
|
);
|
|
assert.deepEqual(dopoPrimaRisposta, {
|
|
presenze: { e1: { g1: "presente" } },
|
|
tempi: { e1: { g1: PRIMA } },
|
|
});
|
|
|
|
const dopoRipensamento = conRisposta(
|
|
dopoPrimaRisposta,
|
|
{ eventoId: "e1", giocatoreId: "g1", stato: "assente" },
|
|
POI,
|
|
);
|
|
assert.equal(dopoRipensamento.presenze["e1"]?.["g1"], "assente", "vale l'ultima risposta");
|
|
assert.equal(
|
|
dopoRipensamento.tempi["e1"]?.["g1"],
|
|
PRIMA,
|
|
"l'istante resta quello della prima risposta: il ripensamento non fa ripartire il cronometro",
|
|
);
|
|
|
|
const dopoRitiro = conRisposta(
|
|
dopoRipensamento,
|
|
{ eventoId: "e1", giocatoreId: "g1", stato: null },
|
|
POI,
|
|
);
|
|
assert.deepEqual(dopoRitiro.presenze["e1"], {}, "ritirare la risposta la toglie");
|
|
assert.deepEqual(
|
|
dopoRitiro.tempi["e1"],
|
|
{},
|
|
"e toglie anche l'istante: chi risponde di nuovo riparte da capo",
|
|
);
|
|
assert.equal(
|
|
conRisposta(dopoRitiro, { eventoId: "e1", giocatoreId: "g1", stato: "presente" }, POI).tempi[
|
|
"e1"
|
|
]?.["g1"],
|
|
POI,
|
|
"dopo un ritiro il cronometro riparte davvero",
|
|
);
|
|
|
|
// Gli altri giocatori e gli altri eventi non vengono toccati.
|
|
const conAltri2 = conRisposta(
|
|
conRisposta(undefined, { eventoId: "e1", giocatoreId: "g1", stato: "presente" }, PRIMA),
|
|
{ eventoId: "e2", giocatoreId: "g2", stato: "forse" },
|
|
POI,
|
|
);
|
|
assert.equal(conAltri2.presenze["e1"]?.["g1"], "presente", "l'altro evento resta in cache");
|
|
assert.equal(conAltri2.tempi["e2"]?.["g2"], POI);
|
|
|
|
// --- includeInfortunato: "infortunato" fuori per gli eventi extra-campo -------
|
|
assert.equal(includeInfortunato("allenamento"), true, "allenamento: infortunato è un'opzione");
|
|
assert.equal(includeInfortunato("partita"), true, "partita: infortunato è un'opzione");
|
|
assert.equal(includeInfortunato("evento"), false, "evento extra-campo: niente infortunato");
|
|
assert.equal(
|
|
includeInfortunato("evento", "infortunato"),
|
|
true,
|
|
"chi ha già risposto infortunato lo vede ancora, per poterlo togliere",
|
|
);
|
|
assert.equal(
|
|
includeInfortunato("evento", "presente"),
|
|
false,
|
|
"un'altra risposta esistente non riapre l'opzione",
|
|
);
|
|
assert.equal(
|
|
includeInfortunato("evento", undefined),
|
|
false,
|
|
"nessuna risposta: l'opzione resta chiusa per gli eventi extra-campo",
|
|
);
|
|
|
|
console.log("presenze: ok");
|