diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index 65c6c2e..c8ac93a 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -31,7 +31,7 @@ serie). | 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` | | Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` | | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` | -| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` | +| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` | | 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) | | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` | @@ -43,14 +43,11 @@ smart (`notifiche-smart.ts`). ## Limiti noti -- "Continuità di squadra" dipende da `serieAllenamenti` (vedi - [Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza - risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la - squadra risponde alle convocazioni, non solo la presenza. - Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali. - I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a - ogni stagione. + ogni stagione — con l'eccezione di "Continuità di squadra" (vedi sotto), il cui target ha un + significato specifico e non va scalato come gli altri. --- @@ -70,3 +67,22 @@ Per i test, `obiettiviSquadra`/`obiettiviOrdinati` accettano un terzo parametro (`test/unit/obiettivi.test.ts`, funzione pura) sia da un integration test end-to-end (`test/integration/obiettivi.test.ts`, scrive/rilegge righe vere su `eventi_app` e `risposte_presenze` sullo stack Supabase locale). + +--- + +## Continuità di squadra — il target 12 è il minimo per un 6vs6 + +Il target di 12 giocatori con almeno 3 allenamenti consecutivi **non è arbitrario**: è il numero +minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A differenza degli +altri target fissi (250 presenze, 200 pagelle...), non va scalato in proporzione alla rosa se +questa cambia dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo +completo", indipendentemente da quanti giocatori ci sono in rosa oltre quel minimo. + +Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati +reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo +misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica. + +Coperto da unit test (rosa vuota, il confine ≥3 — 2 non basta, 3 sì — e il target fisso a 12) e +da un integration test end-to-end che scrive tre allenamenti e presenze reali sul database +locale, calcola `serieConsecutiva()` (la stessa funzione pura usata da `useRosa()` in +produzione) sui dati riletti, e verifica che solo chi è rimasto in serie venga contato. diff --git a/test/integration/obiettivi.test.ts b/test/integration/obiettivi.test.ts index 5ea95de..5902b9c 100644 --- a/test/integration/obiettivi.test.ts +++ b/test/integration/obiettivi.test.ts @@ -23,7 +23,7 @@ import assert from "node:assert/strict"; import { giocatori } from "@/lib/crapp-data"; import { obiettiviSquadra } from "@/lib/obiettivi"; -import { contaPresenzeGiocatore, type MappaPresenze } from "@/lib/presenze"; +import { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze"; import { statoLocale } from "../helpers/locale"; import { prova, riepilogo, salta } from "../helpers/prova"; @@ -479,6 +479,70 @@ if (!locale) { "conteggio dei voti reali scritti sul database", ); }); + + await prova( + "o11 conta chi ha almeno 3 allenamenti consecutivi, calcolati da eventi/risposte reali", + async () => { + // o11 non calcola nulla da `ctx`: legge `g.serieAllenamenti`, un campo già calcolato a + // monte da `serieConsecutiva()` (la stessa funzione pura usata da `useRosa()` in + // produzione). Qui si esercita l'intera catena DB -> serieConsecutiva -> o11. + const OGGI_STR = "2098-01-20"; + const eventi = [ + [`${PREFISSO}-o11-a1`, "2098-01-05"], + [`${PREFISSO}-o11-a2`, "2098-01-08"], + [`${PREFISSO}-o11-a3`, "2098-01-11"], + ] as const; + for (const [id, data] of eventi) { + const inserito = await rest("eventi_app", { + method: "POST", + body: JSON.stringify({ id, tipo: "allenamento", titolo: "Test obiettivi o11", data }), + }); + if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`); + } + + const [g1, g2, g3] = giocatori; + // g1: presente ai tre allenamenti -> serie 3 (conta). g2: presente, presente, assente + // -> la serie si azzera all'ultimo (non conta). g3: assente, assente, presente -> serie + // 1 (non basta). + const stati: Record = { + [g1!.id]: ["presente", "presente", "presente"], + [g2!.id]: ["presente", "presente", "assente"], + [g3!.id]: ["assente", "assente", "presente"], + }; + const righe = Object.entries(stati).flatMap(([giocatoreId, statiPerEvento]) => + eventi.map(([eventoId], i) => ({ + evento_id: eventoId, + giocatore_id: giocatoreId, + stato: statiPerEvento[i], + })), + ); + const inserite = await rest("risposte_presenze", { + method: "POST", + body: JSON.stringify(righe), + }); + if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`); + + const eventiReali = (await leggiEventi()).filter((e) => + eventi.some(([id]) => id === e.id), + ); + const presenzeReali: MappaPresenze = {}; + for (const [id] of eventi) Object.assign(presenzeReali, await leggiPresenze(id)); + + const rosaConSerieReali = [g1!, g2!, g3!].map((g) => ({ + ...g, + serieAllenamenti: serieConsecutiva(g.id, eventiReali, presenzeReali, "allenamento", OGGI_STR), + })); + + const o11 = obiettiviSquadra(rosaConSerieReali, { eventi: [], presenze: {}, pagelle: [] }).find( + (o) => o.id === "o11", + )!; + assert.equal( + o11.valore, + 1, + "solo g1 (presente ai tre allenamenti) resta in serie, calcolato dal database", + ); + }, + ); } finally { await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts index d2e95e3..bb00dda 100644 --- a/test/unit/obiettivi.test.ts +++ b/test/unit/obiettivi.test.ts @@ -302,6 +302,29 @@ assert.equal( "il conteggio somma i voti di più match_id, non solo dell'ultima", ); +// --- o11: continuità di squadra (giocatori con serieAllenamenti >= 3) -------- +{ + assert.equal( + trova(obiettiviSquadra([], contestoVuoto), "o11").valore, + 0, + "rosa vuota: nessuno può essere in serie", + ); + + // Target 12 su una rosa di 17: il minimo per schierare due sestetti (6vs6), non un + // valore arbitrario — vedi docs/modules/obiettivi-squadra.md. + assert.equal(trova(vuoti, "o11").target, 12, "il target resta 12: minimo per un 6vs6"); + + // Il confine è >= 3, non > 3: 2 non basta, 3 sì. + const rosaConfine = giocatori + .slice(0, 3) + .map((g, i) => ({ ...g, serieAllenamenti: [2, 3, 10][i]! })); + assert.equal( + trova(obiettiviSquadra(rosaConfine, contestoVuoto), "o11").valore, + 2, + "conta solo chi ha almeno 3 allenamenti consecutivi (2 non basta, 3 sì)", + ); +} + // --- progressoObiettivo ------------------------------------------------------ const o = (valore: number, target: number): ObiettivoSquadra => ({ id: "x",