63 Commits

Author SHA1 Message Date
davide 82c997d475 Merge branch 'issue#19'
Migliora il referto di gara: nomi squadra al posto di A/B, icona
servizio a inizio set, allineamento "vinto da" e colori squadra
coerenti col segnapunti live.
2026-07-15 10:26:50 +02:00
davide 35c49247a8 style(referto): allinea i colori squadra a quelli del segnapunti live
Punti in striscia e nomi in "Formazione di partenza" ora usano lo
stesso giallo (#f5c518) per la squadra di casa e blu (#2196f3) per
gli ospiti già usati in display/controller, invece di una palette
scollegata dal resto dell'app.
2026-07-15 10:25:57 +02:00
davide fec140df62 style(referto): allinea "vinto da" a destra nell'header del set
Separa punteggio ed etichetta vincitore su un flex con
justify-content: space-between per migliorare la leggibilità.
2026-07-15 10:11:40 +02:00
davide c6eb493f25 feat(referto): mostra l'icona del servizio a inizio set
Aggiunge un'icona nera (nuovo asset serv-nero.png, più visibile del
serv.png bianco su sfondo chiaro) accanto al nome della squadra che
serviva all'inizio di ogni set nel referto stampabile.

Corregge inoltre generaReferto: la stampa partiva subito dopo
document.close(), prima che l'icona (risorsa esterna asincrona)
finisse di caricare, risultando in un riquadro vuoto. Ora attende
l'evento 'load' della finestra popup prima di chiamare print().
2026-07-15 10:05:47 +02:00
davide 7cf84a94d9 fix(referto): mostra i nomi delle squadre al posto delle etichette A/B
L'header e la tabella del risultato finale usavano segnaposto generici
"A"/"B"; ora indicano esplicitamente casa/ospiti e i nomi reali delle
squadre.
2026-07-15 09:55:51 +02:00
davide 00a8300a75 chore(release): bump versione a 2.1.0
Aggiorna package.json/package-lock.json, tag immagine in docker-compose.yml
e riferimenti nel README; changelog 2.1.0 con time out/spot video, modalità
amichevole, referto, layout esteso controller e flusso dev containerizzato.
2026-07-08 10:28:18 +02:00
davide ec19eb62c3 feat(docker): aggiungi flusso di sviluppo con hot reload via compose
Dockerfile diventa multi-stage con target `dev` (Vite + bind-mount) oltre
a `builder`/`runtime`; docker-compose.dev.yml lo usa per un ambiente di
sviluppo containerizzato equivalente a `npm run dev`, senza toccare il
compose di produzione (che resta pull-only dal registry). Documentato in
README e CLAUDE.md.
2026-07-08 10:23:27 +02:00
davide 34c9a61e79 docs(claude): comprimi CLAUDE.md per ridurre i token senza perdere contenuti
Riscrittura più concisa (elenchi puntati al posto di prosa, frasi
accorciate); tutti i concetti tecnici restano invariati.
2026-07-08 00:24:30 +02:00
davide ff6a305554 Merge branch 'update-striscia-18' (#18)
Registra cambi e time out per set nella striscia, ristilizza il
referto in stile FIPAV e porta la copertura test dal 76% al 99%.
2026-07-03 12:09:06 +02:00
davide 82c2286ca6 docs: aggiorna README, tests/README e CLAUDE.md con la nuova copertura test
Riflette i test aggiunti nei commit precedenti: rilevamento
piattaforma di server-utils, avvio effettivo di startServer, rami di
errore del WebSocket handler, cleanup/riconnessione di wsMixin,
side-effect di generaReferto e layout esteso del controller.
Aggiorna anche i conteggi di esempio (15 file/362 test Vitest, 81
E2E) e documenta come verificare la copertura (>99% statement).
2026-07-03 12:04:17 +02:00
davide 0b7cbd00f6 test(component): porta ControllerPage e DisplayPage al 100% di statement coverage
Aggiunge 65 test per ControllerPage.vue (layout esteso/landscape,
popup automatico di set vinto con soglia decisiva, modal di
configurazione, validazione completa dei cambi giocatore, modal
time out con selezione video/ticker/countdown, cleanup dei listener)
e 35 per DisplayPage.vue (countdown overlay, riallineamento video su
riconnessione, fallback muto per autoplay bloccato, selezione voce
per la sintesi vocale). Nessun bug riscontrato nei componenti.
2026-07-03 12:00:45 +02:00
davide 6638d6fe44 test(server): copri l'avvio effettivo di startServer (HTTP + WebSocket)
startServer() non era mai invocato nei test: solo createApp() (routing)
era coperto. Mocka src/persist.js per non toccare .segnapunti/state.json
reale, e verifica l'ascolto HTTP, l'accettazione dell'upgrade su /ws,
il rifiuto su altri percorsi e la persistenza dello stato dopo
un'azione del controller.
2026-07-03 12:00:30 +02:00
davide cbf927255a test(wsMixin): copri cleanup, riconnessione e guardie residue
Aggiunge test per beforeUnmount con connessione OPEN e con ws.close
che lancia, l'annullamento del reconnectTimeout allo smontaggio, la
guardia isConnecting, la chiusura della WebSocket precedente prima
di riconnettersi (incluso il caso in cui close lancia), gli errori
di registrazione/parsing nei gestori onopen/onmessage e la guardia
di scheduleReconnect contro timer doppi.
2026-07-03 12:00:16 +02:00
davide 3d54d162e6 test(websocket-handler): copri i rami di errore su send/terminate/applyAction
Aggiunge scenari in cui ws.send o ws.terminate lanciano eccezioni e
in cui applyAction fallisce a metà azione: verifica che l'errore
venga loggato e lo stato precedente ripristinato senza propagare
l'eccezione al chiamante.
2026-07-03 12:00:02 +02:00
davide eb91b71d7e test(server-utils): copri il rilevamento piattaforma (WSL/PowerShell)
getNetworkIPs() senza parametri usa isWSL()/execSync/os.networkInterfaces,
codice mai esercitato perché tutti gli altri test iniettano `nets`
per bypassarlo. Mocka fs/child_process/os per coprire in modo
deterministico i rami WSL, non-WSL, fallimento PowerShell e lettura
/proc fallita.
2026-07-03 11:59:49 +02:00
davide 2625284e99 test(referto): copri generaReferto e il branch guest di vincitoreSet
buildRefertoHtml mancava il caso vinc nullo con guest in vantaggio;
generaReferto (window.open/print) non era testato affatto perché
richiede un DOM — aggiunto un test component in happy-dom che mocka
window.open.
2026-07-03 11:59:33 +02:00
davide fdd1d0c990 test(gameState): copri i branch residui di nuovoSet/confermaCambi/resetta/startTimeout
Aggiunge casi per team 'guest' in nuovoSet, cambi senza array
`cambi`, resetta con striscia vuota e startTimeout senza startedAt:
gameState.js passa dal 100% al 100% statement ma sale al 99% branch.
2026-07-03 11:59:19 +02:00
davide 5bdd8ad986 style(referto): riprendi il layout del modulo ufficiale FIPAV
Cornice nera, barre teal "SET N" per set, badge A/B per squadre e
box risultato finale in stile tabella, per avvicinare il referto
stampabile all'aspetto del referto cartaceo FIPAV mantenendo solo
i dati che l'app traccia realmente.
2026-07-03 11:31:57 +02:00
davide f6f3dd11d4 feat(striscia): registra cambi e time out per set (#18)
La striscia salva ora storico cambi formazione e time out per ogni
set, con riferimento al punteggio al momento dell'evento anziché a
un timestamp: startTimeout accumula in set.timeouts, confermaCambi
in set.cambi. Il referto elenca entrambi ordinati per punteggio.
2026-07-03 11:16:47 +02:00
davide 7cc3b8bb5e test(e2e): aggiungi flusso end-to-end del time out
Copre il percorso completo controller -> display: apertura modal,
vincolo squadra su Avvia, avvio con "nessun video" (deterministico,
non dipende dal contenuto di spot/), overlay e banner con countdown,
chiusura anticipata e annullamento della modal.
2026-07-03 10:59:26 +02:00
davide b1c69f9517 test(wsMixin): copri riconnessione allo scadere del timer ed errori
Aggiunge i casi non ancora coperti: il setTimeout di scheduleReconnect
riapre davvero la connessione, un costruttore WebSocket che lancia
programma comunque il retry, onerror azzera lo stato, sendWs ritorna
false se send lancia.
2026-07-03 10:59:12 +02:00
davide 8d53178c54 test(controller): copri la modal e il banner del time out
Copre apertura modal con fetch di /spot/list (incluso il fallback di
rete), il vincolo squadra obbligatoria su Avvia, l'invio di
startTimeout/stopTimeout e il banner con countdown durante il time
out attivo.
2026-07-03 10:58:59 +02:00
davide da01149a2a test(display): copri l'overlay time out (video, placeholder, countdown)
Nessun test component copriva ancora l'overlay introdotto con
startTimeout/stopTimeout: placeholder senza video, riproduzione con
src corretto, fallback a fine video, countdown e dissolvenza oltre i
30s. Stub di play/pause/load su HTMLMediaElement, non implementati da
happy-dom.
2026-07-03 10:58:44 +02:00
davide 3ee3578bb1 test(server): copri l'endpoint /spot/list e il serving statico dei video
Aggiunge una cartella spot temporanea ai fixture del routing: elenco
ordinato dei soli .mp4, file serviti da /spot/<file>, [] se la
cartella non esiste.
2026-07-03 10:58:31 +02:00
davide c21e17c9d3 test(e2e): aggiorna le baseline visual regression del display
Le snapshot erano precedenti al restyling del display (0441b0c) e
non riflettevano più l'aspetto attuale.
2026-07-03 10:58:15 +02:00
davide 1bdb07aeef fix(docker): copia src/spot-utils.js nell'immagine di produzione
server.js lo importa per l'endpoint /spot/list: senza questo file
l'immagine Docker attuale non si avvia.
2026-07-03 10:58:05 +02:00
davide f5459f0871 docs: allinea README, CLAUDE.md e guida test al nuovo flusso time out
La documentazione descriveva ancora il vecchio toggleTimeout (video in
loop/sequenza); aggiornata a startTimeout/stopTimeout con scelta
squadra/video, countdown e auto-chiusura server-side a 30s.
2026-07-03 10:57:53 +02:00
davide 8c52f1761e Merge branch 'feat/timeout-filmati-17' (issue #17 - TIMEOUT - FILMATI)
Aggiunge la selezione squadra/video per il time out con modal dedicato,
countdown sincronizzato, timer di auto-chiusura a 30s lato server,
fallback su immagine per video più corti e dissolvenza per quelli più
lunghi, oltre alla relativa copertura di test.
2026-07-03 10:27:51 +02:00
davide 805021dcf5 docs: vieta l'uso di emoji non richieste esplicitamente 2026-07-03 10:26:24 +02:00
davide 1fdf059261 style(controller): rimuovi le emoji da bottoni e banner del time out 2026-07-03 10:26:40 +02:00
davide 58554c3c61 test(timeout): copri startTimeout/stopTimeout e il timer server-side dei 30s
Unit: attivazione con squadra/video/inizio, rifiuto di squadre non valide,
azzeramento completo con stopTimeout. Integration (fake timers): startedAt
iniettato dal server, auto-chiusura a 30s con broadcast e persistenza,
annullamento del timer su stop manuale e riarmo su nuovo avvio.
2026-07-03 10:21:05 +02:00
davide c4599a2722 feat(display): countdown, fallback su immagine e dissolvenza nell'overlay time out
L'overlay ora riproduce il video scelto dal controller mostrando un
countdown 00:30→00:00 calcolato dallo stato condiviso. Se il video finisce
prima dei 30s subentra un'immagine "TIME OUT + nome squadra" fino allo
scadere; se dura più di 30s si applica una dissolvenza CSS negli ultimi
istanti. In caso di riconnessione a metà time out il video viene
riallineato (currentTime) al punto corretto.
2026-07-03 10:14:17 +02:00
davide e179295af3 feat(controller): modal di scelta squadra e video per il time out
Il bottone Time Out apre un modal invece di attivare direttamente
l'overlay: si sceglie la squadra che lo ha richiesto e uno dei video
in spot/ (durata rilevata lato client via <video preload=metadata>).
A time out attivo il bottone diventa "Termina" con countdown, per
poterlo interrompere manualmente prima dei 30s.
2026-07-03 10:14:05 +02:00
davide d11b47c809 feat(timeout): sostituisci toggleTimeout con startTimeout/stopTimeout e auto-chiusura a 30s
Serve una squadra richiedente e un video selezionabile, non più un semplice
flag. Il timer dei 30s è armato lato server (non nel controller) così
sopravvive a refresh/disconnessione/standby del tablet che pilota il gioco.
2026-07-03 10:13:54 +02:00
davide 0441b0c7f5 style(display): allinea il design al controller e rendi più professionale
Il tabellone (display) usava blocchi di colore pieni e testo semplice, molto
diverso dallo stile scuro con card e accenti oro/blu del controller.
Riallinea la palette (#f5c518/#2196f3), la tipografia (Inter, grassetto,
maiuscolo) e i colori di stato connessione/time out, poi aggiunge dettagli da
tabellone reale (linea centrale, badge SET, bagliore sul punteggio, striscia
punti come pannello con bordo/ombra) senza toccare layout o struttura.
2026-07-03 09:43:17 +02:00
davide 7f52d81bbf chore: smetti di tracciare dev-dist (build artifact PWA, già in .gitignore)
I file del service worker generati da vite-plugin-pwa in modalità dev erano
committati nonostante dev-dist sia nel .gitignore. Vengono rigenerati a ogni
`npm run dev`, quindi non vanno versionati.
2026-06-21 18:37:36 +02:00
davide 42aa84450b chore: allinea la versione di package-lock a 2.0.0 2026-06-21 18:35:48 +02:00
davide f65f87722c docs: documenta la modalità time out e dettaglia tutte le funzioni
- README: sezione Funzionalità riscritta per spiegare in modo completo
  display e controller (layout, punteggio/set, config, cambi, servizio,
  voce, time out/spot, reset/referto, regole partita e amichevole).
- CLAUDE.md: descrizione di toggleTimeout, flag `timeout`, cartella `spot/`,
  endpoint `/spot/list` e nota sull'autoplay con audio in kiosk.
2026-06-21 18:35:34 +02:00
davide 68a5f9317e test(timeout): copri toggleTimeout e listSpotVideos
- gameState: stato iniziale `timeout: false` e ciclo toggleTimeout.
- nuovo spotUtils.test.js: cartella assente/vuota, filtro .mp4 ordinato,
  estensione case-insensitive.
2026-06-21 18:35:22 +02:00
davide fb4b14b282 feat(timeout): proietta spot video sul display durante il time out
Aggiunge il flag `timeout` nello stato e l'action `toggleTimeout`: dal
controller il pulsante Time Out commuta la modalità e il display copre il
tabellone con i video .mp4 della cartella `spot/` (non versionata).

- un solo video → loop; più video → sequenza alfabetica con ritorno al primo;
  cartella vuota/assente → overlay "TIME OUT".
- `src/spot-utils.js` (listSpotVideos) elenca i .mp4; endpoint `/spot/list`
  + file statici `/spot/<file>.mp4` serviti sia in prod (server.js) sia in
  dev (vite-plugin-websocket.js).
- il display avvia i video con audio e ripiega su muto se il browser blocca
  l'autoplay con suono.
- il controller resta operativo ma evidenzia la modalità con banner e
  pulsante arancione pulsante, in entrambi i layout (mobile ed esteso).
2026-06-21 18:35:12 +02:00
davide ffba5b368d merge: riparazione e completamento suite di test (Vitest 212, e2e 75)
- ripara la suite Vitest allineandola alla forma attuale dello stato
- migra e completa gli e2e all'architettura a porta singola
- aggiunge copertura per referto, formInizio, persist, wsMixin, routing
- allinea README, CLAUDE.md e tests/README
2026-06-21 18:05:04 +02:00
davide 1ab57654ad docs: allinea README, CLAUDE.md e guida ai test allo stato attuale
- README: referto tra le funzionalità del controller; comando test:ui e aree
  coperte dalle suite
- CLAUDE.md: referto.js (buildRefertoHtml/generaReferto), wsMixin/persist/
  server-utils, createApp/startServer; nota su formInizio e stato derivato
  dalla striscia
- tests/README: conteggi aggiornati (212 Vitest / 75 e2e) e nuove suite
2026-06-21 18:04:16 +02:00
davide c73267d347 test(e2e): visual-regression deterministica e baseline rigenerate
- screenshot stabili: animations disabilitate e mask su .connection-status
- migrata agli helper condivisi
- baseline rigenerate per Chromium, Firefox e Mobile Chrome
2026-06-21 18:04:06 +02:00
davide 5b5dddf2e8 test(e2e): accessibility migrata agli helper ed esclusione color-contrast
- usa openController/openDisplay + resetGame per stato pulito
- esclude la regola color-contrast anche sul controller (colori brand
  giallo/blu valutati separatamente, come già per il display)
2026-06-21 18:03:48 +02:00
davide e6099be0cb test(e2e): spec del referto a PARTITA FINITA
Porta una partita 2/3 a conclusione, intercetta il popup aperto dal bottone
REFERTO e verifica il contenuto (titolo, set, risultato). window.print è
neutralizzato per non bloccare il test.
2026-06-21 18:03:36 +02:00
davide d67f3669d6 test(e2e): helper condivisi e refactor degli spec di gioco
- nuovo tests/e2e/helpers.cjs: openController (viewport portrait per il layout
  mobile), openDisplay, resetGame, addPoints
- resetGame gestisce il dialog di vittoria persistente e reimposta i nomi di
  default (lo stato è condiviso e persistente: serve per il determinismo)
- full-match/game-simulation: gestito il dialog SET VINTO automatico a 25/15
- basic-flow/game-operations: migrati agli helper condivisi
2026-06-21 18:03:29 +02:00
davide eb37f8319f test: ripara la suite Vitest e migra gli e2e all'architettura attuale
La suite era allineata a una vecchia forma dello stato (sp.punt/sp.set/
sp.servHome) e a una vecchia architettura e2e (controller su :3001).
Baseline iniziale: 77/170 test Vitest falliti.

Vitest (ora 212/212 verdi):
- gameState.test.js: riscritto con helper che derivano punteggio/set/
  servizio dalla striscia; aggiunto blocco formInizio
- server-utils.js: getNetworkIPs accetta interfacce iniettabili e
  printServerInfo accetta gli IP iniettabili (deterministico anche su WSL);
  filtro LAN unificato (esclude 127./169.254./172.)
- websocket + stress: punteggio letto via punteggio(striscia)
- ControllerPage/DisplayPage: forzato layout mobile (viewport portrait),
  punteggi impostati via striscia; aggiunto test bottone REFERTO
- nuovi: referto.test.js, persist.test.js (mock fs), wsMixin.test.js,
  integration/server.test.js (routing)

Refactor di supporto:
- referto.js: estratta buildRefertoHtml(state, now) pura; generaReferto
  resta wrapper con window.open/print
- server.js: estratti createApp()/startServer(); avvio solo se entrypoint

e2e (migrazione parziale, NON ancora verificata verde):
- tutti i riferimenti controller :3001 -> :3000/controller
- forzato viewport portrait sul controller prima del goto
- reset helper: chiude il dialog di configurazione che doReset apre
- game-simulation: gestione del dialog SET VINTO automatico a 25
2026-06-21 00:37:35 +02:00
davide ddf68010a4 feat: prototipo generazione referto PDF a fine partita
Aggiunge un bottone "REFERTO" nel modal PARTITA FINITA che apre una
nuova scheda con il referto di gara in HTML e avvia il dialogo di
stampa del browser (Salva come PDF).

Il referto include: punteggio per set, formazione di partenza per ogni
set (salvata automaticamente al primo punto), andamento punto per punto
con colori per squadra, data e ora di generazione.

Nota: funzionalità prototipale — layout, contenuti e dettagli sono
ancora da perfezionare sulla base dell'utilizzo reale.
2026-06-20 23:53:37 +02:00
davide e212fb4654 feat: allinea stile modalità mobile alla dashboard estesa
Le card punteggio mobile adottano lo stesso linguaggio visivo della
modalità estesa: pannello scuro #161618, stripe colorata in cima
(giallo home, blu guest), score come testo colorato senza riquadro.
I bottoni SET diventano a colore pieno; btn-ctrl e btn-danger
uniformati ai token colore della dashboard landscape.
2026-06-20 23:34:48 +02:00
davide c7d0ec6215 feat: modalità estesa controller con dashboard landscape
Aggiunge una seconda modalità dashboard per schermi orizzontali,
rilevata automaticamente dall'orientamento (landscape → estesa,
portrait → mobile) tramite resize/orientationchange listener.

Layout estesa: due pannelli affiancati con stripe colorata in cima
(giallo/blu per identità squadra), score grande come elemento eroe,
diagramma campo in verde scuro, SET button a colori pieni in fondo.
Barra azioni compatta (40px) con tutti i controlli secondari.

Il pannello usa position:fixed ancorato al viewport per impedire
qualsiasi scroll, con @wheel.prevent e @touchmove.prevent.
2026-06-20 23:29:57 +02:00
davide 7e6d51ce58 feat: mostra stato ON/OFF sul bottone Striscia del controller 2026-06-20 22:48:21 +02:00
davide 854669d603 feat: il controller rispetta state.order come il display
Il pannello punteggio ora rispecchia l'ordine delle squadre definito da
state.order: premendo Inverti le card si scambiano di lato anche sul
controller, allineandosi al comportamento visivo del display.
2026-06-20 22:45:27 +02:00
davide d322809682 docs(readme): rimuovi sezione shortcuts tastiera non più implementata 2026-06-20 15:53:04 +02:00
davide 6b37fd299f feat: apri config automaticamente dopo il reset
Dopo aver azzerato la partita il dialog di configurazione si apre
subito, con nomi e modalità correnti e formazioni resettate ai default
— stesso comportamento già presente al termine di ogni set.
2026-06-20 15:51:11 +02:00
davide 87ce0e26b8 feat: modalità amichevole e fix URL remoti in dev (WSL2)
- Aggiunge modalità "Amichevole" in config: i set si vincono normalmente
  ma la partita non termina mai automaticamente (checkVittoriaPartita
  restituisce false), consentendo di giocare set illimitati
- Dev server ora espone su tutte le interfacce (vite --host)
- printServerInfo mostra Display + Controller per dispositivi remoti
- Su WSL2 getNetworkIPs() interroga PowerShell per ottenere gli IP
  Windows reali invece degli IP interni WSL (172.x)
2026-06-20 15:43:03 +02:00
davide 38703116ff refactor: deriva punt/set/servHome dalla striscia, estrai mixin WebSocket
La striscia è ora l'unica source of truth: punt, set vinti e servizio
vengono calcolati via helper puri (punteggio/servizio/setVinti) invece
di essere mantenuti come campi ridondanti nello stato.

Il boilerplate WebSocket (~150 righe identiche in entrambi i componenti)
è estratto in src/wsMixin.js con createWsMixin(role); i componenti
diventano solo logica specifica del loro ruolo.
2026-06-20 15:23:58 +02:00
davide 8e2f3d759d docs(claude): riscrivi CLAUDE.md in italiano con istruzioni operative
Traduzione completa in italiano, aggiunta sezione Deploy con riferimento
al registry Gitea self-hosted, sezione Istruzioni operative con linee
guida per nuove funzionalità e convenzioni di naming. Inclusa anche la
configurazione plugin Claude Code (.claude/settings.json).
2026-06-20 14:57:20 +02:00
davide 6aeeb47f16 chore(docker): usa immagine registry 2.0.0 invece di build locale 2026-05-13 10:21:49 +02:00
davide a094110be3 feat: blocca partita a fine match e mostra dialog dedicato
Aggiunge checkVittoriaPartita per rilevare la vittoria della partita
(2 set in 2/3, 3 set in 3/5). nuovoSet ora registra il set vincente
senza resettare il punteggio quando la partita è finita. Il controller
mostra "PARTITA FINITA" al posto di "SET VINTO" con solo il tasto CHIUDI.
2026-05-13 10:06:43 +02:00
davide 756f78358c Merge branch 'issue#15' 2026-05-13 09:55:16 +02:00
davide fb4177056f chore(docker): usa build locale invece di immagine remota per dev 2026-05-13 09:45:16 +02:00
davide 303c548ab8 refactor(striscia): compatta struttura dati da array a stringa
Sostituisce r: ['home','guest',...] con ris: 'hg...' e i valori
serv 'home'/'guest' con 'h'/'g'. Nessun cambiamento funzionale.
2026-05-13 09:35:24 +02:00
davide 43e49c4c66 chore(docker): sostituisce named volume con bind mount nella root del repository 2026-05-12 18:55:55 +02:00
62 changed files with 4861 additions and 4781 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"enabledPlugins": {
"claude-md-management@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true
}
}
+1
View File
@@ -15,6 +15,7 @@ dist
dist-ssr
dev-dist
.segnapunti
spot
*.local
# Editor directories and files
+31
View File
@@ -7,6 +7,37 @@ e questo progetto aderisce al [Versionamento Semantico](https://semver.org/lang/
---
## [2.1.0] - 2026-07-08
### Aggiunto
- Time out con spot pubblicitari: sul controller una modal fa scegliere squadra e video (o nessuno) tra i file `.mp4` della cartella `spot/`; il display copre il tabellone con un overlay a countdown, riproduce il video scelto (con fallback su testo "TIME OUT <squadra>" se manca o finisce in anticipo) e sfuma nell'ultimo secondo; il server chiude automaticamente il time out dopo 30s anche in caso di disconnessione del controller; video elencati da `/spot/list` e serviti da `/spot/<file>.mp4`
- Storico di cambi e time out registrato per ogni set nella `striscia` (popolato lazy, ordinato per punteggio, usato dal referto)
- Modalità **Amichevole**: i set si vincono normalmente ma la partita non termina mai automaticamente, per giocare set illimitati
- Dialog dedicato a fine partita che blocca ulteriori punti e apre le azioni post-match (referto, chiusura)
- Prototipo di generazione referto di gara stampabile (`src/referto.js`): formazioni di partenza, griglia punti, cambi e time out per set
- Layout esteso "da regia" per il controller in orientamento landscape (tablet), con pannelli squadra affiancati
- Indicatore ON/OFF sul pulsante Striscia del controller; config si riapre automaticamente dopo un reset
- Supporto WSL2 in sviluppo: `getNetworkIPs()` interroga PowerShell per ottenere gli IP reali della rete Windows
- Flusso di sviluppo containerizzato con hot reload: `docker-compose.dev.yml` e nuovo target `dev` nel Dockerfile multi-stage
- Suite di test estesa a oltre il 99% di copertura statement, con nuovi test e2e (time out, referto, accessibilità, visual regression)
### Modificato
- `punteggio`/`setVinti`/`servizio` derivati sempre dalla `striscia` invece di essere memorizzati nello stato
- Struttura dati `striscia` compattata da array a stringa
- Il controller rispetta `state.order` (scambio lato squadre) come il display
- Logica di connessione WebSocket lato client estratta in `src/wsMixin.js` riusabile
- Dev server esposto su tutte le interfacce di rete (`vite --host`)
- Persistenza dati passata da named volume a bind mount (`./.segnapunti`) per accesso diretto ai file dall'host
### Rimosso
- Sezione shortcut da tastiera dal README (funzionalità non più implementata)
- Emoji da codice, UI e commenti (ora vietate salvo richiesta esplicita)
### Corretto
- Immagine Docker di produzione: mancava la copia di `src/spot-utils.js`, causando errori nel serving degli spot video
---
## [2.0.0] - 2026-05-12
### Aggiunto
+80 -31
View File
@@ -1,27 +1,29 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guida per Claude Code su questo repository.
## Project Purpose
## Scopo del progetto
**Segnapunti Anto** is a real-time volleyball scoreboard PWA. An Express/WebSocket server hosts the game state; two separate web interfaces — a **display** (public scoreboard) and a **controller** (operator panel) — stay in sync via WebSocket.
**Segnapunti Anto**: PWA segnapunti pallavolo in tempo reale. Server Express/WebSocket gestisce lo stato di gioco; due interfacce Vue — **display** (tabellone pubblico) e **controller** (pannello operatore) — sincronizzate via WebSocket.
## Commands
## Comandi
```bash
npm run dev # Vite dev server — display: :5173/display, controller: :5173/controller
npm run serve # Build + run production — display: :3000/display, controller: :3000/controller
npm run dev # Vite dev — display: :5173/display, controller: :5173/controller
npm run serve # Build + avvio produzione — display: :3000/display, controller: :3000/controller
npm run test # Vitest watch mode
npm run test:all # All Vitest suites once (unit, integration, component, stress)
npm run test:unit # Unit + integration only
npm run test:component # Vue component tests
npm run test:stress # Load tests (50+ concurrent clients)
npm run test:e2e # Playwright E2E (requires servers to be running via npm run serve)
npm run test:e2e:ui # Playwright with interactive UI
npm run test # Vitest watch
npm run test:all # Tutte le suite in una sola esecuzione
npm run test:unit # Solo unit + integration
npm run test:component # Componenti Vue (happy-dom)
npm run test:stress # Load test (50+ client concorrenti)
npm run test:e2e # Playwright E2E (richiede `npm run serve` attivo)
npm run test:e2e:ui # Playwright con UI interattiva
```
## Architecture
Singolo file di test: `npx vitest run tests/unit/gameState.test.js`
## Architettura
```
Controller (Vue) ──WebSocket──┐
@@ -30,33 +32,80 @@ Display (Vue) ──WebSocket──┤── websocket-handler.js ── gam
│ └── persist.js ── .segnapunti/state.json
```
All game logic lives in `src/gameState.js` as three pure functions:
- `createInitialState()`returns the initial state
- `applyAction(state, action)` — immutable reducer (deep-clones via `structuredClone`)
- `checkVittoria(state)` — volleyball win conditions (25-point sets with 2-point margin, 15-point final set)
**`src/gameState.js`** — logica di gioco, funzioni pure:
- `createInitialState()`stato iniziale
- `applyAction(state, action)` reducer immutabile (deep-clone via `structuredClone`)
- `checkVittoria(state)` — vittoria set: 25 punti/vantaggio 2 (15 nel set decisivo)
- `punteggio` / `setVinti` / `servizio` — derivati dalla `striscia`, NON memorizzati direttamente nello stato
`src/websocket-handler.js` receives actions, validates that the sender is a registered controller (not just a display), calls `applyAction`, broadcasts the new state to all clients, then calls `onStateChange` to persist state to disk.
Punteggio, set e servizio si ricavano sempre dalla `striscia`. Al primo punto di ogni set, `applyAction('incPunt')` salva in `formInizio` uno snapshot della formazione di partenza (usato dal referto); `decPunt` lo rimuove se il set torna a 0-0.
`server.js` (Express) serves both interfaces on port 3000: `/display``dist/index.html`, `/controller``dist/controller.html`. Single WebSocket endpoint at `/ws`.
Ogni voce della `striscia` accumula anche lo storico di cambi/time out del set, popolato lazy (nessun campo se il set non ha eventi), senza timestamp — il "quando" è il punteggio al momento dell'evento, non l'orologio:
- `set.timeouts`: `{ team, punteggio: { home, guest } }`, appeso da `startTimeout` (solo se non già attivo, per non duplicare l'evento su un restart).
- `set.cambi`: `{ team, in, out, punteggio }`, uno per sostituzione applicata da `confermaCambi` (nulla se la validazione fallisce).
In development, `vite-plugin-websocket.js` embeds the WebSocket server inside the Vite dev server with URL rewrite middleware for `/display` and `/controller`.
**`src/websocket-handler.js`** — riceve i messaggi, valida che il mittente sia `controller` (non `display`), chiama `applyAction`, fa broadcast del nuovo stato, poi invoca `onStateChange` per persistere.
## Key Design Constraints
**`src/wsMixin.js`** — mixin Vue lato client: connessione/riconnessione WebSocket (backoff esponenziale), espone i computed `punt`/`servHome`/`set`.
- **All game rules on the server** — clients are pure UI; the server is the source of truth.
- **Role-based WebSocket** — clients register as `display` or `controller`; only controllers may send actions.
- **Immutable state** — `applyAction` never mutates; always returns a new state object.
- **Single-controller intent** — the design targets one active controller and one display; no conflict resolution exists for simultaneous controllers.
- **Persistent state** — `src/persist.js` saves state to `.segnapunti/state.json` after every action and loads it on server startup.
**`src/referto.js`** — genera il referto di gara: `buildRefertoHtml(state, now)` è pura (testabile, ritorna HTML); `generaReferto(state)` apre la finestra (`window.open`) e avvia la stampa. Per ogni set mostra formazione di partenza, griglia punti e (se presenti) `set.timeouts`/`set.cambi` ordinati per punteggio crescente.
## Test Layout
**`src/persist.js`** — carica/salva lo stato su `.segnapunti/state.json`.
**`src/server-utils.js`** — `getNetworkIPs` (sorgente iniettabile), stampa gli URL all'avvio.
**`src/spot-utils.js`** — `listSpotVideos(spotDir)`: elenca i `.mp4` (ordinati) di `spot/`; `[]` se la cartella manca.
| Suite | Path | Runner |
|-------|------|--------|
**Time out / spot video**`startTimeout` (`team`, `video` opzionale) e `stopTimeout` impostano/azzerano `state.timeout`, `timeoutTeam`, `timeoutVideo`, `timeoutStartedAt`. `startedAt` è aggiunto da `websocket-handler.js` (non dal client), così `gameState.js` resta puro/deterministico; lo stesso handler arma un timer server-side di 30s che invia automaticamente `stopTimeout` (sopravvive a refresh/disconnessione del controller). Sul controller, il pulsante Time Out apre una modal per scegliere squadra + un `.mp4` da `spot/` (cartella in root, non versionata) o nessun video; durante il time out mostra il countdown, ripremerlo chiude in anticipo. Sul display, un overlay fullscreen con countdown copre il tabellone: riproduce il video (riallineandosi al punto giusto su riconnessione a metà time out) o mostra "TIME OUT <squadra>" se manca il video o finisce prima dei 30s, con dissolvenza nell'ultimo secondo. Lista esposta da `/spot/list` (JSON), file serviti da `/spot/<file>.mp4` sia in produzione (`server.js`) che in dev (`vite-plugin-websocket.js`). I video partono con audio; se l'autoplay con suono è bloccato si ripiega su muto — per audio reale il browser del display va avviato in kiosk con autoplay consentito (es. Chrome `--autoplay-policy=no-user-gesture-required`).
**`server.js`** — Express: espone `createApp(distDir)` (solo routing, testabile) e `startServer(port)` (HTTP + WebSocket). Serve `/display``dist/index.html`, `/controller``dist/controller.html` sulla porta 3000; endpoint WebSocket unico `/ws`. Si auto-avvia solo se eseguito come entrypoint.
**`vite-plugin-websocket.js`** — in dev, incorpora il server WebSocket nel dev server Vite con rewrite URL per `/display` e `/controller`.
## Vincoli di design
- **Logica solo server-side** — i client sono pura UI; il server è l'unica source of truth.
- **WebSocket role-based** — i client si registrano come `display` o `controller`; solo i controller inviano azioni.
- **Stato immutabile** — `applyAction` non muta mai lo stato, restituisce sempre un nuovo oggetto.
- **Un solo controller/display attivi** — nessuna conflict resolution per controller simultanei.
- **Stato persistente** — `src/persist.js` salva in `.segnapunti/state.json` dopo ogni azione e ricarica all'avvio.
## Layout dei test
| Suite | Percorso | Runner |
|-------|----------|--------|
| Unit | `tests/unit/` | Vitest + Node |
| Integration | `tests/integration/` | Vitest + Node |
| Component | `tests/component/` | Vitest + Happy-DOM |
| Stress | `tests/stress/` | Vitest + Node |
| E2E | `tests/e2e/` | Playwright (Chromium, Firefox, Mobile Chrome) |
E2E tests run serially (`workers: 1`) to avoid WebSocket state races. Run `npm run serve` before `npm run test:e2e`.
E2E gira in serie (`workers: 1`) per evitare race condition sullo stato WebSocket; richiede `npm run serve` attivo prima di `npm run test:e2e`.
Copertura statement/linee Vitest oltre il 99%. Per testare `startServer()` (`server.js`) o i rami platform-dependent di `getNetworkIPs()` (`server-utils.js`) senza effetti collaterali reali (scrittura su `.segnapunti/state.json`, dipendenza dalla piattaforma), mockare il modulo con `vi.mock`/`vi.doMock` (vedi `tests/integration/startServer.test.js`, `tests/unit/server-utils-platform.test.js`) invece di eseguire il codice reale.
## Deploy
`Dockerfile` è multi-stage con 4 target: `deps` (dipendenze condivise), `dev` (hot reload Vite), `builder` (build frontend), `runtime` (immagine di produzione, target di default).
**Produzione**`docker-compose.yml` usa l'immagine già pubblicata sul registry Gitea self-hosted (`santantonio.sytes.net`), niente build locale:
```bash
docker compose up -d # Avvia con immagine dal registry privato (porta 3000)
```
**Sviluppo**`docker-compose.dev.yml` builda il target `dev` in locale e monta il codice sorgente per l'hot reload (Vite su porta 5173); `node_modules` resta un volume anonimo del container per non farsi sovrascrivere dal bind-mount (evita anche problemi di binari nativi arch-specific tra host e container):
```bash
docker compose -f docker-compose.dev.yml up --build
```
Per buildare in locale l'immagine di produzione (es. per testarla prima del push sul registry): `docker build -t segnapunti:local .` (usa il target `runtime` di default).
Volume `./.segnapunti` persiste lo stato tra i riavvii del container.
## Istruzioni operative
- Nuova funzionalità: valutare come si inserisce nel flusso `action → applyAction → broadcast`, poi decidere se serve un nuovo action type o estendere uno esistente.
- Regole di gioco → solo `src/gameState.js`. Protocollo WebSocket → solo `src/websocket-handler.js`.
- Nuovo action type o regola di gioco → aggiungere test in `tests/unit/gameState.test.js`.
- Commenti e nomi di dominio in italiano (es. `servHome`, `striscia`, `nomi`); nomi tecnici standard in inglese (`state`, `action`, `handler`).
- Niente emoji in codice, UI (label, testi bottoni, banner) o commenti, salvo richiesta esplicita dell'utente.
+14 -5
View File
@@ -1,13 +1,22 @@
# Stage 1: build frontend
FROM node:20-alpine AS builder
# Stage 1: dipendenze condivise (build + dev)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: dev — hot reload via Vite, sorgente montato da bind-mount
FROM deps AS dev
ENV NODE_ENV=development
EXPOSE 5173
CMD ["npm", "run", "dev"]
# Stage 3: build frontend
FROM deps AS builder
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:20-alpine
# Stage 4: runtime di produzione
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=3000
@@ -15,7 +24,7 @@ COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY src/gameState.js src/websocket-handler.js src/server-utils.js src/persist.js ./src/
COPY src/gameState.js src/websocket-handler.js src/server-utils.js src/persist.js src/spot-utils.js ./src/
COPY --from=builder /app/dist ./dist
EXPOSE 3000
+119 -40
View File
@@ -1,6 +1,6 @@
# Segnapunti
![Version](https://img.shields.io/badge/versione-2.0.0-blue)
![Version](https://img.shields.io/badge/versione-2.1.0-blue)
![Node](https://img.shields.io/badge/node-%3E%3D18-green)
![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)
![License](https://img.shields.io/badge/licenza-privata-lightgrey)
@@ -14,9 +14,9 @@ Segnapunti digitale in tempo reale per partite di pallavolo. Un server centrale
- [Architettura](#architettura)
- [Guida utente](#guida-utente)
- [Funzionalità](#funzionalità)
- [Shortcuts tastiera](#shortcuts-tastiera)
- [Deploy con Docker](#deploy-con-docker)
- [Sviluppo](#sviluppo)
- [Sviluppo con Docker](#sviluppo-con-docker)
- [Test](#test)
---
@@ -82,55 +82,91 @@ http://<IP-del-server>:3000/controller
## Funzionalità
### Display
Il sistema è composto da due interfacce complementari: il **display** è in sola lettura e mostra al pubblico lo stato della partita; il **controller** è il pannello operatore da cui parte ogni azione. Tutto ciò che l'operatore tocca sul controller si riflette istantaneamente sul display via WebSocket.
- Nomi squadre con indicatore di servizio
- Punteggio del set corrente (grande, leggibile da lontano)
- Contatore set vinti
- Striscia storica punti del set in corso, scorrevole verso destra
- Modalità formazioni: posizioni dei 6 giocatori in campo
- Indicatore connessione WebSocket (scompare quando connesso, rosso lampeggiante se disconnesso)
### Display (tabellone pubblico)
### Controller
Pagina di sola visualizzazione, pensata per uno schermo grande o un proiettore. Mostra:
- **Punti** — `+1` per casa e ospite, con annullamento dell'ultimo punto
- **Dialog set vinto** — appare automaticamente al raggiungimento dei 25 punti (o 15 nel tie-break); permette di confermare il set o annullare l'ultimo punto
- **Formazioni** — configura i numeri di maglia; la rotazione avviene automaticamente al cambio palla
- **Cambi** — dialog `IN → OUT` con validazione
- **Servizio** — cambio manuale (disponibile solo a 0-0)
- **Visualizzazione** — alterna tra punteggio grande e formazioni in campo
- **Striscia** — mostra/nasconde lo storico punti sul display
- **Reset** — azzera la partita (richiede conferma)
- **Nomi squadre** con **indicatore di servizio** (l'icona della palla compare accanto alla squadra che sta servendo).
- **Punteggio del set corrente** in caratteri molto grandi, leggibili da lontano.
- **Contatore dei set vinti** per ciascuna squadra.
- **Striscia storica dei punti** del set in corso: una sequenza scorrevole che ricostruisce l'andamento punto su punto.
- **Modalità formazioni**: in alternativa al punteggio, mostra le posizioni dei 6 giocatori in campo (numeri di maglia disposti come a referto).
- **Ordine di visualizzazione invertibile**: le due squadre possono essere scambiate di lato per rispecchiare l'orientamento reale del campo.
- **Overlay time out / spot**: durante un time out copre il tabellone con un countdown e il video scelto dall'operatore, o con la scritta *TIME OUT* (vedi sotto).
- **Annuncio vocale del punteggio** (sintesi vocale del browser), comandato dal controller.
- **Indicatore di connessione**: scompare quando tutto è connesso, diventa un punto rosso lampeggiante con scritta *Disconnesso* in caso di problemi.
- **Fullscreen automatico** all'apertura sui dispositivi mobili.
### Controller (pannello operatore)
Il controller adatta automaticamente il proprio layout all'orientamento del dispositivo:
- **Verticale (smartphone)** — layout compatto: anteprima punteggio toccabile in alto e griglia di pulsanti sotto.
- **Orizzontale (tablet)** — layout esteso "da regia": due grandi pannelli squadra affiancati con punteggio, campo e tasto set, più una barra di azioni in basso.
#### Gestione del punteggio
- **Punto** — tocca il punteggio/la card di una squadra per assegnarle `+1`. Il sistema gestisce automaticamente il **cambio palla** e la **rotazione** della squadra che riconquista il servizio.
- **Annulla punto** — rimuove l'ultimo punto assegnato, ripristinando anche eventuale rotazione e servizio.
- **Tasti SET** — incrementano manualmente il contatore dei set vinti di una squadra (utile per correzioni); il contatore cicla da 0.
- **Dialog automatico SET VINTO / PARTITA FINITA** — compare da solo al raggiungimento delle condizioni di vittoria (25 punti, o 15 nel set decisivo, con 2 di scarto). Da qui puoi:
- **VAI AL SET SUCCESSIVO** — chiude il set, lo registra come vinto e prepara il set successivo aprendo la configurazione delle formazioni;
- **INDIETRO** — annulla l'ultimo punto se il dialog è comparso per errore;
- a partita conclusa, **REFERTO** per generare il referto e **CHIUDI** per tornare al tabellone.
#### Configurazione partita
Dal pulsante **Config** si apre una finestra che permette di impostare:
- **Nomi delle squadre** (Home e Guest);
- **Modalità partita**: `2/3` (al meglio dei 3 set), `3/5` (al meglio dei 5 set) o `Amichevole` (senza vittoria automatica della partita);
- **Formazioni iniziali**: i numeri di maglia dei 6 giocatori in campo per ciascuna squadra, disposti per zona.
#### Cambi giocatore
Il pulsante **Cambi** apre, dopo aver scelto la squadra, un dialog con righe `IN → OUT`. Le sostituzioni vengono **validate** prima di essere applicate: i numeri devono essere cifre, il giocatore entrante non può essere già in campo, quello uscente deve essere effettivamente in formazione, e nessuno può sostituire sé stesso. Eventuali errori sono segnalati in chiaro.
#### Servizio e visualizzazione
- **Cambio Palla** — assegna manualmente il servizio; disponibile **solo a 0-0** (a inizio set).
- **Inverti** — scambia il lato delle due squadre sul display.
- **Formazioni / Punteggio** — alterna sul display la vista formazioni in campo e la vista punteggio.
- **Striscia ON/OFF** — mostra o nasconde lo storico punti del set sul display.
- **Voce** — fa annunciare al display il punteggio corrente con la sintesi vocale (es. *"15 a 12"*, *"15 pari"*, *"zero a zero"*).
#### Time Out e spot pubblicitari
Il pulsante **Time Out** apre una finestra in cui l'operatore sceglie **quale squadra** ha chiamato il time out (obbligatorio) e **quale video** proiettare sul display, scegliendo tra i file della cartella `spot/` oppure *nessun video* (overlay con la scritta *TIME OUT*). Alla conferma:
- il **display** copre il tabellone con un overlay a tutto schermo: countdown in sovraimpressione e video scelto; se non c'è video, o se il video finisce prima del tempo, compare la scritta *TIME OUT* con il nome della squadra. Nell'ultimo secondo il video sfuma in dissolvenza.
- il **controller** resta pienamente operativo: compare un banner *TIME OUT <squadra>* con il conto alla rovescia e il pulsante Time Out mostra i secondi rimanenti. Ripremendolo si chiude il time out in anticipo.
- il **server** chiude automaticamente il time out dopo **30 secondi**, anche se il controller si disconnette o va in standby.
I video da proiettare vanno messi in una cartella **`spot/`** nella root del progetto (non versionata): vengono elencati nella finestra di scelta in ordine alfabetico (solo file `.mp4`). Se un display si riconnette a metà time out, il video riparte dal punto corretto.
> **Audio:** i video partono con audio, ma il display è in genere uno schermo senza tastiera/mouse e i browser bloccano l'autoplay con suono senza un'interazione dell'utente. In assenza di configurazione il video parte automaticamente **muto** (il video si vede comunque). Per avere l'audio, avvia il browser del display in modalità kiosk consentendo l'autoplay con suono (es. Chrome con `--autoplay-policy=no-user-gesture-required`, oppure impostando un'eccezione del sito per audio/video).
#### Reset e referto
- **Reset** — azzera la partita (punteggi, set e formazioni) previa conferma, e riapre subito la configurazione per impostare la partita successiva.
- **Referto** — a fine partita, dal dialog *PARTITA FINITA*, genera un referto di gara stampabile/PDF con i punteggi per set, le formazioni di partenza e l'andamento punto-punto. *Funzione prototipale.*
### Regole pallavolo integrate
Le condizioni di vittoria del set sono applicate automaticamente dal server (è quello che fa comparire il dialog *SET VINTO*):
| Set | Condizione di vittoria |
|---|---|
| Set 14 (modalità 3/5) o 12 (modalità 2/3) | Primo a **25** con almeno 2 punti di scarto |
| Set decisivo (tie-break) | Primo a **15** con almeno 2 punti di scarto |
---
## Shortcuts tastiera
> Valide sul controller da browser desktop.
| Tasto | Azione |
|---|---|
| `Ctrl + ↑` | Punto casa |
| `Ctrl + ↓` | Annulla ultimo punto |
| `Shift + ↑` | Punto ospite |
| `Ctrl + ←` | Cambia servizio (solo a 0-0) |
| `Ctrl + M` | Apri configurazione |
| `Ctrl + C` | Cambi squadra casa |
| `Shift + C` | Cambi squadra ospite |
| `Ctrl + Z` | Toggle punteggio / formazioni |
| `Ctrl + S` | Annuncio vocale punteggio |
| `Ctrl + B` | Mostra/nascondi barra pulsanti |
| `Ctrl + F` | Fullscreen |
La **partita** è vinta da chi raggiunge 3 set su 5 (modalità `3/5`) o 2 set su 3 (modalità `2/3`). In modalità **Amichevole** non c'è vittoria automatica della partita: si continua a giocare set senza che compaia *PARTITA FINITA*.
---
## Deploy con Docker
### Prima installazione
@@ -139,7 +175,9 @@ http://<IP-del-server>:3000/controller
docker compose up -d
```
Lo stato viene salvato nel volume Docker `segnapunti-state` e sopravvive ai riavvii del container.
Lo stato viene salvato nella cartella `./.segnapunti` montata come volume nel container e sopravvive ai riavvii.
> **Video spot in Docker:** la cartella `spot/` non è inclusa nell'immagine. Per usare i video del time out aggiungi un volume in `docker-compose.yml`: `./spot:/app/spot`.
### Aggiornamento a nuova versione
@@ -149,15 +187,24 @@ docker compose pull && docker compose up -d
### Build e pubblicazione immagine
Il `Dockerfile` è multi-stage (`deps``dev` / `builder``runtime`); senza specificare `--target`, il build produce l'ultimo stage (`runtime`), l'immagine di produzione:
```bash
docker build \
-t santantonio.sytes.net/attilio/segnapunti:2.0.0 \
-t santantonio.sytes.net/attilio/segnapunti:2.1.0 \
-t santantonio.sytes.net/attilio/segnapunti:latest .
docker push santantonio.sytes.net/attilio/segnapunti:2.0.0
docker push santantonio.sytes.net/attilio/segnapunti:2.1.0
docker push santantonio.sytes.net/attilio/segnapunti:latest
```
Per testare in locale l'immagine di produzione senza pubblicarla:
```bash
docker build -t segnapunti:local .
docker run --rm -p 3000:3000 -v ./.segnapunti:/app/.segnapunti segnapunti:local
```
---
## Sviluppo
@@ -193,6 +240,29 @@ Lo stato viene salvato in `.segnapunti/state.json` anche in modalità dev.
---
## Sviluppo con Docker
In alternativa a Node.js installato in locale, `docker-compose.dev.yml` builda il target `dev` del `Dockerfile` e monta il codice sorgente nel container, con hot reload identico a `npm run dev`:
```bash
docker compose -f docker-compose.dev.yml up --build
```
| URL | Interfaccia |
|---|---|
| `http://localhost:5173/display` | Display |
| `http://localhost:5173/controller` | Controller |
Le modifiche ai file sorgente sull'host si riflettono subito nel container (bind-mount); `node_modules` resta invece un volume anonimo del container, per non farsi sovrascrivere dal bind-mount e per evitare incompatibilità tra i binari nativi installati sull'host e quelli richiesti dal container (es. host ARM / container `node:20-alpine` su x86).
Per fermare e rimuovere il container:
```bash
docker compose -f docker-compose.dev.yml down
```
---
## Test
| Comando | Descrizione |
@@ -201,8 +271,17 @@ Lo stato viene salvato in `.segnapunti/state.json` anche in modalità dev.
| `npm run test:component` | Componenti Vue (Happy-DOM) |
| `npm run test:stress` | Load test WebSocket (50+ client) |
| `npm run test:all` | Tutti i test tranne E2E |
| `npm run test:ui` | Vitest con interfaccia grafica |
| `npm run test:e2e` | Playwright — Chromium, Firefox, Mobile Chrome |
Le suite Vitest coprono la logica di gioco (`gameState`, inclusa `formInizio`),
il protocollo e il routing del server (incluso l'avvio effettivo di
`startServer`), la persistenza, il rilevamento di rete/piattaforma
(`server-utils`, inclusi i rami WSL/PowerShell), il client WebSocket
(`wsMixin`, connessione/riconnessione/cleanup), i componenti Vue e la
generazione del referto (inclusi i suoi side-effect di stampa). La copertura
statement complessiva è oltre il 99%.
> I test E2E richiedono il server in esecuzione (`npm run serve`) e i browser Playwright installati:
> ```bash
> npx playwright install chromium firefox
-1
View File
@@ -1 +0,0 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
-92
View File
@@ -1,92 +0,0 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5357ef54'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"revision": null,
"url": "index.html"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
services:
segnapunti:
build:
context: .
target: dev
container_name: segnapunti-dev
ports:
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
+2 -5
View File
@@ -1,12 +1,9 @@
services:
segnapunti:
image: santantonio.sytes.net/attilio/segnapunti:1.0.0
image: santantonio.sytes.net/attilio/segnapunti:2.1.0
container_name: segnapunti
ports:
- "3000:3000"
volumes:
- segnapunti-state:/app/.segnapunti
- ./.segnapunti:/app/.segnapunti
restart: unless-stopped
volumes:
segnapunti-state:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "segnapuntianto",
"version": "0.0.0",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "segnapuntianto",
"version": "0.0.0",
"version": "2.1.0",
"dependencies": {
"express": "^5.2.1",
"vue": "^3.2.47",
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "segnapuntianto",
"private": true,
"version": "2.0.0",
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "vite build",
"serve": "vite build && node server.js",
"test": "vitest",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+29 -5
View File
@@ -1,22 +1,32 @@
import { createServer } from 'http'
import express from 'express'
import { WebSocketServer } from 'ws'
import { fileURLToPath } from 'url'
import { fileURLToPath, pathToFileURL } from 'url'
import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js'
import { loadState, saveState } from './src/persist.js'
import { listSpotVideos } from './src/spot-utils.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const PORT = process.env.PORT || 3000
const distDir = join(__dirname, 'dist')
const DIST_DIR = join(__dirname, 'dist')
const SPOT_DIR = join(__dirname, 'spot')
// Crea l'app Express (asset statici + route display/controller) senza avviare il
// listen né il WebSocket: così il routing è testabile in isolamento.
export function createApp(distDir = DIST_DIR, spotDir = SPOT_DIR) {
const app = express()
app.use(express.static(distDir, { index: false }))
// Video spot del time out: elenco JSON + file statici (range requests gestiti
// da express.static, così il seeking dei video funziona). La route /spot/list
// va prima dello static per non collidere con un eventuale file "list".
app.get('/spot/list', (_req, res) => res.json(listSpotVideos(spotDir)))
app.use('/spot', express.static(spotDir))
app.get(['/', '/display', '/display/*splat'], (_req, res) => {
res.sendFile(join(distDir, 'index.html'))
})
@@ -25,6 +35,12 @@ app.get(['/controller', '/controller/*splat'], (_req, res) => {
res.sendFile(join(distDir, 'controller.html'))
})
return app
}
// Avvia HTTP + WebSocket. Lo stato viene caricato da disco e ripersistito ad ogni azione.
export function startServer(port = process.env.PORT || 3000) {
const app = createApp()
const server = createServer(app)
const wss = new WebSocketServer({ noServer: true })
setupWebSocketHandler(wss, { initialState: loadState(), onStateChange: saveState })
@@ -40,6 +56,14 @@ server.on('upgrade', (request, socket, head) => {
}
})
server.listen(PORT, '0.0.0.0', () => {
printServerInfo(PORT)
server.listen(port, '0.0.0.0', () => {
printServerInfo(port)
})
return server
}
// Avvia solo se eseguito direttamente (`node server.js`), non quando importato nei test.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
startServer()
}
File diff suppressed because it is too large Load Diff
+175 -223
View File
@@ -7,22 +7,22 @@
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.home }}
<span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
<img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.home }}</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ set.home }}</span>
</div>
<div class="hea guest">
<span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
<span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
<img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
{{ state.sp.nomi.guest }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.guest }}</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ set.guest }}</span>
</div>
<span v-if="state.visuForm">
@@ -39,8 +39,8 @@
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt home">{{ state.sp.punt.home }}</div>
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
<div class="col punt home">{{ punt.home }}</div>
<div class="col punt guest">{{ punt.guest }}</div>
</div>
</span>
</span>
@@ -51,22 +51,22 @@
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.guest }}
<span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
<img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.guest }}</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ set.guest }}</span>
</div>
<div class="hea home">
<span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
<span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
<img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
{{ state.sp.nomi.home }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.home }}</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ set.home }}</span>
</div>
<span v-if="state.visuForm">
@@ -83,8 +83,8 @@
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
<div class="col punt home">{{ state.sp.punt.home }}</div>
<div class="col punt guest">{{ punt.guest }}</div>
<div class="col punt home">{{ punt.home }}</div>
</div>
</span>
</span>
@@ -112,93 +112,66 @@
{{ wsConnected ? '' : 'Disconnesso' }}
</div>
</div>
<!-- Overlay time out: proietta il video spot a tutto schermo -->
<div v-if="state.timeout" class="timeout-overlay">
<div class="timeout-countdown">{{ timeoutCountdown }}</div>
<video v-if="state.timeoutVideo && !mostraPlaceholder" ref="spotVideo" class="timeout-video"
:class="{ 'timeout-video--fading': timeoutFading }" playsinline></video>
<div v-else class="timeout-placeholder">
TIME OUT<br />{{ state.sp.nomi[state.timeoutTeam] }}
</div>
</div>
</section>
</template>
<script>
import { createWsMixin } from '../wsMixin.js'
export default {
name: "DisplayPage",
mixins: [createWsMixin('display')],
data() {
return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000, // Ritardo massimo di riconnessione: 30 secondi
state: {
order: true,
visuForm: false,
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: [{ serv: 'home', r: [] }],
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
},
},
mostraPlaceholder: false, // video terminato prima dei 30s: mostra l'immagine "TIME OUT"
spotVideoDuration: null, // durata reale del video in riproduzione, nota dopo il loadedmetadata
nowTick: Date.now(),
timeoutTicker: null,
}
},
mounted() {
this.connectWebSocket()
// Attiva la modalita fullscreen su dispositivi mobili.
if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {}
}
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
// Annulla eventuali tentativi di riconnessione pianificati.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
if (this.state.timeout) this.avviaTimeout()
},
beforeUnmount() {
// Pulisce il timeout di riconnessione.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
// Chiude il WebSocket con il codice di chiusura appropriato.
if (this.ws) {
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Se la connessione e ancora in fase di apertura, chiude direttamente.
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing WebSocket:', err)
}
this.ws = null
}
this.fermaTimeoutTicker()
},
computed: {
timeoutRemaining() {
if (!this.state.timeout || !this.state.timeoutStartedAt) return 30
const trascorsi = (this.nowTick - this.state.timeoutStartedAt) / 1000
return Math.max(0, Math.ceil(30 - trascorsi))
},
timeoutCountdown() {
const r = this.timeoutRemaining
const m = String(Math.floor(r / 60)).padStart(2, '0')
const s = String(r % 60).padStart(2, '0')
return `${m}:${s}`
},
timeoutFading() {
// Video più lungo di 30s: dissolvenza negli ultimi istanti prima dello stop del server.
if (!this.spotVideoDuration || this.spotVideoDuration <= 30) return false
return this.timeoutRemaining <= 1
},
stricciaStrip() {
const currentSet = this.state.sp.striscia.at(-1)
if (!currentSet) return { home: [], guest: [] }
let h = 0, g = 0
const home = [], guest = []
for (const scorer of currentSet.r) {
if (scorer === 'home') { h++; home.push(h); guest.push(' ') }
for (const scorer of currentSet.ris) {
if (scorer === 'h') { h++; home.push(h); guest.push(' ') }
else { g++; guest.push(g); home.push(' ') }
}
return { home, guest }
@@ -214,156 +187,79 @@ export default {
})
}
},
'state.timeout': {
handler(attivo) {
attivo ? this.avviaTimeout() : this.fermaTimeout()
},
},
},
methods: {
avviaTimeoutTicker() {
this.fermaTimeoutTicker()
this.nowTick = Date.now()
this.timeoutTicker = setInterval(() => { this.nowTick = Date.now() }, 200)
},
fermaTimeoutTicker() {
if (this.timeoutTicker) { clearInterval(this.timeoutTicker); this.timeoutTicker = null }
},
async avviaTimeout() {
this.mostraPlaceholder = !this.state.timeoutVideo
this.spotVideoDuration = null
this.avviaTimeoutTicker()
if (!this.state.timeoutVideo) return
await this.$nextTick()
this.caricaSpotCorrente()
},
fermaTimeout() {
this.fermaTimeoutTicker()
const video = this.$refs.spotVideo
if (video) { video.pause(); video.removeAttribute('src'); video.load() }
this.mostraPlaceholder = false
this.spotVideoDuration = null
},
caricaSpotCorrente() {
const video = this.$refs.spotVideo
if (!video || !this.state.timeoutVideo) return
video.src = '/spot/' + encodeURIComponent(this.state.timeoutVideo)
video.onloadedmetadata = () => {
this.spotVideoDuration = video.duration
// Riconnessione a metà time out: riallinea il video al punto corretto.
const trascorsi = this.state.timeoutStartedAt ? (Date.now() - this.state.timeoutStartedAt) / 1000 : 0
if (trascorsi > 0.5 && trascorsi < video.duration) video.currentTime = trascorsi
}
// Video più corto dei 30s: a fine riproduzione mostra l'immagine "TIME OUT" fino allo stop del server.
video.onended = () => { this.mostraPlaceholder = true }
this.playSpot()
},
playSpot() {
const video = this.$refs.spotVideo
if (!video) return
// Prova con audio; se il browser blocca l'autoplay con suono, ripiega su muto.
video.muted = false
const p = video.play()
if (p && typeof p.catch === 'function') {
p.catch(() => { video.muted = true; video.play().catch(() => {}) })
}
},
onWsMessage(msg) {
if (msg.type === 'speak') this.speakOnDisplay(msg.text)
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
},
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
},
connectWebSocket() {
// Evita connessioni simultanee multiple.
if (this.isConnecting) {
console.log('[Display] Already connecting, skipping...')
return
}
// Chiude la connessione precedente, se presente.
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing previous WebSocket:', err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
// Permette di specificare un host WebSocket alternativo via query parameter
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:5173
const params = new URLSearchParams(location.search)
const defaultHost = (
location.hostname === 'localhost' || location.hostname === '::1'
)
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
: location.host
const wsHost = params.get('wsHost') || defaultHost
const wsUrl = `${protocol}//${wsHost}/ws`
console.log('[Display] Connecting to WebSocket:', wsUrl)
try {
this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error('[Display] Failed to create WebSocket:', err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true
this.reconnectAttempts = 0
console.log('[Display] Connected to server')
// Registra il client come display solo con connessione effettivamente aperta.
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'register', role: 'display' }))
} catch (err) {
console.error('[Display] Failed to register:', err)
}
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'state') {
this.state = msg.state
} else if (msg.type === 'speak') {
this.speakOnDisplay(msg.text)
} else if (msg.type === 'error') {
console.error('[Display] Server error:', msg.message)
}
} catch (e) {
console.error('[Display] Error parsing message:', e)
}
}
this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false
console.log('[Display] Disconnected from server', event.code, event.reason)
// Non riconnette durante HMR (codice 1001, "going away")
// ne in caso di chiusura pulita (codice 1000).
if (event.code === 1000 || event.code === 1001) {
console.log('[Display] Clean close, not reconnecting')
return
}
this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('[Display] WebSocket error:', err)
this.isConnecting = false
this.wsConnected = false
}
},
scheduleReconnect() {
// Evita pianificazioni multiple della riconnessione.
if (this.reconnectTimeout) {
return
}
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
)
this.reconnectAttempts++
console.log(`[Display] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
},
speakOnDisplay(text) {
if (typeof text !== 'string' || !text.trim()) {
return
}
if (!('speechSynthesis' in window)) {
console.warn('[Display] speechSynthesis not supported')
return
}
if (typeof text !== 'string' || !text.trim() || !('speechSynthesis' in window)) return
const utterance = new SpeechSynthesisUtterance(text.trim())
const voices = window.speechSynthesis.getVoices()
const preferredVoice = voices.find((v) => v.name === 'Google italiano')
|| voices.find((v) => v.lang && v.lang.toLowerCase().startsWith('it'))
if (preferredVoice) {
utterance.voice = preferredVoice
}
utterance.voice = voices.find(v => v.name === 'Google italiano')
|| voices.find(v => v.lang?.toLowerCase().startsWith('it'))
|| null
utterance.lang = 'it-IT'
window.speechSynthesis.cancel()
window.speechSynthesis.speak(utterance)
}
}
},
},
}
</script>
@@ -371,7 +267,8 @@ export default {
.display-page {
width: 100%;
height: 100vh;
background: #000;
background: #111;
font-family: 'Inter', system-ui, sans-serif;
overflow: hidden;
}
@@ -394,7 +291,7 @@ export default {
}
.connection-status.disconnected {
background: rgba(255, 50, 50, 0.8);
background: #c62828;
color: white;
opacity: 1;
}
@@ -407,11 +304,11 @@ export default {
}
.connected .dot {
background: #4caf50;
background: #2e7d32;
}
.disconnected .dot {
background: #f44336;
background: #ff6b6b;
animation: blink 1s infinite;
}
@@ -431,6 +328,8 @@ export default {
.punt {
font-size: 60vh;
font-weight: 900;
font-variant-numeric: tabular-nums;
flex: 1;
display: flex;
justify-content: center;
@@ -441,4 +340,57 @@ export default {
overflow: hidden;
box-sizing: border-box;
}
.timeout-overlay {
position: fixed;
inset: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
.timeout-video {
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.8s ease-out;
}
.timeout-video--fading {
opacity: 0;
}
.timeout-countdown {
position: absolute;
top: 24px;
left: 50%;
transform: translateX(-50%);
color: #fff;
background: rgba(0, 0, 0, 0.5);
font-size: 4vh;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
padding: 6px 18px;
border-radius: 12px;
z-index: 501;
}
.timeout-placeholder {
color: #ff9800;
font-size: 14vh;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
text-align: center;
line-height: 1.3;
animation: timeout-pulse 1.2s ease-in-out infinite;
}
@keyframes timeout-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
</style>
+105 -39
View File
@@ -1,14 +1,33 @@
export function punteggio(striscia) {
let home = 0, guest = 0
for (const c of striscia.at(-1).ris) c === 'h' ? home++ : guest++
return { home, guest }
}
export function servizio(striscia) {
const set = striscia.at(-1)
return set.ris.length === 0 ? set.serv === 'h' : set.ris.at(-1) === 'h'
}
export function setVinti(striscia) {
return {
home: striscia.filter(s => s.vinc === 'h').length,
guest: striscia.filter(s => s.vinc === 'g').length,
}
}
export function createInitialState() {
return {
order: true,
visuForm: false,
visuStriscia: true,
timeout: false,
timeoutTeam: null,
timeoutVideo: null,
timeoutStartedAt: null,
modalitaPartita: "3/5",
sp: {
striscia: [{ serv: 'home', r: [] }],
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
striscia: [{ serv: 'h', ris: '', vinc: null }],
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
@@ -19,12 +38,9 @@ export function createInitialState() {
}
export function checkVittoria(state) {
const puntHome = state.sp.punt.home
const puntGuest = state.sp.punt.guest
const setHome = state.sp.set.home
const setGuest = state.sp.set.guest
const totSet = setHome + setGuest
const { home: puntHome, guest: puntGuest } = punteggio(state.sp.striscia)
const sv = setVinti(state.sp.striscia)
const totSet = sv.home + sv.guest
const isSetDecisivo = state.modalitaPartita === "2/3" ? totSet >= 2 : totSet >= 4
const punteggioVittoria = isSetDecisivo ? 15 : 25
@@ -33,6 +49,13 @@ export function checkVittoria(state) {
return false
}
export function checkVittoriaPartita(state) {
if (state.modalitaPartita === 'amichevole') return false
const setsToWin = state.modalitaPartita === "2/3" ? 2 : 3
const sv = setVinti(state.sp.striscia)
return sv.home >= setsToWin || sv.guest >= setsToWin
}
export function applyAction(state, action) {
const s = structuredClone(state)
@@ -41,32 +64,38 @@ export function applyAction(state, action) {
const team = action.team
if (checkVittoria(s)) break
const cambioPalla = (team === "home") !== s.sp.servHome
s.sp.punt[team]++
s.sp.striscia.at(-1).r.push(team)
const servHome = servizio(s.sp.striscia)
const cambioPalla = (team === "home") !== servHome
const setCorrente = s.sp.striscia.at(-1)
if (setCorrente.ris === '') {
setCorrente.formInizio = {
home: [...s.sp.form.home],
guest: [...s.sp.form.guest],
}
}
s.sp.striscia.at(-1).ris += team === 'home' ? 'h' : 'g'
if (cambioPalla) {
s.sp.form[team].push(s.sp.form[team].shift())
}
s.sp.servHome = team === "home"
break
}
case "decPunt": {
const currentSet = s.sp.striscia.at(-1)
if (currentSet.r.length === 0) break
if (currentSet.ris.length === 0) break
const lastScorer = currentSet.r[currentSet.r.length - 1]
const prevServer = currentSet.r.length >= 2
? currentSet.r[currentSet.r.length - 2]
const lastScorerShort = currentSet.ris.at(-1)
const prevServerShort = currentSet.ris.length >= 2
? currentSet.ris.at(-2)
: currentSet.serv
const wasCambioPalla = lastScorer !== prevServer
const wasCambioPalla = lastScorerShort !== prevServerShort
currentSet.r.pop()
s.sp.punt[lastScorer]--
s.sp.servHome = prevServer === 'home'
currentSet.ris = currentSet.ris.slice(0, -1)
if (currentSet.ris === '') delete currentSet.formInizio
const lastScorer = lastScorerShort === 'h' ? 'home' : 'guest'
if (wasCambioPalla) {
s.sp.form[lastScorer].unshift(s.sp.form[lastScorer].pop())
@@ -76,10 +105,17 @@ export function applyAction(state, action) {
case "incSet": {
const team = action.team
if (s.sp.set[team] === 2) {
s.sp.set[team] = 0
const sv = setVinti(s.sp.striscia)
const count = sv[team]
const teamShort = team === 'home' ? 'h' : 'g'
if (count >= 2) {
// cicla a 0: rimuove le voci fantasma per questo team
s.sp.striscia = s.sp.striscia.filter(
entry => !(entry._phantom && entry.vinc === teamShort)
)
} else {
s.sp.set[team]++
// inserisce una voce fantasma prima dell'ultimo set (quello in corso)
s.sp.striscia.splice(-1, 0, { serv: teamShort, ris: '', vinc: teamShort, _phantom: true })
}
break
}
@@ -87,11 +123,10 @@ export function applyAction(state, action) {
case "nuovoSet": {
const team = action.team
if (team !== 'home' && team !== 'guest') break
s.sp.set[team]++
s.sp.punt.home = 0
s.sp.punt.guest = 0
s.sp.servHome = team === 'home'
s.sp.striscia.push({ serv: team, r: [] })
if (checkVittoriaPartita(s)) break
s.sp.striscia.at(-1).vinc = team === 'home' ? 'h' : 'g'
if (checkVittoriaPartita(s)) break
s.sp.striscia.push({ serv: team === 'home' ? 'h' : 'g', ris: '', vinc: null })
s.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
@@ -100,24 +135,21 @@ export function applyAction(state, action) {
}
case "cambiaPalla": {
if (s.sp.punt.home === 0 && s.sp.punt.guest === 0) {
s.sp.servHome = !s.sp.servHome
s.sp.striscia.at(-1).serv = s.sp.servHome ? 'home' : 'guest'
const currentSet = s.sp.striscia.at(-1)
if (currentSet.ris.length === 0) {
currentSet.serv = currentSet.serv === 'h' ? 'g' : 'h'
}
break
}
case "resetta": {
s.visuForm = false
s.sp.punt.home = 0
s.sp.punt.guest = 0
s.sp.set.home = 0
s.sp.set.guest = 0
const servIniziale = s.sp.striscia[0]?.serv ?? 'h'
s.sp.striscia = [{ serv: servIniziale, ris: '', vinc: null }]
s.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
s.sp.striscia = [{ serv: s.sp.servHome ? 'home' : 'guest', r: [] }]
break
}
@@ -136,6 +168,31 @@ export function applyAction(state, action) {
break
}
case "startTimeout": {
const team = action.team
if (team !== 'home' && team !== 'guest') break
if (!s.timeout) {
const setCorrente = s.sp.striscia.at(-1)
;(setCorrente.timeouts ??= []).push({
team,
punteggio: punteggio(s.sp.striscia),
})
}
s.timeout = true
s.timeoutTeam = team
s.timeoutVideo = action.video ?? null
s.timeoutStartedAt = action.startedAt ?? null
break
}
case "stopTimeout": {
s.timeout = false
s.timeoutTeam = null
s.timeoutVideo = null
s.timeoutStartedAt = null
break
}
case "setNomi": {
if (action.home !== undefined) s.sp.nomi.home = action.home
if (action.guest !== undefined) s.sp.nomi.guest = action.guest
@@ -159,6 +216,7 @@ export function applyAction(state, action) {
const cambi = action.cambi || []
const form = s.sp.form[team].map((val) => String(val).trim())
const formAggiornata = [...form]
const cambiApplicati = []
let valid = true
for (const cambio of cambi) {
@@ -173,11 +231,19 @@ export function applyAction(state, action) {
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
if (idx !== -1) {
formAggiornata.splice(idx, 1, cin)
cambiApplicati.push({ in: cin, out: cout })
}
}
if (valid) {
s.sp.form[team] = formAggiornata
if (cambiApplicati.length > 0) {
const setCorrente = s.sp.striscia.at(-1)
const puntAttuale = punteggio(s.sp.striscia)
;(setCorrente.cambi ??= []).push(
...cambiApplicati.map(c => ({ team, ...c, punteggio: { ...puntAttuale } }))
)
}
}
break
}
+234
View File
@@ -0,0 +1,234 @@
function vincitoreSet(s) {
if (s.vinc === 'h' || s.vinc === 'g') return s.vinc
let h = 0, g = 0
for (const c of s.ris) c === 'h' ? h++ : g++
if (h > g) return 'h'
if (g > h) return 'g'
return null
}
// Costruisce l'HTML del referto (funzione pura, testabile).
// `now` è iniettabile per rendere deterministica la data nei test.
export function buildRefertoHtml(state, now = new Date()) {
const { sp, modalitaPartita } = state
const { nomi, striscia } = sp
const setReali = striscia.filter(s => !s._phantom)
const setVinti = { home: 0, guest: 0 }
for (const s of setReali) {
const v = vincitoreSet(s)
if (v === 'h') setVinti.home++
else if (v === 'g') setVinti.guest++
}
const dataOra = now.toLocaleString('it-IT', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
const giocatoreHtml = (n) => `<div class="giocatore">${n}</div>`
const formazioneHtml = (formazioneSet) => {
if (!formazioneSet) return '<em style="color:#999;font-size:11px">non disponibile</em>'
return `
<div class="form-row">
<div class="form-team">
<div class="form-team-name form-team-name-h">${nomi.home}</div>
<div class="giocatori">${formazioneSet.home.map(giocatoreHtml).join('')}</div>
</div>
<div class="form-team">
<div class="form-team-name form-team-name-g">${nomi.guest}</div>
<div class="giocatori">${formazioneSet.guest.map(giocatoreHtml).join('')}</div>
</div>
</div>`
}
const nomeTeam = (team) => team === 'home' ? nomi.home : nomi.guest
const eventiHtml = (s) => {
const eventi = [
...(s.timeouts ?? []).map(t => ({ tipo: 'timeout', ...t })),
...(s.cambi ?? []).map(c => ({ tipo: 'cambio', ...c })),
].sort((a, b) => (a.punteggio.home + a.punteggio.guest) - (b.punteggio.home + b.punteggio.guest))
if (eventi.length === 0) return ''
const righe = eventi.map(e => {
const sul = `sul ${e.punteggio.home}-${e.punteggio.guest}`
const testo = e.tipo === 'timeout'
? `Time out <strong>${nomeTeam(e.team)}</strong> ${sul}`
: `Cambio <strong>${nomeTeam(e.team)}</strong>: entra ${e.in} per ${e.out} ${sul}`
return `<li class="evento evento-${e.tipo}">${testo}</li>`
}).join('')
return `
<div class="eventi-set">
<div class="eventi-label">Cambi e time out</div>
<ul class="eventi-lista">${righe}</ul>
</div>`
}
const setsHtml = setReali.map((s, i) => {
let h = 0, g = 0
const punti = []
for (const c of s.ris) {
c === 'h' ? h++ : g++
punti.push({ chi: c, h, g })
}
const vinc = vincitoreSet(s)
const nomeVinc = vinc === 'h' ? nomi.home : vinc === 'g' ? nomi.guest : ''
const etichettaVinc = nomeVinc ? `<span class="set-vinc">vinto da <strong>${nomeVinc}</strong></span>` : ''
const servIcon = '<img src="/serv-nero.png" class="serv-icon" alt="Servizio">'
const servHomeIcon = s.serv === 'h' ? servIcon : ''
const servGuestIcon = s.serv === 'g' ? servIcon : ''
const puntiHtml = punti.map(p =>
`<span class="punto punto-${p.chi}">${p.h}-${p.g}</span>`
).join('')
return `
<div class="set-section">
<div class="set-barra"><span>SET</span><span class="set-numero">${i + 1}</span></div>
<div class="set-corpo">
<div class="set-header">
<span>${servHomeIcon}${nomi.home} <strong>${h}</strong> · <strong>${g}</strong> ${nomi.guest}${servGuestIcon}</span>
${etichettaVinc}
</div>
<div class="form-inizio">
<div class="form-inizio-label">Formazione di partenza</div>
${formazioneHtml(s.formInizio)}
</div>${eventiHtml(s)}
<div class="punti-grid">${puntiHtml || '<em style="color:#999;font-size:11px">Nessun punto registrato</em>'}</div>
</div>
</div>`
}).join('')
const risultatoSetHtml = setReali.map((s, i) => {
let h = 0, g = 0
for (const c of s.ris) c === 'h' ? h++ : g++
const vinc = vincitoreSet(s)
return `<tr>
<td class="ris-set-n">${i + 1}</td>
<td class="ris-set-p ${vinc === 'h' ? 'ris-vinc' : ''}">${h}</td>
<td class="ris-set-p ${vinc === 'g' ? 'ris-vinc' : ''}">${g}</td>
</tr>`
}).join('')
const html = `<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Referto ${nomi.home} vs ${nomi.guest}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root { --teal: #23b8dc; --teal-dark: #0f8faf; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; background: #fff; padding: 20px; font-size: 14px; }
@media print { body { padding: 8px; } }
.foglio { border: 3px solid #111; padding: 14px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 2px solid #111; padding: 10px 16px; margin-bottom: 14px; }
.titolo { font-size: 15px; font-weight: bold; letter-spacing: 2px; text-transform: uppercase; }
.meta { font-size: 11px; color: #333; margin-top: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.squadre-row { display: flex; align-items: center; gap: 14px; }
.badge-ab { display: inline-flex; align-items: center; justify-content: center; height: 22px; padding: 0 8px; border-radius: 11px; border: 2px solid #111; font-weight: bold; font-size: 12px; flex-shrink: 0; }
.nome-sq { font-size: 18px; font-weight: bold; }
.vs { font-size: 12px; color: #666; font-weight: bold; }
.risultato-riepilogo { display: flex; align-items: center; gap: 10px; }
.risultato { font-size: 30px; font-weight: bold; letter-spacing: 3px; }
.modalita-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
.set-section { display: flex; margin-bottom: 10px; border: 2px solid #111; }
.set-barra { flex: 0 0 26px; background: var(--teal); color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border-right: 2px solid #111; }
.set-barra span:first-child { writing-mode: vertical-rl; transform: rotate(180deg); font-weight: bold; font-size: 11px; letter-spacing: 2px; }
.set-numero { font-weight: bold; font-size: 15px; }
.set-corpo { flex: 1; min-width: 0; }
.set-header { background: #eaf7fb; padding: 6px 12px; font-size: 13px; border-bottom: 1px solid #111; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.set-vinc { flex-shrink: 0; }
.serv-icon { width: 12px; height: 12px; vertical-align: middle; margin: 0 3px; }
.punti-grid { display: flex; flex-wrap: wrap; gap: 0; padding: 0; border-top: 1px solid #111; }
.punto { display: inline-flex; align-items: center; justify-content: center; min-width: 34px; padding: 3px 4px; font-size: 11px; font-family: 'Courier New', monospace; white-space: nowrap; border-right: 1px solid #ccc; border-bottom: 1px solid #ccc; }
.punto-h { background: #f5c518; color: #111; }
.punto-g { background: #2196f3; color: #fff; }
.eventi-set { padding: 6px 12px; border-bottom: 1px solid #111; }
.eventi-label { font-size: 10px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #0f8faf; margin-bottom: 4px; }
.eventi-lista { list-style: none; }
.evento { font-size: 12px; padding: 1px 0; }
.form-inizio { padding: 6px 12px; border-bottom: 1px solid #111; background: #fafafa; }
.form-inizio-label { font-size: 10px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #0f8faf; margin-bottom: 6px; }
.form-row { display: flex; gap: 40px; }
.form-team { flex: 1; }
.form-team-name { font-weight: bold; font-size: 12px; margin-bottom: 5px; }
.form-team-name-h { color: #f5c518; }
.form-team-name-g { color: #2196f3; }
.giocatori { display: flex; gap: 5px; flex-wrap: wrap; }
.giocatore { background: #fff; border-radius: 50%; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 11px; border: 2px solid #111; }
.finale { display: flex; gap: 14px; align-items: flex-start; margin-top: 4px; }
.finale-box { border: 2px solid #111; flex: 0 0 220px; }
.finale-box-header { background: var(--teal); color: #fff; text-align: center; font-weight: bold; font-size: 12px; letter-spacing: 1px; text-transform: uppercase; padding: 5px; }
.finale-tabella { width: 100%; border-collapse: collapse; font-size: 12px; }
.finale-tabella th, .finale-tabella td { border: 1px solid #111; padding: 4px 8px; text-align: center; }
.finale-tabella th { background: #eaf7fb; font-size: 10px; text-transform: uppercase; }
.ris-vinc { font-weight: bold; background: #d0e8ff; }
.finale-vince { padding: 8px; text-align: center; font-size: 14px; font-weight: bold; border-top: 2px solid #111; }
</style>
</head>
<body>
<div class="foglio">
<div class="header">
<div>
<div class="titolo">Referto di Gara</div>
<div class="meta">${dataOra} &nbsp;·&nbsp; Modalità: ${modalitaPartita}</div>
</div>
<div class="squadre-row">
<span class="badge-ab">Casa</span>
<span class="nome-sq">${nomi.home}</span>
<span class="vs">vs</span>
<span class="nome-sq">${nomi.guest}</span>
<span class="badge-ab">Ospiti</span>
</div>
</div>
${setsHtml}
<div class="finale">
<div class="finale-box">
<div class="finale-box-header">Risultato finale</div>
<table class="finale-tabella">
<thead><tr><th>Set</th><th>${nomi.home}</th><th>${nomi.guest}</th></tr></thead>
<tbody>${risultatoSetHtml}</tbody>
</table>
<div class="finale-vince">Vince ${setVinti.home >= setVinti.guest ? nomi.home : nomi.guest} &nbsp;${setVinti.home} ${setVinti.guest}</div>
</div>
<div class="risultato-riepilogo">
<div class="risultato">${setVinti.home} ${setVinti.guest}</div>
<div class="modalita-label">set vinti</div>
</div>
</div>
</div>
</body>
</html>`
return html
}
// Apre il referto in una nuova scheda e avvia la stampa (effetto collaterale,
// solo browser). La generazione dell'HTML è delegata a buildRefertoHtml.
export function generaReferto(state) {
const html = buildRefertoHtml(state)
const w = window.open('', '_blank')
w.document.write(html)
w.document.close()
// Aspetta il caricamento delle immagini (icona servizio) prima di stampare,
// altrimenti la stampa parte con risorse ancora in caricamento e appaiono vuote.
w.addEventListener('load', () => w.print())
}
+48 -14
View File
@@ -1,34 +1,68 @@
import { networkInterfaces } from 'os'
import { readFileSync, existsSync } from 'fs'
import { execSync } from 'child_process'
export function getNetworkIPs() {
const nets = networkInterfaces()
const networkIPs = []
function isWSL() {
try {
return existsSync('/proc/sys/kernel/osrelease') &&
readFileSync('/proc/sys/kernel/osrelease', 'utf8').toLowerCase().includes('microsoft')
} catch { return false }
}
for (const name of Object.keys(nets)) {
// Un IPv4 è "pubblicabile" in LAN se non è loopback, link-local o bridge Docker.
function isLanIPv4(address) {
return !!address
&& !address.startsWith('127.')
&& !address.startsWith('169.254.')
&& !address.startsWith('172.')
}
// Estrae gli IP LAN da un oggetto in stile os.networkInterfaces().
// Esportata per poter essere testata in isolamento, senza dipendere dalla piattaforma.
export function collectIPs(nets) {
const out = []
for (const name of Object.keys(nets || {})) {
for (const net of nets[name]) {
if (net.family === 'IPv4' &&
!net.internal &&
!net.address.startsWith('172.17.') &&
!net.address.startsWith('172.18.')) {
networkIPs.push(net.address)
if (net.family === 'IPv4' && !net.internal && isLanIPv4(net.address)) {
out.push(net.address)
}
}
}
return out
}
return networkIPs
// Restituisce gli IP di rete della macchina.
// Passando `nets` (oggetto in stile os.networkInterfaces) si forza il percorso
// deterministico, scavalcando il rilevamento WSL/PowerShell: utile nei test.
export function getNetworkIPs(nets) {
if (nets) return collectIPs(nets)
if (isWSL()) {
try {
const out = execSync(
'powershell.exe -NoProfile -Command "Get-NetIPAddress -AddressFamily IPv4 | Select-Object -ExpandProperty IPAddress"',
{ timeout: 3000 }
)
return out.toString().trim().split('\n')
.map(s => s.trim())
.filter(isLanIPv4)
} catch { return [] }
}
export function printServerInfo(port = 3000) {
const networkIPs = getNetworkIPs()
return collectIPs(networkInterfaces())
}
// `networkIPs` è iniettabile per rendere la stampa testabile in modo deterministico.
export function printServerInfo(port = 3000, networkIPs = getNetworkIPs()) {
console.log(`\nSegnapunti Server`)
console.log(` Display: http://127.0.0.1:${port}/display`)
console.log(` Controller: http://127.0.0.1:${port}/controller`)
if (networkIPs.length > 0) {
console.log(`\n Controller da dispositivi remoti:`)
console.log(`\n Da dispositivi remoti:`)
networkIPs.forEach(ip => {
console.log(` http://${ip}:${port}/controller`)
console.log(` Display: http://${ip}:${port}/display`)
console.log(` Controller: http://${ip}:${port}/controller`)
})
}
+16
View File
@@ -0,0 +1,16 @@
import { readdirSync } from 'fs'
/**
* Elenca i video .mp4 presenti nella cartella spot.
* Ritorna i filename ordinati alfabeticamente (sequenza prevedibile),
* oppure un array vuoto se la cartella non esiste o non è leggibile.
*/
export function listSpotVideos(spotDir) {
try {
return readdirSync(spotDir)
.filter(name => name.toLowerCase().endsWith('.mp4'))
.sort((a, b) => a.localeCompare(b))
} catch {
return []
}
}
+78 -10
View File
@@ -46,11 +46,31 @@ button:focus-visible {
width: 100%;
display: table;
color: #fff;
position: relative;
}
/* Linea centrale che separa i due campi, come un tabellone reale */
.campo::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
background: rgba(255, 255, 255, 0.12);
pointer-events: none;
z-index: 1;
}
.hea {
float: left;
width: 50%;
font-size: xx-large;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 18px 4px 12px;
box-sizing: border-box;
border-bottom: 3px solid currentColor;
opacity: 0.96;
}
.hea span {
/* Bordo di debug: border: 1px solid #f3fb00; */
@@ -70,6 +90,27 @@ button:focus-visible {
align-items: center;
justify-content: center;
vertical-align: middle;
border-radius: 50%;
background: rgba(255, 255, 255, 0.07);
}
.mr3, .ml3 {
display: inline-flex;
align-items: center;
font-size: 3vh;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 4px 16px;
margin-top: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.mr3 {
margin-right: 14px;
}
.ml3 {
margin-left: 14px;
}
.tal {
text-align: left;
@@ -110,10 +151,11 @@ button:focus-visible {
max-width: 50vw;
overflow: hidden;
box-sizing: border-box;
text-shadow: 0 0 40px currentColor;
}
.form {
font-size: 5vh;
border-top: #fff dashed 25px;
border-top: 2px solid rgba(255, 255, 255, 0.18);
padding-top: 50px;
}
.formtit {
@@ -125,14 +167,23 @@ button:focus-visible {
font-size: 20vh;
float: left;
width: 32%;
box-sizing: border-box;
margin: 3px 0;
color: #ddd;
font-weight: 700;
background: rgba(255, 255, 255, 0.07);
border-radius: 10px;
}
.text-bold {
font-weight: 800;
}
.home {
background-color: black;
color: yellow;
background-color: transparent;
color: #f5c518;
}
.guest {
background-color: blue;
color: white
background-color: transparent;
color: #2196f3;
}
.striscia {
position: fixed;
@@ -141,17 +192,28 @@ button:focus-visible {
right: 10px;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 2px;
row-gap: 6px;
align-items: center;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
backdrop-filter: blur(6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 2;
}
.striscia-nome {
white-space: nowrap;
padding-right: 6px;
padding-right: 10px;
font-size: 2.2vh;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.striscia-items {
display: flex;
flex-wrap: nowrap;
overflow: hidden;
gap: 2px;
}
.striscia .item {
width: 25px;
@@ -159,11 +221,17 @@ button:focus-visible {
text-align: center;
font-weight: bold;
flex-shrink: 0;
border-radius: 5px;
border-radius: 6px;
}
.striscia .item:not(.item-vuoto) {
background-color: rgb(206, 247, 3);
color: blue;
background-color: #f5c518;
color: #111;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.striscia-items.guest-striscia .item:not(.item-vuoto) {
background-color: #2196f3;
color: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.campo-config {
+34 -1
View File
@@ -5,6 +5,8 @@ import { createInitialState, applyAction } from './gameState.js'
* @param {WebSocketServer} wss - Istanza del server WebSocket.
* @returns {Object} Oggetto con metodi di gestione dello stato.
*/
const DURATA_TIMEOUT_MS = 30000
export function setupWebSocketHandler(wss, options = {}) {
// Stato globale della partita.
let gameState = options.initialState ?? createInitialState()
@@ -12,6 +14,17 @@ export function setupWebSocketHandler(wss, options = {}) {
// Mappa dei ruoli associati ai client connessi.
const clients = new Map() // ws -> { role: 'display' | 'controller' }
// Timer che chiude automaticamente il time out dopo 30s, indipendentemente
// dal controller (sopravvive a refresh/disconnessione/standby del tablet).
let timeoutTimer = null
function annullaTimerTimeout() {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
}
/**
* Gestisce i messaggi in arrivo dal client
*/
@@ -87,10 +100,16 @@ export function setupWebSocketHandler(wss, options = {}) {
return
}
// Il momento di inizio del time out lo decide il server (non il client),
// cosi' gameState.js resta puro/deterministico e testabile.
const action = msg.action.type === 'startTimeout'
? { ...msg.action, startedAt: Date.now() }
: msg.action
// Applica l'azione allo stato della partita.
const previousState = gameState
try {
gameState = applyAction(gameState, msg.action)
gameState = applyAction(gameState, action)
} catch (err) {
console.error('Error applying action:', err)
sendError(ws, 'Failed to apply action')
@@ -98,6 +117,20 @@ export function setupWebSocketHandler(wss, options = {}) {
return
}
// Qualsiasi azione che tocca il time out invalida il timer di auto-chiusura
// pendente; se il time out e' (ri)partito, ne arma uno nuovo.
if (action.type === 'startTimeout' || action.type === 'stopTimeout') {
annullaTimerTimeout()
}
if (action.type === 'startTimeout' && gameState.timeout) {
timeoutTimer = setTimeout(() => {
timeoutTimer = null
gameState = applyAction(gameState, { type: 'stopTimeout' })
broadcastState()
options.onStateChange?.(gameState)
}, DURATA_TIMEOUT_MS)
}
// Propaga il nuovo stato a tutti i client connessi.
broadcastState()
options.onStateChange?.(gameState)
+146
View File
@@ -0,0 +1,146 @@
import { createInitialState, punteggio, servizio, setVinti } from './gameState.js'
export function createWsMixin(role) {
return {
data() {
return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000,
state: createInitialState(),
}
},
computed: {
punt() { return punteggio(this.state.sp.striscia) },
servHome() { return servizio(this.state.sp.striscia) },
set() { return setVinti(this.state.sp.striscia) },
},
mounted() {
this.connectWebSocket()
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
},
beforeUnmount() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error(`[${role}] Error closing WebSocket:`, err)
}
this.ws = null
}
},
methods: {
connectWebSocket() {
if (this.isConnecting) return
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error(`[${role}] Error closing previous WebSocket:`, err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams(location.search)
const defaultHost = (location.hostname === 'localhost' || location.hostname === '::1')
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
: location.host
const wsUrl = `${protocol}//${params.get('wsHost') || defaultHost}/ws`
try {
this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error(`[${role}] Failed to create WebSocket:`, err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true
this.reconnectAttempts = 0
try {
this.ws?.send(JSON.stringify({ type: 'register', role }))
} catch (err) {
console.error(`[${role}] Failed to register:`, err)
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'state') this.state = msg.state
else this.onWsMessage?.(msg)
} catch (e) {
console.error(`[${role}] Error parsing message:`, e)
}
}
this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false
if (event.code !== 1000 && event.code !== 1001) this.scheduleReconnect()
}
this.ws.onerror = () => {
this.isConnecting = false
this.wsConnected = false
}
},
scheduleReconnect() {
if (this.reconnectTimeout) return
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay)
this.reconnectAttempts++
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
},
sendWs(msg) {
if (!this.wsConnected || this.ws?.readyState !== WebSocket.OPEN) return false
try {
this.ws.send(JSON.stringify(msg))
return true
} catch (err) {
console.error(`[${role}] Failed to send:`, err)
return false
}
},
},
}
}
+47 -5
View File
@@ -90,10 +90,22 @@ Esempi reali nel progetto:
- cambio palla
- vittoria set con regola dei 2 punti di scarto
- reset stato
- `formInizio` (snapshot formazione di partenza del set)
- time out (`startTimeout`/`stopTimeout`: squadra, video, timestamp)
- generazione del referto (`buildRefertoHtml`)
- persistenza stato su disco (`persist.js`, con `fs` mockato)
- utility di rete (`server-utils.js`), incluso il rilevamento piattaforma
(WSL/PowerShell vs. `os.networkInterfaces()`, con `fs`/`child_process`/`os`
mockati per non dipendere dalla macchina che esegue i test)
- elenco video spot (`spot-utils.js`, su cartella temporanea reale)
Quando falliscono:
- quasi sempre c'e un problema nella logica core
> Nota: punteggio, set e servizio NON sono campi dello stato: si ricavano dalla
> `striscia` con `punteggio` / `setVinti` / `servizio`. Nei test si impostano gli
> scenari costruendo la `striscia`, non scrivendo `sp.punt`/`sp.set`.
### 5.2 Integration (`tests/integration`)
Cosa sono:
@@ -107,6 +119,10 @@ Esempi reali nel progetto:
- broadcast stato ai client
- validazione input malformati
- autorizzazioni (solo controller puo inviare certe azioni)
- timer server-side del time out (auto-`stopTimeout` dopo 30s, `startedAt` impostato dal server)
- routing del server Express (`createApp`: rotte display/controller/asset, `/spot/list` e file statici `/spot/`)
- rami di errore del WebSocket handler (`ws.send`/`ws.terminate` che lanciano, `applyAction` che fallisce a metà azione: lo stato precedente va ripristinato senza propagare l'eccezione)
- avvio effettivo di `startServer` (`server.js`): ascolto HTTP, upgrade su `/ws`, rifiuto su altri percorsi, persistenza dopo un'azione — con `persist.js` mockato per non toccare mai `.segnapunti/state.json` reale
Quando falliscono:
- spesso c'e problema nel protocollo messaggi o nei controlli di ruolo
@@ -124,6 +140,11 @@ Esempi reali nel progetto:
- stato connessione
- click bottoni controller
- dialog reset/config/cambi
- modal time out del controller (scelta squadra/video, banner, countdown)
- overlay time out del display (video, placeholder, countdown, dissolvenza)
- bottone REFERTO nel dialog PARTITA FINITA e generazione del referto stampabile (`generaReferto`: `window.open`, scrittura HTML, stampa)
- client WebSocket (`wsMixin`: connessione, riconnessione con backoff, cleanup allo smontaggio, `sendWs`, errori)
- layout esteso/landscape del controller (pannelli squadra, popup automatico SET VINTO con soglia decisiva)
Quando falliscono:
- spesso hai rotto template, computed, metodi o binding
@@ -152,15 +173,20 @@ A cosa servono:
- verificano che Controller e Display funzionino davvero insieme
File principali:
- `helpers.cjs`: helper condivisi (`openController`/`openDisplay`/`resetGame`/`addPoints`)
- `basic-flow.spec.cjs`: flusso base Controller <-> Display
- `game-operations.spec.cjs`: reset, config, toggle, cambi
- `game-simulation.spec.cjs`: simulazione partita
- `full-match.spec.cjs`: scenari partita completi
- `timeout.spec.cjs`: time out end-to-end (modal, overlay sul display, chiusura anticipata)
- `referto.spec.cjs`: generazione referto a PARTITA FINITA
- `accessibility.spec.cjs`: controlli accessibilita con axe
- `visual-regression.spec.cjs`: confronto screenshot con baseline
Nota importante:
Note importanti:
- gli E2E sono configurati in seriale (`workers: 1`) per evitare interferenze sullo stato partita condiviso.
- il controller viene aperto in viewport portrait (layout "mobile") tramite `openController`: in landscape userebbe la dashboard estesa con selettori diversi.
- lo stato è UNICO e persistente sul server: ogni test inizia con `resetGame`, che azzera e reimposta i nomi di default per garantire determinismo.
## 6) Come leggere i risultati
@@ -169,8 +195,8 @@ Nota importante:
Caso OK (verde):
```text
Test Files 6 passed (6)
Tests 159 passed (159)
Test Files 15 passed (15)
Tests 362 passed (362)
```
Significa:
@@ -187,7 +213,7 @@ Caso KO (rosso):
Caso OK:
```text
72 passed
81 passed
```
Caso KO:
@@ -258,7 +284,23 @@ npm run test:e2e -- --update-snapshots
npm run test:e2e
```
## 10) Come aggiungere un nuovo test (consigli pratici)
## 10) Copertura del codice
Non c'è uno script `npm` dedicato, ma si può controllare la copertura in
qualsiasi momento con:
```bash
npx vitest run tests/unit tests/integration tests/component --coverage
```
Il report a schermo mostra percentuale e righe scoperte per file. La suite
attuale copre oltre il 99% di statement/linee; le poche righe rimaste
scoperte sono documentate nei commit dei rispettivi test come codice morto,
difensivo, oppure legato a HMR (`import.meta.hot`) o all'avvio diretto
(`node server.js`) — casi non testabili in modo significativo senza side
effect reali sull'ambiente.
## 11) Come aggiungere un nuovo test (consigli pratici)
- metti i test nel posto giusto:
- `tests/unit` per logica pura
+755 -10
View File
@@ -1,7 +1,11 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, flushPromises } from '@vue/test-utils'
import ControllerPage from '../../src/components/ControllerPage.vue'
import { generaReferto } from '../../src/referto.js'
// Il referto apre una finestra/print: lo mockiamo per testarne solo l'invocazione.
vi.mock('../../src/referto.js', () => ({ generaReferto: vi.fn() }))
// Mock globale WebSocket per jsdom
class MockWebSocket {
@@ -25,6 +29,25 @@ class MockWebSocket {
vi.stubGlobal('WebSocket', MockWebSocket)
// Mock di fetch per /spot/list (usato dalla modal time out).
const fetchMock = vi.fn().mockResolvedValue({ json: async () => [] })
vi.stubGlobal('fetch', fetchMock)
// Forza l'orientamento portrait → il controller usa il layout "mobile"
// (con .team-pts, .btn-ctrl, ecc.) su cui questi test fanno asserzioni.
Object.defineProperty(window, 'innerWidth', { value: 400, writable: true, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, writable: true, configurable: true })
// Imposta il punteggio del set in corso costruendo una ris coerente.
// `serv` ('h'|'g') controlla l'ultimo punto, quindi chi risulta al servizio.
function setScore(wrapper, home, guest, serv = 'h') {
const altro = serv === 'h' ? 'g' : 'h'
const nAltro = serv === 'h' ? guest : home
const nServ = serv === 'h' ? home : guest
// mette per ultimo il carattere del battitore desiderato
wrapper.vm.state.sp.striscia.at(-1).ris = altro.repeat(nAltro) + serv.repeat(nServ)
}
// Helper per creare il componente con stato personalizzato
function mountController(stateOverrides = {}) {
const wrapper = mount(ControllerPage, {
@@ -105,11 +128,19 @@ describe('ControllerPage.vue', () => {
it('dovrebbe essere disabilitato se il punteggio non è 0-0', async () => {
const wrapper = mountController()
wrapper.vm.state.sp.punt.home = 5
setScore(wrapper, 5, 0)
await wrapper.vm.$nextTick()
const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla'))
expect(btn.attributes('disabled')).toBeDefined()
})
it('il click quando abilitato invia cambiaPalla', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla'))
await btn.trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'cambiaPalla' })
})
})
// =============================================
@@ -194,8 +225,7 @@ describe('ControllerPage.vue', () => {
it('dovrebbe generare "N pari" a punteggio uguale', () => {
const wrapper = mountController()
wrapper.vm.state.sp.punt.home = 5
wrapper.vm.state.sp.punt.guest = 5
setScore(wrapper, 5, 5)
wrapper.vm.wsConnected = true
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
wrapper.vm.speak()
@@ -205,9 +235,7 @@ describe('ControllerPage.vue', () => {
it('dovrebbe annunciare prima il punteggio di chi batte (home serve)', () => {
const wrapper = mountController()
wrapper.vm.state.sp.punt.home = 15
wrapper.vm.state.sp.punt.guest = 10
wrapper.vm.state.sp.servHome = true
setScore(wrapper, 15, 10, 'h')
wrapper.vm.wsConnected = true
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
wrapper.vm.speak()
@@ -217,9 +245,7 @@ describe('ControllerPage.vue', () => {
it('dovrebbe annunciare prima il punteggio di chi batte (guest serve)', () => {
const wrapper = mountController()
wrapper.vm.state.sp.punt.home = 10
wrapper.vm.state.sp.punt.guest = 15
wrapper.vm.state.sp.servHome = false
setScore(wrapper, 10, 15, 'g')
wrapper.vm.wsConnected = true
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
wrapper.vm.speak()
@@ -228,6 +254,725 @@ describe('ControllerPage.vue', () => {
})
})
// =============================================
// REFERTO (modal PARTITA FINITA)
// =============================================
describe('Referto', () => {
// Porta il componente allo stato "partita finita" per home in 2/3
function setPartitaFinita(wrapper) {
wrapper.vm.state.modalitaPartita = '2/3'
wrapper.vm.state.sp.striscia = [
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'h', ris: '', vinc: null },
]
wrapper.vm.setVintoTeam = 'home'
wrapper.vm.showSetVinto = true
}
it('mostra il bottone REFERTO quando la partita è finita', async () => {
const wrapper = mountController()
setPartitaFinita(wrapper)
await wrapper.vm.$nextTick()
expect(wrapper.vm.isPartitaFinita).toBe(true)
const btn = wrapper.findAll('.btn-secondary').find(b => b.text().includes('REFERTO'))
expect(btn).toBeDefined()
})
it('il click su REFERTO invoca generaReferto con lo stato', async () => {
const wrapper = mountController()
setPartitaFinita(wrapper)
await wrapper.vm.$nextTick()
const btn = wrapper.findAll('.btn-secondary').find(b => b.text().includes('REFERTO'))
await btn.trigger('click')
expect(generaReferto).toHaveBeenCalledWith(wrapper.vm.state)
})
it('NON mostra il bottone REFERTO a set vinto (partita non finita)', async () => {
const wrapper = mountController()
wrapper.vm.state.modalitaPartita = '3/5'
wrapper.vm.setVintoTeam = 'home'
wrapper.vm.showSetVinto = true
await wrapper.vm.$nextTick()
expect(wrapper.vm.isPartitaFinita).toBe(false)
const btn = wrapper.findAll('.btn-secondary').find(b => b.text().includes('REFERTO'))
expect(btn).toBeUndefined()
})
})
// =============================================
// TIME OUT
// =============================================
describe('Time Out', () => {
it('il click su Time Out apre la modal con l\'elenco dei video', async () => {
fetchMock.mockResolvedValueOnce({ json: async () => ['a.mp4', 'b.mp4'] })
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
expect(wrapper.vm.showTimeoutModal).toBe(true)
expect(wrapper.find('.dialog-timeout').exists()).toBe(true)
expect(wrapper.vm.spotList).toEqual(['a.mp4', 'b.mp4'])
// le opzioni sono i video più "Nessun video"
expect(wrapper.findAll('.timeout-video-option')).toHaveLength(3)
})
it('se /spot/list fallisce la modal si apre comunque senza video', async () => {
fetchMock.mockRejectedValueOnce(new Error('rete assente'))
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
expect(wrapper.vm.showTimeoutModal).toBe(true)
expect(wrapper.vm.spotList).toEqual([])
})
it('Avvia è disabilitato finché non si sceglie la squadra', async () => {
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
const avvia = wrapper.find('.dialog-timeout .btn-confirm')
expect(avvia.attributes('disabled')).toBeDefined()
await wrapper.find('.dialog-timeout .btn-set.home-bg').trigger('click')
expect(avvia.attributes('disabled')).toBeUndefined()
})
it('Avvia invia startTimeout con squadra e video e chiude la modal', async () => {
fetchMock.mockResolvedValueOnce({ json: async () => ['a.mp4'] })
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
await wrapper.find('.dialog-timeout .btn-set.guest-bg').trigger('click')
wrapper.vm.timeoutVideoSel = 'a.mp4'
await wrapper.find('.dialog-timeout .btn-confirm').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'startTimeout', team: 'guest', video: 'a.mp4' })
expect(wrapper.vm.showTimeoutModal).toBe(false)
})
it('durante il time out il bottone invia stopTimeout', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
wrapper.vm.state.timeout = true
wrapper.vm.state.timeoutTeam = 'home'
await wrapper.vm.$nextTick()
await wrapper.find('.btn-timeout').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'stopTimeout' })
})
it('avviaTimeout non invia nulla se nessuna squadra è selezionata', () => {
const wrapper = mountController()
wrapper.vm.timeoutTeamSel = null
const spy = vi.spyOn(wrapper.vm, 'sendAction')
wrapper.vm.avviaTimeout()
expect(spy).not.toHaveBeenCalled()
})
it('il ticker del time out aggiorna nowTick periodicamente', async () => {
const wrapper = mountController()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = Date.now()
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
const before = wrapper.vm.nowTick
vi.advanceTimersByTime(500)
expect(wrapper.vm.nowTick).toBeGreaterThan(before)
})
it('durante il time out mostra banner con squadra e countdown', async () => {
const wrapper = mountController()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = Date.now() - 10000
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
const banner = wrapper.find('.timeout-banner')
expect(banner.exists()).toBe(true)
expect(banner.text()).toContain('TIME OUT')
expect(banner.text()).toContain('Antoniana')
expect(banner.text()).toContain('00:20')
// anche il bottone mostra il countdown
expect(wrapper.find('.btn-timeout').text()).toBe('00:20')
})
})
// =============================================
// MODALITÀ ESTESA (landscape)
// =============================================
describe('Modalità estesa', () => {
it('con isLandscape=true mostra il layout esteso con i pannelli squadra', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
expect(wrapper.find('.e-dash').exists()).toBe(true)
expect(wrapper.findAll('.e-panel')).toHaveLength(2)
// in modalità estesa non compare il layout mobile
expect(wrapper.find('.score-preview').exists()).toBe(false)
})
it('il resize della finestra aggiorna isLandscape', async () => {
const wrapper = mountController()
expect(wrapper.vm.isLandscape).toBe(false)
Object.defineProperty(window, 'innerWidth', { value: 800, writable: true, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 400, writable: true, configurable: true })
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
expect(wrapper.vm.isLandscape).toBe(true)
// ripristina le dimensioni portrait usate dagli altri test
Object.defineProperty(window, 'innerWidth', { value: 400, writable: true, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, writable: true, configurable: true })
})
it('il click sul punteggio in modalità estesa invia incPunt', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.findAll('.e-panel__score')[0].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'home' })
})
it('smontare il componente rimuove i listener di resize e ferma il ticker del time out', async () => {
const wrapper = mountController()
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
expect(vi.getTimerCount()).toBeGreaterThan(0)
const removeSpy = vi.spyOn(window, 'removeEventListener')
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
expect(removeSpy).toHaveBeenCalledWith('orientationchange', expect.any(Function))
})
})
// =============================================
// SET VINTO AUTOMATICO (watch squadraVincente)
// =============================================
describe('Set vinto automatico', () => {
it('apre la modal SET VINTO quando il punteggio raggiunge la soglia', async () => {
const wrapper = mountController()
setScore(wrapper, 25, 10)
await wrapper.vm.$nextTick()
expect(wrapper.vm.showSetVinto).toBe(true)
expect(wrapper.vm.setVintoTeam).toBe('home')
expect(wrapper.find('.dialog-title').text()).toBe('SET VINTO')
})
it('non riapre la modal se è già aperta', async () => {
const wrapper = mountController()
setScore(wrapper, 25, 10)
await wrapper.vm.$nextTick()
wrapper.vm.setVintoTeam = 'guest'
setScore(wrapper, 25, 24)
await wrapper.vm.$nextTick()
// il watch non deve sovrascrivere setVintoTeam perché showSetVinto è già true
expect(wrapper.vm.setVintoTeam).toBe('guest')
})
it('INDIETRO annulla l\'ultimo punto e chiude la modal', async () => {
const wrapper = mountController()
setScore(wrapper, 25, 10)
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.btn-cancel').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'decPunt' })
expect(wrapper.vm.showSetVinto).toBe(false)
})
it('VAI AL SET SUCCESSIVO invia nuovoSet e apre la configurazione', async () => {
const wrapper = mountController()
setScore(wrapper, 25, 10)
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const btn = wrapper.findAll('.btn-confirm').find(b => b.text().includes('SET SUCCESSIVO'))
await btn.trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'nuovoSet', team: 'home' })
expect(wrapper.vm.showSetVinto).toBe(false)
expect(wrapper.vm.showConfig).toBe(true)
})
})
// =============================================
// CONFIGURAZIONE
// =============================================
describe('Configurazione', () => {
it('apre la modal di configurazione precompilata con lo stato corrente', async () => {
const wrapper = mountController()
await wrapper.find('.btn-ctrl:not(.btn-timeout)').exists()
const btn = wrapper.findAll('.btn-ctrl').find(b => b.text() === 'Config')
await btn.trigger('click')
expect(wrapper.vm.showConfig).toBe(true)
expect(wrapper.vm.configData.nomeHome).toBe('Antoniana')
expect(wrapper.vm.configData.nomeGuest).toBe('Guest')
expect(wrapper.vm.configData.modalita).toBe('3/5')
})
it('OK invia le action di configurazione e chiude la modal', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
wrapper.vm.configData = {
nomeHome: 'Casa', nomeGuest: 'Ospiti', modalita: '2/3',
formHome: ['1', '2', '3', '4', '5', '6'],
formGuest: ['1', '2', '3', '4', '5', '6'],
}
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.dialog-config .btn-confirm').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'setNomi', home: 'Casa', guest: 'Ospiti' })
expect(spy).toHaveBeenCalledWith({ type: 'setModalita', modalita: '2/3' })
expect(spy).toHaveBeenCalledWith({ type: 'setFormazione', team: 'home', form: ['1', '2', '3', '4', '5', '6'] })
expect(spy).toHaveBeenCalledWith({ type: 'setFormazione', team: 'guest', form: ['1', '2', '3', '4', '5', '6'] })
expect(wrapper.vm.showConfig).toBe(false)
})
it('Annulla chiude la modal di configurazione senza inviare nulla', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.dialog-config .btn-cancel').trigger('click')
expect(spy).not.toHaveBeenCalled()
expect(wrapper.vm.showConfig).toBe(false)
})
it('scegliendo la modalità 2/3 il bottone diventa attivo', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const btn23 = wrapper.findAll('.btn-mode').find(b => b.text() === '2/3')
await btn23.trigger('click')
expect(wrapper.vm.configData.modalita).toBe('2/3')
expect(btn23.classes()).toContain('active')
})
})
// =============================================
// GESTIONE CAMBI GIOCATORE
// =============================================
describe('Gestione cambi', () => {
it('il click su Cambi apre la selezione squadra, poi la modal cambio per la squadra scelta', async () => {
const wrapper = mountController()
const btnCambi = wrapper.findAll('.btn-ctrl').find(b => b.text() === 'Cambi')
await btnCambi.trigger('click')
expect(wrapper.vm.showCambiTeam).toBe(true)
await wrapper.find('.dialog-title').exists()
const btnHome = wrapper.find('.overlay .btn-set.home-bg')
await btnHome.trigger('click')
expect(wrapper.vm.showCambiTeam).toBe(false)
expect(wrapper.vm.showCambi).toBe(true)
expect(wrapper.vm.cambiTeam).toBe('home')
})
it('Annulla chiude la modal cambi e azzera i campi', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: '10', out: '1' }, { in: '', out: '' }]
await wrapper.vm.$nextTick()
await wrapper.find('.dialog .btn-cancel').trigger('click')
expect(wrapper.vm.showCambi).toBe(false)
expect(wrapper.vm.cambiData).toEqual([{ in: '', out: '' }, { in: '', out: '' }])
})
it('CONFERMA con un cambio valido invia confermaCambi e chiude la modal', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: '10', out: '1' }, { in: '', out: '' }]
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.dialog .btn-confirm').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'confermaCambi', team: 'home', cambi: [{ in: '10', out: '1' }] })
expect(wrapper.vm.showCambi).toBe(false)
})
it('segnala errore se il numero del giocatore non è formato da sole cifre', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: 'ab', out: '1' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(wrapper.vm.cambiError).toBe('I numeri dei giocatori devono essere cifre')
})
it('segnala errore se il giocatore entrante coincide con l\'uscente', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: '1', out: '1' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(wrapper.vm.cambiError).toBe('Il giocatore 1 non può sostituire sé stesso')
})
it('segnala errore se il giocatore entrante è già in formazione', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
// "2" è già presente nella formazione di default ["1".."6"]
wrapper.vm.cambiData = [{ in: '2', out: '1' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(wrapper.vm.cambiError).toBe('Il giocatore 2 è già in formazione')
})
it('segnala errore se il giocatore uscente non è in formazione', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: '10', out: '9' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(wrapper.vm.cambiError).toBe('Il giocatore 9 non è in formazione')
})
it('confermaCambi non fa nulla se cambiValid è false', () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
const spy = vi.spyOn(wrapper.vm, 'sendAction')
wrapper.vm.cambiData = [{ in: '', out: '' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(spy).not.toHaveBeenCalled()
})
})
// =============================================
// GESTIONE ERRORI WEBSOCKET
// =============================================
describe('Gestione errori WebSocket', () => {
it('onWsMessage con type error logga il messaggio', () => {
const wrapper = mountController()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper.vm.onWsMessage({ type: 'error', message: 'boom' })
expect(spy).toHaveBeenCalledWith('[Controller] Error:', 'boom')
spy.mockRestore()
})
it('sendAction senza type non invia nulla', () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendWs')
wrapper.vm.sendAction({})
expect(spy).not.toHaveBeenCalled()
})
it('sendAction mostra un feedback di errore se non connesso', () => {
const wrapper = mountController()
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper.vm.wsConnected = false
wrapper.vm.sendAction({ type: 'incPunt', team: 'home' })
expect(errSpy).toHaveBeenCalledWith('[Controller] Error:', 'Non connesso al server')
errSpy.mockRestore()
})
})
// =============================================
// DURATA VIDEO SPOT
// =============================================
describe('Durata video spot', () => {
it('formatDurata mostra i secondi arrotondati o un placeholder', () => {
const wrapper = mountController()
expect(wrapper.vm.formatDurata(12.4)).toBe('12s')
expect(wrapper.vm.formatDurata(undefined)).toBe('…')
expect(wrapper.vm.formatDurata(NaN)).toBe('…')
})
it('probeDurata popola spotDurations quando i metadata del video sono pronti', () => {
const wrapper = mountController()
const videoEls = []
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
const el = originalCreateElement(tag)
if (tag === 'video') videoEls.push(el)
return el
})
wrapper.vm.probeDurata('a.mp4')
const videoEl = videoEls[0]
Object.defineProperty(videoEl, 'duration', { value: 42, configurable: true })
videoEl.onloadedmetadata()
expect(wrapper.vm.spotDurations['a.mp4']).toBe(42)
document.createElement.mockRestore()
})
})
// =============================================
// PULSANTI MOBILE (click reali sui bottoni)
// =============================================
describe('Pulsanti layout mobile', () => {
it('ANNULLA PUNTO invia decPunt', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.btn-undo').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'decPunt' })
})
it('i due bottoni SET inviano incSet per la rispettiva squadra', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const setBtns = wrapper.findAll('.action-row .btn-set')
await setBtns[0].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'home' })
await setBtns[1].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'guest' })
})
it('Formazioni/Striscia/Inverti/Voce inviano le rispettive action', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const byLabel = (label) => wrapper.findAll('.btn-ctrl').find(b => b.text().includes(label))
await byLabel('Formazioni').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleFormazione' })
await byLabel('Striscia').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleStriscia' })
await byLabel('Inverti').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleOrder' })
const wsSendSpy = vi.spyOn(wrapper.vm, 'sendWs')
wrapper.vm.wsConnected = true
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
await byLabel('Voce').trigger('click')
expect(wsSendSpy).toHaveBeenCalled()
})
it('con order=false lo score-preview mostra prima guest e poi home', async () => {
const wrapper = mountController()
wrapper.vm.state.order = false
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const scores = wrapper.findAll('.team-score')
expect(scores[0].classes()).toContain('guest-bg')
expect(scores[1].classes()).toContain('home-bg')
await scores[0].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'guest' })
})
})
// =============================================
// MODALITÀ ESTESA: interazioni aggiuntive
// =============================================
describe('Modalità estesa: interazioni', () => {
it('il click sul punteggio e sul bottone SET della seconda squadra funzionano', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.findAll('.e-panel__score')[1].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'guest' })
await wrapper.findAll('.e-panel__setbtn')[0].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'home' })
await wrapper.findAll('.e-panel__setbtn')[1].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'guest' })
})
it('la barra e-actions invia le action corrispondenti ai bottoni', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const byLabel = (label) => wrapper.findAll('.e-act').find(b => b.text().includes(label))
await byLabel('Annulla').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'decPunt' })
await byLabel('Cambio Palla').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'cambiaPalla' })
await byLabel('Inverti').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleOrder' })
await byLabel('Striscia').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleStriscia' })
expect(wrapper.vm.showCambiTeam).toBe(false)
const btnCambi = wrapper.findAll('.e-act').find(b => b.text() === 'Cambi')
await btnCambi.trigger('click')
expect(wrapper.vm.showCambiTeam).toBe(true)
wrapper.vm.showCambiTeam = false
await byLabel('Config').trigger('click')
expect(wrapper.vm.showConfig).toBe(true)
wrapper.vm.showConfig = false
await wrapper.find('.e-act--danger').trigger('click')
expect(wrapper.vm.confirmReset).toBe(true)
})
it('il bottone Voce e il bottone Time Out della barra estesa funzionano', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
wrapper.vm.wsConnected = true
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
const wsSendSpy = vi.spyOn(wrapper.vm, 'sendWs')
const btnVoce = wrapper.findAll('.e-act').find(b => b.text() === 'Voce')
await btnVoce.trigger('click')
expect(wsSendSpy).toHaveBeenCalled()
const btnTimeout = wrapper.find('.e-act.btn-timeout')
await btnTimeout.trigger('click')
expect(wrapper.vm.showTimeoutModal).toBe(true)
wrapper.vm.showTimeoutModal = false
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await btnTimeout.trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'stopTimeout' })
})
})
// =============================================
// CHIUSURA DIALOG CLICCANDO FUORI (overlay self)
// =============================================
describe('Chiusura dialog cliccando fuori', () => {
it('conferma reset si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
wrapper.vm.confirmReset = true
await wrapper.vm.$nextTick()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.confirmReset).toBe(false)
})
it('configurazione si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.showConfig).toBe(false)
})
it('scelta squadra cambi si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
wrapper.vm.showCambiTeam = true
await wrapper.vm.$nextTick()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.showCambiTeam).toBe(false)
})
it('la modal cambi si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
await wrapper.vm.$nextTick()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.showCambi).toBe(false)
})
it('la modal time out si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.showTimeoutModal).toBe(false)
})
})
// =============================================
// DIALOG PARTITA FINITA / CONFIGURAZIONE: dettagli
// =============================================
describe('Dettagli dialog', () => {
it('CHIUDI chiude il dialog partita finita senza inviare action', async () => {
const wrapper = mountController()
wrapper.vm.state.modalitaPartita = '2/3'
wrapper.vm.state.sp.striscia = [
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'h', ris: '', vinc: null },
]
wrapper.vm.setVintoTeam = 'home'
wrapper.vm.showSetVinto = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const chiudi = wrapper.findAll('.btn-confirm').find(b => b.text() === 'CHIUDI')
await chiudi.trigger('click')
expect(spy).not.toHaveBeenCalled()
expect(wrapper.vm.showSetVinto).toBe(false)
})
it('isPartitaFinita è false in modalità amichevole anche a set vinto', () => {
const wrapper = mountController()
wrapper.vm.state.modalitaPartita = 'amichevole'
wrapper.vm.setVintoTeam = 'home'
expect(wrapper.vm.isPartitaFinita).toBe(false)
})
it('la vittoria della squadra guest apre comunque la modal SET VINTO', async () => {
const wrapper = mountController()
setScore(wrapper, 10, 25, 'g')
await wrapper.vm.$nextTick()
expect(wrapper.vm.setVintoTeam).toBe('guest')
})
it('nel set decisivo la soglia di vittoria scende a 15 punti', async () => {
const wrapper = mountController()
// 4 set già assegnati (2 a testa): il quinto è il set decisivo (3/5)
wrapper.vm.state.sp.striscia = [
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'h', ris: '', vinc: 'g' },
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'h', ris: '', vinc: 'g' },
{ serv: 'h', ris: '', vinc: null },
]
setScore(wrapper, 15, 10)
await wrapper.vm.$nextTick()
expect(wrapper.vm.setVintoTeam).toBe('home')
})
it('i bottoni modalità 3/5 e amichevole aggiornano configData', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const btn35 = wrapper.findAll('.btn-mode').find(b => b.text() === '3/5')
await btn35.trigger('click')
expect(wrapper.vm.configData.modalita).toBe('3/5')
const btnAmichevole = wrapper.findAll('.btn-mode').find(b => b.text() === 'Amichevole')
await btnAmichevole.trigger('click')
expect(wrapper.vm.configData.modalita).toBe('amichevole')
expect(btnAmichevole.classes()).toContain('active')
})
it('i campi di testo di configurazione aggiornano configData tramite v-model', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const inputs = wrapper.findAll('.dialog-config .input-field')
await inputs[0].setValue('Casa Nuova')
await inputs[1].setValue('Ospiti Nuovi')
expect(wrapper.vm.configData.nomeHome).toBe('Casa Nuova')
expect(wrapper.vm.configData.nomeGuest).toBe('Ospiti Nuovi')
})
it('i campi numerici della formazione aggiornano configData tramite v-model', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const numInputs = wrapper.findAll('.dialog-config .input-num')
// 6 campi per home + 6 per guest: li valorizziamo tutti per coprire ogni binding v-model
for (let i = 0; i < numInputs.length; i++) {
await numInputs[i].setValue(String(i))
}
// il DOM ordina le celle come 3,2,1 / 4,5,0: verifichiamo l'insieme dei valori digitati
expect(new Set(wrapper.vm.configData.formHome)).toEqual(new Set(['0', '1', '2', '3', '4', '5']))
expect(new Set(wrapper.vm.configData.formGuest)).toEqual(new Set(['6', '7', '8', '9', '10', '11']))
})
it('la selezione squadra guest per i cambi apre la modal con cambiTeam=guest', async () => {
const wrapper = mountController()
wrapper.vm.showCambiTeam = true
await wrapper.vm.$nextTick()
await wrapper.find('.overlay .btn-set.guest-bg').trigger('click')
expect(wrapper.vm.cambiTeam).toBe('guest')
expect(wrapper.vm.showCambi).toBe(true)
})
it('i campi IN/OUT della modal cambi aggiornano cambiData tramite v-model', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
await wrapper.vm.$nextTick()
const inField = wrapper.find('.cambi-in-field')
const outField = wrapper.find('.cambi-out-field')
await inField.setValue('10')
await outField.setValue('1')
expect(wrapper.vm.cambiData[0]).toEqual({ in: '10', out: '1' })
})
it('nella modal time out si può selezionare un video dall\'elenco tramite radio', async () => {
fetchMock.mockResolvedValueOnce({ json: async () => ['a.mp4'] })
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
const radios = wrapper.findAll('input[type=radio]')
await radios[0].setValue(true)
expect(wrapper.vm.timeoutVideoSel).toBe('a.mp4')
await radios[1].setValue(true)
expect(wrapper.vm.timeoutVideoSel).toBe(null)
})
it('Annulla nella modal time out la chiude senza avviarlo', async () => {
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.dialog-timeout .btn-cancel').trigger('click')
expect(spy).not.toHaveBeenCalled()
expect(wrapper.vm.showTimeoutModal).toBe(false)
})
})
// =============================================
// BARRA CONNESSIONE
// =============================================
+343 -2
View File
@@ -31,6 +31,21 @@ vi.stubGlobal('speechSynthesis', {
getVoices: () => []
})
// happy-dom non implementa la Web Speech API: stub minimo dell'utterance.
vi.stubGlobal('SpeechSynthesisUtterance', class {
constructor(text) {
this.text = text
this.voice = null
this.lang = ''
}
})
// happy-dom non implementa la riproduzione media: stub di play/pause/load
// così l'overlay time out può montare il <video> senza errori.
Object.defineProperty(HTMLMediaElement.prototype, 'play', { value: vi.fn().mockResolvedValue(undefined), writable: true })
Object.defineProperty(HTMLMediaElement.prototype, 'pause', { value: vi.fn(), writable: true })
Object.defineProperty(HTMLMediaElement.prototype, 'load', { value: vi.fn(), writable: true })
function mountDisplay() {
return mount(DisplayPage, {
global: {
@@ -75,8 +90,8 @@ describe('DisplayPage.vue', () => {
it('dovrebbe aggiornare il punteggio quando lo stato cambia', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.sp.punt.home = 15
wrapper.vm.state.sp.punt.guest = 12
// il punteggio si ricava dalla striscia: 15 punti home + 12 guest
wrapper.vm.state.sp.striscia.at(-1).ris = 'h'.repeat(15) + 'g'.repeat(12)
await wrapper.vm.$nextTick()
const punti = wrapper.findAll('.punt')
expect(punti[0].text()).toBe('15')
@@ -175,6 +190,332 @@ describe('DisplayPage.vue', () => {
})
})
// =============================================
// OVERLAY TIME OUT
// =============================================
describe('Overlay time out', () => {
// Attiva il time out impostando lo stato come farebbe il server.
async function attivaTimeout(wrapper, { team = 'home', video = null, startedAt = Date.now() } = {}) {
wrapper.vm.state.timeoutTeam = team
wrapper.vm.state.timeoutVideo = video
wrapper.vm.state.timeoutStartedAt = startedAt
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
}
it('non mostra l\'overlay quando il time out non è attivo', () => {
const wrapper = mountDisplay()
expect(wrapper.find('.timeout-overlay').exists()).toBe(false)
})
it('senza video mostra il placeholder con il nome della squadra', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { team: 'home', video: null })
expect(wrapper.find('.timeout-overlay').exists()).toBe(true)
const placeholder = wrapper.find('.timeout-placeholder')
expect(placeholder.text()).toContain('TIME OUT')
expect(placeholder.text()).toContain('Antoniana')
expect(wrapper.find('.timeout-video').exists()).toBe(false)
})
it('con un video lo riproduce nell\'overlay', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { team: 'guest', video: 'a.mp4' })
const video = wrapper.find('.timeout-video')
expect(video.exists()).toBe(true)
expect(video.element.src).toContain('/spot/a.mp4')
expect(wrapper.find('.timeout-placeholder').exists()).toBe(false)
})
it('a fine video mostra il placeholder fino allo stop del server', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4' })
const video = wrapper.find('.timeout-video')
video.element.onended()
await wrapper.vm.$nextTick()
expect(wrapper.find('.timeout-video').exists()).toBe(false)
expect(wrapper.find('.timeout-placeholder').exists()).toBe(true)
})
it('il countdown parte da timeoutStartedAt', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { startedAt: Date.now() - 5000 })
expect(wrapper.vm.timeoutRemaining).toBe(25)
expect(wrapper.find('.timeout-countdown').text()).toBe('00:25')
})
it('timeoutFading è true solo con video oltre i 30s negli ultimi istanti', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4', startedAt: Date.now() - 29500 })
// video corto: mai dissolvenza
wrapper.vm.spotVideoDuration = 20
expect(wrapper.vm.timeoutFading).toBe(false)
// video più lungo dei 30s: dissolvenza nell'ultimo secondo
wrapper.vm.spotVideoDuration = 45
expect(wrapper.vm.timeoutRemaining).toBe(1)
expect(wrapper.vm.timeoutFading).toBe(true)
})
it('alla fine del time out l\'overlay scompare', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper)
wrapper.vm.state.timeout = false
await wrapper.vm.$nextTick()
expect(wrapper.find('.timeout-overlay').exists()).toBe(false)
})
})
// =============================================
// ORDINE GUEST/HOME CON FORMAZIONE
// =============================================
describe('Ordine guest/home con formazione visibile', () => {
it('order=false e visuForm=true mostra punteggio inline e formazioni nell\'ordine guest poi home', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.order = false
wrapper.vm.state.visuForm = true
wrapper.vm.state.sp.striscia.at(-1).ris = 'h'.repeat(3) + 'g'.repeat(2)
await wrapper.vm.$nextTick()
const scores = wrapper.findAll('.score-inline')
expect(scores[0].text()).toBe('2') // guest per primo
expect(scores[1].text()).toBe('3') // home per secondo
const formDivs = wrapper.findAll('.formdiv')
expect(formDivs).toHaveLength(12)
})
})
// =============================================
// MONTAGGIO: FULLSCREEN E TIME OUT GIÀ ATTIVO
// =============================================
describe('Comportamento al mount', () => {
it('su dispositivo mobile richiede la fullscreen', () => {
const uaSpy = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')
const fsSpy = vi.fn()
document.documentElement.requestFullscreen = fsSpy
const wrapper = mountDisplay()
expect(fsSpy).toHaveBeenCalled()
wrapper.unmount()
uaSpy.mockRestore()
})
it('non richiede la fullscreen su desktop', () => {
const fsSpy = vi.fn()
document.documentElement.requestFullscreen = fsSpy
const wrapper = mountDisplay()
expect(fsSpy).not.toHaveBeenCalled()
wrapper.unmount()
})
it('ignora l\'errore se requestFullscreen lancia un\'eccezione', () => {
const uaSpy = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Linux; Android 10)')
document.documentElement.requestFullscreen = () => { throw new Error('non consentito') }
expect(() => mountDisplay()).not.toThrow()
uaSpy.mockRestore()
})
it('se il time out è già attivo al mount avvia subito la modalità time out', async () => {
const wrapper = mount(DisplayPage, {
global: { stubs: { 'w-app': true } },
data() {
const state = {
order: true, visuForm: false, visuStriscia: true,
timeout: true, timeoutTeam: 'home', timeoutVideo: null,
timeoutStartedAt: Date.now(), modalitaPartita: '3/5',
sp: {
striscia: [{ serv: 'h', ris: '', vinc: null }],
nomi: { home: 'Antoniana', guest: 'Guest' },
form: { home: ['1', '2', '3', '4', '5', '6'], guest: ['1', '2', '3', '4', '5', '6'] },
},
}
return { state }
}
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.mostraPlaceholder).toBe(true)
expect(wrapper.find('.timeout-overlay').exists()).toBe(true)
})
})
// =============================================
// TICKER COUNTDOWN E CASI LIMITE
// =============================================
describe('Ticker del countdown', () => {
it('il ticker aggiorna nowTick periodicamente durante il time out', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = Date.now()
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
expect(wrapper.vm.timeoutRemaining).toBe(30)
vi.advanceTimersByTime(1000)
await wrapper.vm.$nextTick()
expect(wrapper.vm.timeoutRemaining).toBeLessThan(30)
})
it('timeoutRemaining resta 30 se manca timeoutStartedAt', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = null
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
expect(wrapper.vm.timeoutRemaining).toBe(30)
})
it('smontare il componente durante il time out ferma il ticker senza errori', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = Date.now()
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
expect(() => wrapper.unmount()).not.toThrow()
})
it('stricciaStrip non fallisce se la striscia del set corrente è assente', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.sp.striscia = []
expect(wrapper.vm.stricciaStrip).toEqual({ home: [], guest: [] })
// ripristina uno stato valido prima che Vue ricalcoli gli altri computed (punt/servHome)
wrapper.vm.state.sp.striscia = [{ serv: 'h', ris: '', vinc: null }]
await wrapper.vm.$nextTick()
})
})
// =============================================
// FINE TIME OUT: STOP VIDEO E FALLBACK MUTO
// =============================================
describe('Stop del time out e riproduzione video', () => {
async function attivaTimeout(wrapper, { team = 'home', video = null, startedAt = Date.now() } = {}) {
wrapper.vm.state.timeoutTeam = team
wrapper.vm.state.timeoutVideo = video
wrapper.vm.state.timeoutStartedAt = startedAt
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
}
it('allo stop del time out con video in corso mette in pausa e resetta il video', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4' })
const video = wrapper.find('.timeout-video').element
const pauseSpy = vi.spyOn(video, 'pause')
wrapper.vm.state.timeout = false
await wrapper.vm.$nextTick()
expect(pauseSpy).toHaveBeenCalled()
})
it('a metà riproduzione si riallinea al punto corretto (riconnessione)', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4', startedAt: Date.now() - 10000 })
const video = wrapper.find('.timeout-video').element
Object.defineProperty(video, 'duration', { value: 60, configurable: true })
video.onloadedmetadata()
expect(video.currentTime).toBeCloseTo(10, 0)
})
it('senza timeoutStartedAt non tenta alcun riallineamento (trascorsi=0)', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4', startedAt: null })
const video = wrapper.find('.timeout-video').element
Object.defineProperty(video, 'duration', { value: 60, configurable: true })
video.currentTime = 0
video.onloadedmetadata()
expect(video.currentTime).toBe(0)
})
it('non si riallinea se il tempo trascorso supera la durata del video', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4', startedAt: Date.now() - 10000 })
const video = wrapper.find('.timeout-video').element
Object.defineProperty(video, 'duration', { value: 5, configurable: true })
video.currentTime = 0
video.onloadedmetadata()
expect(video.currentTime).toBe(0)
})
it('caricaSpotCorrente non fa nulla se manca il ref del video o il video del time out', () => {
const wrapper = mountDisplay()
// nessun overlay attivo: $refs.spotVideo non esiste
expect(() => wrapper.vm.caricaSpotCorrente()).not.toThrow()
expect(wrapper.vm.spotVideoDuration).toBe(null)
})
it('playSpot non fa nulla se manca il ref del video', () => {
const wrapper = mountDisplay()
expect(() => wrapper.vm.playSpot()).not.toThrow()
})
it('se l\'autoplay con audio viene bloccato ripiega sul muto', async () => {
const wrapper = mountDisplay()
HTMLMediaElement.prototype.play = vi.fn()
.mockRejectedValueOnce(new Error('autoplay bloccato'))
.mockResolvedValueOnce(undefined)
await attivaTimeout(wrapper, { video: 'a.mp4' })
const video = wrapper.find('.timeout-video').element
await Promise.resolve()
await Promise.resolve()
expect(video.muted).toBe(true)
})
})
// =============================================
// MESSAGGI WEBSOCKET E SINTESI VOCALE
// =============================================
describe('onWsMessage e sintesi vocale', () => {
it('un messaggio "speak" avvia la sintesi vocale con il testo ricevuto', () => {
const wrapper = mountDisplay()
wrapper.vm.onWsMessage({ type: 'speak', text: '15 a 10' })
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
const utterance = window.speechSynthesis.speak.mock.calls.at(-1)[0]
expect(utterance.text).toBe('15 a 10')
expect(utterance.lang).toBe('it-IT')
})
it('un messaggio "error" viene loggato in console', () => {
const wrapper = mountDisplay()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper.vm.onWsMessage({ type: 'error', message: 'boom' })
expect(spy).toHaveBeenCalledWith('[Display] Server error:', 'boom')
spy.mockRestore()
})
it('ignora testo vuoto o non stringa nella sintesi vocale', () => {
const wrapper = mountDisplay()
window.speechSynthesis.speak.mockClear()
wrapper.vm.speakOnDisplay(' ')
wrapper.vm.speakOnDisplay(42)
expect(window.speechSynthesis.speak).not.toHaveBeenCalled()
})
it('sceglie la voce "Google italiano" quando disponibile', () => {
const wrapper = mountDisplay()
const vIt = { name: 'voce it generica', lang: 'it-IT' }
const vGoogle = { name: 'Google italiano', lang: 'it-IT' }
window.speechSynthesis.getVoices = () => [vIt, vGoogle]
wrapper.vm.speakOnDisplay('prova')
const utterance = window.speechSynthesis.speak.mock.calls.at(-1)[0]
expect(utterance.voice).toBe(vGoogle)
})
it('ripiega su una voce con lingua "it" se manca "Google italiano"', () => {
const wrapper = mountDisplay()
const vIt = { name: 'voce italiana', lang: 'it-IT' }
const vEn = { name: 'english voice', lang: 'en-US' }
window.speechSynthesis.getVoices = () => [vEn, vIt]
wrapper.vm.speakOnDisplay('prova')
const utterance = window.speechSynthesis.speak.mock.calls.at(-1)[0]
expect(utterance.voice).toBe(vIt)
})
it('usa voice=null se nessuna voce italiana è disponibile', () => {
const wrapper = mountDisplay()
window.speechSynthesis.getVoices = () => [{ name: 'english voice', lang: 'en-US' }]
wrapper.vm.speakOnDisplay('prova')
const utterance = window.speechSynthesis.speak.mock.calls.at(-1)[0]
expect(utterance.voice).toBe(null)
})
})
// =============================================
// ICONA SERVIZIO
// =============================================
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, afterEach } from 'vitest'
import { generaReferto } from '../../src/referto.js'
import { createInitialState } from '../../src/gameState.js'
// generaReferto è l'unico punto con effetti collaterali di referto.js (window.open
// + stampa): qui verifichiamo che orchestri correttamente le API del browser,
// mockando window.open per non aprire davvero una finestra durante i test.
describe('generaReferto (referto.js)', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('apre una nuova scheda, scrive il referto e avvia la stampa dopo il caricamento', () => {
const listeners = {}
const popup = {
document: { write: vi.fn(), close: vi.fn() },
print: vi.fn(),
addEventListener: vi.fn((evt, cb) => { listeners[evt] = cb }),
}
const openSpy = vi.spyOn(window, 'open').mockReturnValue(popup)
const state = createInitialState()
state.sp.nomi = { home: 'Antoniana', guest: 'Rivali' }
generaReferto(state)
expect(openSpy).toHaveBeenCalledWith('', '_blank')
expect(popup.document.write).toHaveBeenCalledTimes(1)
expect(popup.document.write.mock.calls[0][0]).toContain('Antoniana')
expect(popup.document.close).toHaveBeenCalledTimes(1)
expect(popup.print).not.toHaveBeenCalled()
listeners.load()
expect(popup.print).toHaveBeenCalledTimes(1)
})
})
+325
View File
@@ -0,0 +1,325 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createWsMixin } from '../../src/wsMixin.js'
import { createInitialState } from '../../src/gameState.js'
// WebSocket mock controllabile: i gestori (onopen/onmessage/onclose) vengono
// assegnati dal mixin e li invochiamo manualmente nei test.
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
constructor(url) {
this.url = url
this.readyState = MockWebSocket.CONNECTING
this.send = vi.fn()
this.close = vi.fn()
MockWebSocket.instances.push(this)
}
}
MockWebSocket.instances = []
vi.stubGlobal('WebSocket', MockWebSocket)
// Monta un componente che usa il mixin. `extra` permette di aggiungere hook.
function mountWith(role = 'controller', extra = {}) {
const Comp = {
mixins: [createWsMixin(role)],
template: '<div></div>',
...extra,
}
return mount(Comp)
}
function ultimaWs() {
return MockWebSocket.instances.at(-1)
}
describe('createWsMixin (wsMixin.js)', () => {
beforeEach(() => {
MockWebSocket.instances = []
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('computed derivati', () => {
it('punt/servHome/set delegano alle funzioni pure sulla striscia', async () => {
const wrapper = mountWith()
wrapper.vm.state.sp.striscia = [
{ serv: 'h', ris: 'h', vinc: 'h' },
{ serv: 'h', ris: 'h'.repeat(10) + 'g'.repeat(8), vinc: null },
]
await wrapper.vm.$nextTick()
expect(wrapper.vm.punt).toEqual({ home: 10, guest: 8 })
expect(wrapper.vm.set).toEqual({ home: 1, guest: 0 })
// ultimo punto 'g' → serve guest
expect(wrapper.vm.servHome).toBe(false)
})
})
describe('connessione', () => {
it('apre una WebSocket verso /ws al mount', () => {
mountWith()
const ws = ultimaWs()
expect(ws).toBeDefined()
expect(ws.url).toMatch(/^ws:\/\/.+\/ws$/)
})
it('invia il messaggio register all\'apertura', () => {
const wrapper = mountWith('controller')
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
ws.onopen()
expect(ws.send).toHaveBeenCalledTimes(1)
const msg = JSON.parse(ws.send.mock.calls[0][0])
expect(msg).toEqual({ type: 'register', role: 'controller' })
expect(wrapper.vm.wsConnected).toBe(true)
})
it('un messaggio "state" aggiorna lo stato locale', () => {
const wrapper = mountWith()
const ws = ultimaWs()
const nuovo = createInitialState()
nuovo.sp.nomi.home = 'Nuova Squadra'
ws.onmessage({ data: JSON.stringify({ type: 'state', state: nuovo }) })
expect(wrapper.vm.state.sp.nomi.home).toBe('Nuova Squadra')
})
it('un messaggio non-state invoca l\'hook onWsMessage', () => {
const onWsMessage = vi.fn()
const wrapper = mountWith('display', { methods: { onWsMessage } })
const ws = ultimaWs()
ws.onmessage({ data: JSON.stringify({ type: 'speak', text: 'ciao' }) })
expect(onWsMessage).toHaveBeenCalledWith({ type: 'speak', text: 'ciao' })
})
})
describe('riconnessione', () => {
it('scheduleReconnect usa backoff esponenziale con cap a 30s', () => {
const wrapper = mountWith()
const delays = []
const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(() => 123)
// intercetta i delay leggendoli dalle chiamate
spy.mockImplementation((_fn, d) => { delays.push(d); return 123 })
wrapper.vm.reconnectAttempts = 0
wrapper.vm.reconnectTimeout = null
wrapper.vm.scheduleReconnect() // 1000
wrapper.vm.reconnectTimeout = null
wrapper.vm.scheduleReconnect() // 2000
wrapper.vm.reconnectTimeout = null
wrapper.vm.scheduleReconnect() // 4000
expect(delays).toEqual([1000, 2000, 4000])
// attempts alto → cap a 30000
delays.length = 0
wrapper.vm.reconnectAttempts = 20
wrapper.vm.reconnectTimeout = null
wrapper.vm.scheduleReconnect()
expect(delays[0]).toBe(30000)
spy.mockRestore()
})
it('non riconnette su chiusura pulita (1000/1001)', () => {
const wrapper = mountWith()
const ws = ultimaWs()
const spy = vi.spyOn(wrapper.vm, 'scheduleReconnect')
ws.onclose({ code: 1000 })
ws.onclose({ code: 1001 })
expect(spy).not.toHaveBeenCalled()
expect(wrapper.vm.wsConnected).toBe(false)
})
it('riconnette su chiusura anomala (es. 1006)', () => {
const wrapper = mountWith()
const ws = ultimaWs()
const spy = vi.spyOn(wrapper.vm, 'scheduleReconnect')
ws.onclose({ code: 1006 })
expect(spy).toHaveBeenCalled()
})
it('allo scadere del timer riapre davvero la connessione', () => {
vi.useFakeTimers()
try {
const wrapper = mountWith()
// il mock non fa mai scattare onopen: sblocca la guardia isConnecting
wrapper.vm.isConnecting = false
const prima = MockWebSocket.instances.length
wrapper.vm.scheduleReconnect()
vi.advanceTimersByTime(1000)
expect(MockWebSocket.instances.length).toBe(prima + 1)
expect(wrapper.vm.reconnectTimeout).toBe(null)
} finally {
vi.useRealTimers()
}
})
it('se il costruttore WebSocket lancia, programma la riconnessione', () => {
const wrapper = mountWith()
wrapper.vm.isConnecting = false
const spy = vi.spyOn(wrapper.vm, 'scheduleReconnect').mockImplementation(() => {})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal('WebSocket', class { constructor() { throw new Error('boom') } })
try {
wrapper.vm.connectWebSocket()
expect(spy).toHaveBeenCalled()
expect(wrapper.vm.isConnecting).toBe(false)
expect(wrapper.vm.ws).toBe(null)
} finally {
vi.stubGlobal('WebSocket', MockWebSocket)
consoleSpy.mockRestore()
}
})
it('onerror azzera lo stato di connessione', () => {
const wrapper = mountWith()
const ws = ultimaWs()
wrapper.vm.wsConnected = true
wrapper.vm.isConnecting = true
ws.onerror()
expect(wrapper.vm.wsConnected).toBe(false)
expect(wrapper.vm.isConnecting).toBe(false)
})
})
describe('sendWs', () => {
it('ritorna false se non connesso', () => {
const wrapper = mountWith()
wrapper.vm.wsConnected = false
expect(wrapper.vm.sendWs({ type: 'action' })).toBe(false)
})
it('serializza e invia se connesso e aperto', () => {
const wrapper = mountWith()
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
wrapper.vm.wsConnected = true
const ok = wrapper.vm.sendWs({ type: 'action', action: { type: 'incPunt' } })
expect(ok).toBe(true)
const inviato = JSON.parse(ws.send.mock.calls.at(-1)[0])
expect(inviato.type).toBe('action')
})
it('ritorna false se send lancia', () => {
const wrapper = mountWith()
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
wrapper.vm.wsConnected = true
ws.send.mockImplementation(() => { throw new Error('boom') })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(wrapper.vm.sendWs({ type: 'action' })).toBe(false)
consoleSpy.mockRestore()
})
})
describe('cleanup', () => {
it('beforeUnmount chiude la WebSocket', () => {
const wrapper = mountWith()
const ws = ultimaWs()
wrapper.unmount()
expect(ws.close).toHaveBeenCalled()
})
it('beforeUnmount con connessione OPEN chiude con codice 1000', () => {
const wrapper = mountWith()
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
wrapper.unmount()
expect(ws.close).toHaveBeenCalledWith(1000, 'Component unmounting')
})
it('beforeUnmount non lancia se ws.close lancia', () => {
const wrapper = mountWith()
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
ws.close.mockImplementation(() => { throw new Error('già chiusa') })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => wrapper.unmount()).not.toThrow()
expect(consoleSpy).toHaveBeenCalled()
})
it('beforeUnmount annulla un reconnectTimeout pendente', () => {
vi.useFakeTimers()
try {
const wrapper = mountWith()
wrapper.vm.isConnecting = false
wrapper.vm.scheduleReconnect()
expect(wrapper.vm.reconnectTimeout).not.toBe(null)
wrapper.unmount()
// se il timer non fosse stato annullato, allo scoccare del delay
// verrebbe creata una nuova connessione anche a componente smontato
const primaDelTimeout = MockWebSocket.instances.length
vi.advanceTimersByTime(30000)
expect(MockWebSocket.instances.length).toBe(primaDelTimeout)
} finally {
vi.useRealTimers()
}
})
})
describe('rami aggiuntivi', () => {
it('connectWebSocket non fa nulla se è già in corso un tentativo di connessione', () => {
const wrapper = mountWith()
const primaDelTimeout = MockWebSocket.instances.length
wrapper.vm.isConnecting = true
wrapper.vm.connectWebSocket()
expect(MockWebSocket.instances.length).toBe(primaDelTimeout)
})
it('riconnettendosi con la WebSocket precedente OPEN, la chiude con codice 1000', () => {
const wrapper = mountWith()
const prima = ultimaWs()
prima.readyState = MockWebSocket.OPEN
wrapper.vm.isConnecting = false
wrapper.vm.connectWebSocket()
expect(prima.close).toHaveBeenCalledWith(1000, 'Reconnecting')
})
it('se la chiusura della WebSocket precedente lancia, logga e continua a riconnettersi', () => {
const wrapper = mountWith()
const prima = ultimaWs()
prima.readyState = MockWebSocket.OPEN
prima.close.mockImplementation(() => { throw new Error('già chiusa') })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper.vm.isConnecting = false
expect(() => wrapper.vm.connectWebSocket()).not.toThrow()
expect(consoleSpy).toHaveBeenCalled()
expect(ultimaWs()).not.toBe(prima)
})
it('onopen non lancia se il registro fallisce (send che lancia)', () => {
const wrapper = mountWith()
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
ws.send.mockImplementation(() => { throw new Error('boom') })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => ws.onopen()).not.toThrow()
expect(wrapper.vm.wsConnected).toBe(true)
expect(consoleSpy).toHaveBeenCalled()
})
it('onmessage non lancia su JSON non valido', () => {
mountWith()
const ws = ultimaWs()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => ws.onmessage({ data: 'non è JSON {{{' })).not.toThrow()
expect(consoleSpy).toHaveBeenCalled()
})
it('scheduleReconnect non arma un secondo timer se uno è già pendente', () => {
const wrapper = mountWith()
const spy = vi.spyOn(globalThis, 'setTimeout')
wrapper.vm.reconnectTimeout = 123 // timer già pendente (valore fittizio)
wrapper.vm.scheduleReconnect()
expect(spy).not.toHaveBeenCalled()
})
})
})
+15 -11
View File
@@ -1,11 +1,12 @@
const { test, expect } = require('@playwright/test');
const AxeBuilderImport = require('@axe-core/playwright');
const AxeBuilder = AxeBuilderImport.default || AxeBuilderImport;
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
test.describe('Accessibility (a11y)', () => {
test('Display: non dovrebbe avere violazioni critiche a11y', async ({ page }) => {
await page.goto('http://localhost:3000');
test('Display: non dovrebbe avere violazioni critiche a11y', async ({ context }) => {
const page = await openDisplay(context);
await page.waitForTimeout(500);
const results = await new AxeBuilder({ page })
@@ -16,12 +17,15 @@ test.describe('Accessibility (a11y)', () => {
expect(results.violations).toEqual([]);
});
test('Controller: non dovrebbe avere violazioni critiche a11y', async ({ page }) => {
await page.goto('http://localhost:3001');
await page.waitForTimeout(500);
test('Controller: non dovrebbe avere violazioni critiche a11y', async ({ context }) => {
const page = await openController(context);
await resetGame(page);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
// I colori squadra (giallo/blu) sono colori brand: il contrasto è
// valutato separatamente, come per il display.
.disableRules(['color-contrast'])
.analyze();
// Mostra i dettagli delle violazioni se ci sono
@@ -41,9 +45,9 @@ test.describe('Accessibility (a11y)', () => {
expect(serious).toEqual([]);
});
test('Controller: i touch target dovrebbero avere dimensione minima', async ({ page }) => {
await page.goto('http://localhost:3001');
await page.waitForSelector('.conn-bar.connected');
test('Controller: i touch target dovrebbero avere dimensione minima', async ({ context }) => {
const page = await openController(context);
await resetGame(page);
// Controlla che i bottoni principali abbiano dimensione minima 44x44px
const buttons = page.locator('.btn-ctrl');
@@ -56,9 +60,9 @@ test.describe('Accessibility (a11y)', () => {
}
});
test('Controller: i bottoni punteggio dovrebbero avere dimensione adeguata', async ({ page }) => {
await page.goto('http://localhost:3001');
await page.waitForSelector('.conn-bar.connected');
test('Controller: i bottoni punteggio dovrebbero avere dimensione adeguata', async ({ context }) => {
const page = await openController(context);
await resetGame(page);
const scoreButtons = page.locator('.team-score');
const count = await scoreButtons.count();
+14 -53
View File
@@ -1,24 +1,19 @@
const { test, expect } = require('@playwright/test');
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
test.describe('Basic Flow: Controller ↔ Display', () => {
test('dovrebbe caricare Display e Controller con i titoli corretti', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await expect(displayPage).toHaveTitle(/Segnapunti/);
await expect(controllerPage).toHaveTitle(/Controller/);
});
test('il punteggio iniziale dovrebbe essere 0-0', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
// Attende la connessione WebSocket
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
const homeScore = controllerPage.locator('.team-score.home-bg .team-pts');
const guestScore = controllerPage.locator('.team-score.guest-bg .team-pts');
@@ -27,22 +22,10 @@ test.describe('Basic Flow: Controller ↔ Display', () => {
});
test('click +1 Home sul Controller dovrebbe aggiornare il Display', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
// Attende la connessione WebSocket del controller
await controllerPage.waitForSelector('.conn-bar.connected');
// Reset per stato pulito
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
await controllerPage.waitForTimeout(200);
await resetGame(controllerPage);
// Click +1 Home
await controllerPage.locator('.team-score.home-bg').click();
@@ -56,21 +39,10 @@ test.describe('Basic Flow: Controller ↔ Display', () => {
});
test('click +1 Guest sul Controller dovrebbe aggiornare il Display', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
// Reset
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
await controllerPage.waitForTimeout(200);
await resetGame(controllerPage);
// Click +1 Guest
await controllerPage.locator('.team-score.guest-bg').click();
@@ -84,21 +56,10 @@ test.describe('Basic Flow: Controller ↔ Display', () => {
});
test('la sincronizzazione dovrebbe funzionare con punti alternati', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
// Reset
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
await controllerPage.waitForTimeout(200);
await resetGame(controllerPage);
// Home +1, Guest +1, Home +1
await controllerPage.locator('.team-score.home-bg').click();
+15 -54
View File
@@ -1,43 +1,10 @@
const { test, expect } = require('@playwright/test');
// Helper: reset dal controller
async function resetGame(controllerPage) {
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
await controllerPage.waitForTimeout(300);
}
// Helper: incrementa punti per una squadra N volte
async function addPoints(controllerPage, team, count) {
const selector = team === 'home' ? '.team-score.home-bg' : '.team-score.guest-bg';
for (let i = 0; i < count; i++) {
await controllerPage.locator(selector).click();
await controllerPage.waitForTimeout(30);
}
await controllerPage.waitForTimeout(100);
}
// Helper: assegna un set a una squadra (25 punti + click SET)
async function winSet(controllerPage, team) {
await addPoints(controllerPage, team, 25);
// Clicca bottone SET
const setSelector = team === 'home' ? '.btn-set.home-bg' : '.btn-set.guest-bg';
await controllerPage.locator(setSelector).click();
await controllerPage.waitForTimeout(100);
// Reset punti per il prossimo set
// (in questo gioco i punti non si resettano automaticamente, serve reset manuale
// o il controller gestisce il prossimo set manualmente)
}
const { openController, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Full Match Simulation', () => {
test('Partita 2/3: Home vince 2 set a 0', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
test('Partita 2/3: Home vince il primo set', async ({ context }) => {
const controllerPage = await openController(context);
await resetGame(controllerPage);
@@ -54,18 +21,19 @@ test.describe('Full Match Simulation', () => {
// Verifica punteggio 25
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('25');
// Incrementa set Home
await controllerPage.locator('.btn-set.home-bg').click();
await controllerPage.waitForTimeout(100);
// A 25-0 compare automaticamente il dialog "SET VINTO": vai al set successivo
await controllerPage.waitForSelector('.dialog-winner');
await controllerPage.getByText('VAI AL SET SUCCESSIVO').click();
// doNuovoSet riapre il dialog di configurazione: chiudilo
await controllerPage.locator('.dialog-config .btn-cancel').click();
await controllerPage.waitForTimeout(200);
// Verifica set 1 per Home
await expect(controllerPage.locator('.team-score.home-bg .team-set')).toContainText('SET 1');
});
test('Set decisivo 2/3: vittoria a 15 punti', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
@@ -92,17 +60,12 @@ test.describe('Full Match Simulation', () => {
// Verifica punteggio 15 (e il set è decisivo: dopo 15 punti il gioco è vinto)
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('15');
// Verifica che non si possono aggiungere altri punti (vittoria)
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(100);
// Dovrebbe restare 15 (checkVittoria blocca incPunt)
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('15');
// A 15 nel set decisivo scatta la vittoria: compare il dialog (PARTITA FINITA)
await expect(controllerPage.locator('.dialog-winner')).toBeVisible();
});
test('Set normale: punti oltre 25 fino ai vantaggi', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
@@ -123,9 +86,7 @@ test.describe('Full Match Simulation', () => {
await controllerPage.waitForTimeout(100);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('26');
// 26-24 è vittoria → non si possono più aggiungere punti
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(100);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('26');
// 26-24 è vittoria → compare automaticamente il dialog SET VINTO
await expect(controllerPage.locator('.dialog-winner')).toBeVisible();
});
});
+16 -44
View File
@@ -1,22 +1,10 @@
const { test, expect } = require('@playwright/test');
// Helper: reset dal controller
async function resetGame(controllerPage) {
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
await controllerPage.waitForTimeout(300);
}
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
test.describe('Game Operations', () => {
test('Undo: dovrebbe annullare l\'ultimo punto', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Incrementa Home a 1
@@ -31,9 +19,8 @@ test.describe('Game Operations', () => {
});
test('Reset: dovrebbe azzerare tutto dopo conferma', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Imposta qualche punto
for (let i = 0; i < 5; i++) {
@@ -50,12 +37,9 @@ test.describe('Game Operations', () => {
});
test('Config: dovrebbe cambiare i nomi dei team', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri config
await controllerPage.getByText('Config').click();
@@ -80,12 +64,9 @@ test.describe('Game Operations', () => {
});
test('Toggle Formazione: dovrebbe mostrare la formazione sul display', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Inizialmente mostra punteggio, non formazione
await expect(displayPage.locator('.punteggio-container')).toBeVisible();
@@ -99,12 +80,9 @@ test.describe('Game Operations', () => {
});
test('Toggle Striscia: dovrebbe nascondere/mostrare la striscia', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Inizialmente la striscia è visibile
await expect(displayPage.locator('.striscia')).toBeVisible();
@@ -121,12 +99,8 @@ test.describe('Game Operations', () => {
});
test('Cambi: dovrebbe effettuare una sostituzione giocatore', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
@@ -156,9 +130,7 @@ test.describe('Game Operations', () => {
});
test('Cambi: dovrebbe mostrare errore per giocatore non in formazione', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
+15 -56
View File
@@ -1,69 +1,28 @@
const { test, expect } = require('@playwright/test');
const { openController, openDisplay, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Game Simulation', () => {
test('Simulazione Partita: Controller aggiunge punti finché non cambia il set', async ({ context }) => {
// 1. Setup Pagine
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
await resetGame(controllerPage);
// Selettori (basati su ID ipotetici o classi, adattali al tuo HTML reale)
// Assumo che nel DOM ci siano elementi con ID o classi riconoscibili
// E che i punteggi siano visibili.
// Porta Home a 25: in ControllerPage il click su .team-score.home-bg
// incrementa i punti home. A 25-0 scatta la vittoria del set.
await addPoints(controllerPage, 'home', 25);
// Pulisco lo stato iniziale (reset)
const btnReset = controllerPage.getByText(/Reset/i).first();
if (await btnReset.isVisible()) {
await btnReset.click();
// La modale di conferma ha un bottone "SI" con classe .btn-confirm
const btnConfirmReset = controllerPage.locator('.dialog .btn-confirm').getByText('SI');
if (await btnConfirmReset.isVisible()) {
await btnConfirmReset.click();
}
}
// 2. Loop per vincere il primo set (25 punti)
// In ControllerPage.vue, il click su .team-score.home-bg incrementa i punti home
const btnHomeScore = controllerPage.locator('.team-score.home-bg');
for (let i = 0; i < 25; i++) {
await btnHomeScore.click();
// Piccola pausa per lasciare tempo al server di processare e broadcastare
//await displayPage.waitForTimeout(10);
}
// 3. Verifica Vittoria Set
// I punti dovrebbero essere tornati a 0 (o mostrare 25 prima del reset manuale?)
// Il codice gameState dice: checkVittoria -> resetta solo se qualcuno chiama resetta?
// No, checkVittoria è boolean. applyAction('incPunt') incrementa.
// Se vince, il set incrementa? 'incPunt' non incrementa i set in automatico nel codice gameState checkato prima!
// Controllo applyAction:
// "s.sp.punt[team]++" ... POI "checkVittoria(s)" all'inizio del prossimo incPunt?
// NO: "if (checkVittoria(s)) break" all'inizio di incPunt impedisce di andare oltre 25 se già vinto.
// MA 'incSet' è un'azione separata!
// Aspetta, la logica standard è: arrivo a 25 -> vinco set?
// In questo codice `gameState.js` NON c'è automatismo "arrivo a 25 -> set++ e palla al centro".
// L'utente deve cliccare "SET Antoniana" manualmente?
// Guardiamo ControllerPage.vue:
// C'è un bottone "SET {{ state.sp.nomi.home }}" che manda { type: 'incSet', team: 'home' }
// QUINDI: Il test deve:
// 1. Arrivare a 25 pt.
// 2. Cliccare "SET HOME".
// 3. Verificare che Set Home = 1.
// Verifica che siamo a 25
// A 25-0 compare automaticamente il dialog "SET VINTO"
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('25');
await controllerPage.waitForSelector('.dialog-winner');
// Clicca bottone SET
const btnSetHome = controllerPage.locator('.btn-set.home-bg');
await btnSetHome.click();
// Procedi al set successivo: registra il set vinto da Home
await controllerPage.getByText('VAI AL SET SUCCESSIVO').click();
// doNuovoSet riapre il dialog di configurazione: chiudilo
await controllerPage.locator('.dialog-config .btn-cancel').click();
await controllerPage.waitForTimeout(300);
// Verifica che il set sia incrementato
// Nota: display potrebbe chiamarsi diversamente, controlliamo Controller per coerenza
// Verifica che il set Home sia incrementato a 1
await expect(controllerPage.locator('.team-score.home-bg .team-set')).toContainText('SET 1');
});
});
+81
View File
@@ -0,0 +1,81 @@
// Helper condivisi per gli E2E.
// Nota: lo stato di gioco è UNICO e persistente sul server, quindi ogni test
// deve riportarlo a zero (resetGame) e gestire eventuali dialog rimasti aperti
// dal test precedente.
const CONTROLLER_URL = 'http://localhost:3000/controller';
const DISPLAY_URL = 'http://localhost:3000';
// Apre il controller in viewport portrait (layout "mobile", su cui si basano i
// selettori dei test) e attende la connessione WebSocket.
async function openController(context) {
const page = await context.newPage();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(CONTROLLER_URL);
await page.waitForSelector('.conn-bar.connected');
return page;
}
// Apre il display.
async function openDisplay(context) {
const page = await context.newPage();
await page.goto(DISPLAY_URL);
return page;
}
// Chiude l'eventuale dialog di fine set / fine partita (può essersi aperto in
// automatico al caricamento se lo stato persistito era già "vinto").
async function chiudiDialogVittoria(page) {
if (await page.locator('.dialog-winner').isVisible().catch(() => false)) {
const chiudi = page.getByRole('button', { name: 'CHIUDI' });
if (await chiudi.isVisible().catch(() => false)) {
await chiudi.click();
} else {
await page.getByText('INDIETRO').click();
}
await page.waitForTimeout(150);
}
}
// Reset completo dello stato dal controller:
// 1) chiude un eventuale dialog di vittoria, 2) azzera, 3) nel dialog di
// configurazione che doReset riapre, reimposta nomi di default e conferma.
// Reimpostare i nomi rende lo stato deterministico (lo stato è condiviso e
// persistente: senza questo i nomi di un test precedente "sporcano" gli altri,
// in particolare gli screenshot di visual-regression).
async function resetGame(page) {
await chiudiDialogVittoria(page);
await page.getByText(/Reset/i).first().click();
const btnConfirm = page.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible().catch(() => false)) {
await btnConfirm.click();
}
const cfg = page.locator('.dialog-config');
if (await cfg.isVisible().catch(() => false)) {
const inputs = page.locator('.dialog-config .input-field');
await inputs.first().fill('Antoniana');
await inputs.nth(1).fill('Guest');
await page.locator('.dialog-config .btn-confirm').click();
}
await page.waitForTimeout(300);
}
// Incrementa N punti per una squadra cliccando il punteggio.
async function addPoints(page, team, count) {
const selector = team === 'home' ? '.team-score.home-bg' : '.team-score.guest-bg';
for (let i = 0; i < count; i++) {
await page.locator(selector).click();
await page.waitForTimeout(30);
}
await page.waitForTimeout(100);
}
module.exports = {
CONTROLLER_URL,
DISPLAY_URL,
openController,
openDisplay,
chiudiDialogVittoria,
resetGame,
addPoints,
};
+47
View File
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
const { openController, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Referto di fine partita', () => {
test('a PARTITA FINITA il bottone REFERTO apre il referto stampabile', async ({ context }) => {
// window.print() bloccherebbe il test: lo neutralizziamo (anche nel popup).
await context.addInitScript(() => { window.print = () => {}; });
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Modalità 2/3 (bastano 2 set per chiudere la partita)
await controllerPage.getByText('Config').click();
await controllerPage.waitForSelector('.dialog-config');
await controllerPage.locator('.btn-mode').getByText('2/3').click();
await controllerPage.locator('.dialog-config .btn-confirm').click();
await controllerPage.waitForTimeout(200);
// SET 1: Home a 25 → dialog SET VINTO → vai al set successivo
await addPoints(controllerPage, 'home', 25);
await controllerPage.waitForSelector('.dialog-winner');
await controllerPage.getByText('VAI AL SET SUCCESSIVO').click();
await controllerPage.locator('.dialog-config .btn-cancel').click();
await controllerPage.waitForTimeout(200);
// SET 2: Home a 25 → PARTITA FINITA
await addPoints(controllerPage, 'home', 25);
await controllerPage.waitForSelector('.dialog-winner');
const referto = controllerPage.getByRole('button', { name: 'REFERTO' });
await expect(referto).toBeVisible();
// Il click apre il referto in un nuovo popup
const [popup] = await Promise.all([
context.waitForEvent('page'),
referto.click(),
]);
// Verifica il contenuto del referto
const body = popup.locator('body');
await expect(body).toContainText('Referto di Gara');
await expect(popup.locator('.set-numero')).toHaveText(['1', '2']);
// Home ha vinto 2 set a 0
await expect(body).toContainText('2 0');
});
});
+56
View File
@@ -0,0 +1,56 @@
const { test, expect } = require('@playwright/test');
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
// Il flusso usa sempre l'opzione "Nessun video": non dipende dal contenuto
// della cartella spot/ e resta deterministico su qualsiasi macchina.
test.describe('Time Out', () => {
test('flusso completo: avvio, overlay sul display, chiusura anticipata', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri la modal time out
await controllerPage.locator('.btn-timeout').click();
await expect(controllerPage.locator('.dialog-timeout')).toBeVisible();
// Avvia è disabilitato finché non si sceglie la squadra
const avvia = controllerPage.locator('.dialog-timeout .btn-confirm');
await expect(avvia).toBeDisabled();
// Scegli squadra Home e nessun video
await controllerPage.locator('.dialog-timeout .btn-set.home-bg').click();
await controllerPage.locator('.timeout-video-option').last().locator('input').check();
await expect(avvia).toBeEnabled();
await avvia.click();
// Display: overlay a tutto schermo con countdown e squadra
await expect(displayPage.locator('.timeout-overlay')).toBeVisible();
await expect(displayPage.locator('.timeout-placeholder')).toContainText('TIME OUT');
await expect(displayPage.locator('.timeout-placeholder')).toContainText('Antoniana');
await expect(displayPage.locator('.timeout-countdown')).toBeVisible();
// Controller: banner attivo con nome squadra
await expect(controllerPage.locator('.timeout-banner')).toContainText('TIME OUT');
await expect(controllerPage.locator('.timeout-banner')).toContainText('Antoniana');
// Chiusura anticipata: il bottone (che mostra il countdown) invia stopTimeout
await controllerPage.locator('.btn-timeout').click();
await expect(displayPage.locator('.timeout-overlay')).not.toBeVisible();
await expect(controllerPage.locator('.timeout-banner')).not.toBeVisible();
});
test('Annulla chiude la modal senza avviare il time out', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
await controllerPage.locator('.btn-timeout').click();
await expect(controllerPage.locator('.dialog-timeout')).toBeVisible();
await controllerPage.locator('.dialog-timeout .btn-cancel').click();
await expect(controllerPage.locator('.dialog-timeout')).not.toBeVisible();
await expect(controllerPage.locator('.timeout-banner')).not.toBeVisible();
await expect(displayPage.locator('.timeout-overlay')).not.toBeVisible();
});
});
+14 -38
View File
@@ -1,26 +1,12 @@
const { test, expect } = require('@playwright/test');
// Helper: reset dal controller
async function resetGame(controllerPage) {
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
await controllerPage.waitForTimeout(300);
}
const { openController, openDisplay, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Visual Regression', () => {
test('Display: screenshot a 0-0', async ({ context }) => {
const controllerPage = await context.newPage();
const displayPage = await context.newPage();
const controllerPage = await openController(context);
const displayPage = await openDisplay(context);
await controllerPage.goto('http://localhost:3001');
await displayPage.goto('http://localhost:3000');
await controllerPage.waitForSelector('.conn-bar.connected');
// Reset per stato pulito
await resetGame(controllerPage);
// Attende che il display riceva lo stato
@@ -28,40 +14,31 @@ test.describe('Visual Regression', () => {
await expect(displayPage).toHaveScreenshot('display-0-0.png', {
maxDiffPixelRatio: 0.05,
animations: 'disabled',
mask: [displayPage.locator('.connection-status')],
});
});
test('Display: screenshot durante partita (15-12)', async ({ context }) => {
const controllerPage = await context.newPage();
const displayPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await displayPage.goto('http://localhost:3000');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
const displayPage = await openDisplay(context);
await resetGame(controllerPage);
// Porta il punteggio a 15-12
for (let i = 0; i < 15; i++) {
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(20);
}
for (let i = 0; i < 12; i++) {
await controllerPage.locator('.team-score.guest-bg').click();
await controllerPage.waitForTimeout(20);
}
await addPoints(controllerPage, 'home', 15);
await addPoints(controllerPage, 'guest', 12);
await displayPage.waitForTimeout(500);
await expect(displayPage).toHaveScreenshot('display-15-12.png', {
maxDiffPixelRatio: 0.05,
animations: 'disabled',
mask: [displayPage.locator('.connection-status')],
});
});
test('Controller: screenshot stato iniziale', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
await expect(controllerPage).toHaveScreenshot('controller-initial.png', {
@@ -70,9 +47,8 @@ test.describe('Visual Regression', () => {
});
test('Controller: screenshot con modal config aperta', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.goto('http://localhost:3001');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri config
await controllerPage.getByText('Config').click();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 197 KiB

+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { createApp } from '../../server.js'
// Usiamo una dist e una cartella spot temporanee con file marcatori, così il
// test non dipende da una build reale ed è deterministico.
let server
let baseUrl
let distDir
let spotDir
beforeAll(async () => {
distDir = mkdtempSync(join(tmpdir(), 'segnapunti-dist-'))
writeFileSync(join(distDir, 'index.html'), 'DISPLAY_MARKER')
writeFileSync(join(distDir, 'controller.html'), 'CONTROLLER_MARKER')
writeFileSync(join(distDir, 'app.js'), 'ASSET_MARKER')
spotDir = mkdtempSync(join(tmpdir(), 'segnapunti-spot-'))
writeFileSync(join(spotDir, 'b.mp4'), 'VIDEO_B')
writeFileSync(join(spotDir, 'a.mp4'), 'VIDEO_A')
writeFileSync(join(spotDir, 'note.txt'), 'NON_VIDEO')
const app = createApp(distDir, spotDir)
await new Promise((resolve) => {
server = app.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`
resolve()
})
})
})
afterAll(async () => {
await new Promise((resolve) => server.close(resolve))
rmSync(distDir, { recursive: true, force: true })
rmSync(spotDir, { recursive: true, force: true })
})
async function get(path) {
const res = await fetch(baseUrl + path)
return { status: res.status, body: await res.text() }
}
describe('Routing server (server.js)', () => {
it('GET / serve la pagina display', async () => {
const { status, body } = await get('/')
expect(status).toBe(200)
expect(body).toBe('DISPLAY_MARKER')
})
it('GET /display serve la pagina display', async () => {
expect((await get('/display')).body).toBe('DISPLAY_MARKER')
})
it('GET /display/qualsiasi serve comunque la pagina display (SPA)', async () => {
expect((await get('/display/foo/bar')).body).toBe('DISPLAY_MARKER')
})
it('GET /controller serve la pagina controller', async () => {
expect((await get('/controller')).body).toBe('CONTROLLER_MARKER')
})
it('GET /controller/qualsiasi serve comunque la pagina controller', async () => {
expect((await get('/controller/foo')).body).toBe('CONTROLLER_MARKER')
})
it('serve gli asset statici dalla dist', async () => {
const { status, body } = await get('/app.js')
expect(status).toBe(200)
expect(body).toBe('ASSET_MARKER')
})
it('GET /spot/list elenca i soli .mp4 in ordine alfabetico', async () => {
const res = await fetch(baseUrl + '/spot/list')
expect(res.status).toBe(200)
expect(await res.json()).toEqual(['a.mp4', 'b.mp4'])
})
it('serve i video staticamente da /spot/<file>', async () => {
const { status, body } = await get('/spot/a.mp4')
expect(status).toBe(200)
expect(body).toBe('VIDEO_A')
})
it('GET /spot/list ritorna [] se la cartella spot non esiste', async () => {
const app = createApp(distDir, join(spotDir, 'inesistente'))
const srv = await new Promise((resolve) => {
const s = app.listen(0, () => resolve(s))
})
try {
const res = await fetch(`http://127.0.0.1:${srv.address().port}/spot/list`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
} finally {
await new Promise((resolve) => srv.close(resolve))
}
})
})
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'
import WebSocket from 'ws'
import { createInitialState } from '../../src/gameState.js'
// startServer() usa src/persist.js per caricare/salvare lo stato reale su disco
// (.segnapunti/state.json): lo mockiamo per non toccare lo stato di una partita
// vera durante i test, ed esercitare in isolamento l'avvio di HTTP + WebSocket.
const saveState = vi.fn()
vi.mock('../../src/persist.js', () => ({
loadState: () => createInitialState(),
saveState,
}))
const { startServer } = await import('../../server.js')
describe('startServer (server.js)', () => {
let server
let port
beforeAll(async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
server = startServer(0)
await new Promise((resolve) => server.on('listening', resolve))
port = server.address().port
consoleSpy.mockRestore()
})
afterAll(async () => {
await new Promise((resolve) => server.close(resolve))
})
it('stampa le info del server all\'avvio', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const s = startServer(0)
await new Promise((resolve) => s.on('listening', resolve))
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
expect(allLogs).toContain('Segnapunti Server')
consoleSpy.mockRestore()
await new Promise((resolve) => s.close(resolve))
})
it('accetta una connessione WebSocket su /ws', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`)
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
})
ws.close()
})
it('rifiuta l\'upgrade su un percorso diverso da /ws', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/altro-percorso`)
await new Promise((resolve) => {
ws.on('error', () => resolve())
ws.on('open', () => { ws.close(); resolve() })
})
})
it('un\'azione dal controller invoca saveState (onStateChange)', async () => {
saveState.mockClear()
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`)
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
})
ws.send(JSON.stringify({ type: 'register', role: 'controller' }))
await new Promise((resolve) => ws.once('message', resolve))
ws.send(JSON.stringify({ type: 'action', action: { type: 'incPunt', team: 'home' } }))
await new Promise((resolve) => ws.once('message', resolve))
expect(saveState).toHaveBeenCalled()
ws.close()
})
})
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { EventEmitter } from 'events'
// Mock parziale di una WebSocket e del Server, analogo a websocket.test.js ma
// con send/terminate che possono essere forzati a lanciare per esercitare i
// rami di gestione errori altrimenti irraggiungibili.
class MockWebSocket extends EventEmitter {
constructor() {
super()
this.readyState = 1 // OPEN
}
send = vi.fn()
terminate = vi.fn()
}
class MockWebSocketServer extends EventEmitter {
clients = new Set()
}
describe('websocket-handler.js — rami di errore', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
vi.doUnmock('../../src/gameState.js')
})
describe('errori di invio (ws.send che lancia)', () => {
let wss, setupWebSocketHandler, consoleSpy
beforeEach(async () => {
vi.resetModules()
;({ setupWebSocketHandler } = await import('../../src/websocket-handler.js'))
wss = new MockWebSocketServer()
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('handleRegister: se ws.send lancia durante l\'invio dello stato iniziale, non deve propagare l\'errore', () => {
setupWebSocketHandler(wss)
const ws = new MockWebSocket()
ws.send.mockImplementation(() => { throw new Error('socket chiuso a metà') })
wss.emit('connection', ws)
wss.clients.add(ws)
expect(() => {
ws.emit('message', JSON.stringify({ type: 'register', role: 'display' }))
}).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith('Error sending initial state:', expect.any(Error))
})
it('sendError: se ws.send lancia rispondendo a un JSON non valido, non deve propagare l\'errore', () => {
setupWebSocketHandler(wss)
const ws = new MockWebSocket()
ws.send.mockImplementation(() => { throw new Error('socket chiuso a metà') })
wss.emit('connection', ws)
wss.clients.add(ws)
// JSON non valido → handleMessage va nel catch → sendError prova a rispondere → ws.send lancia
expect(() => {
ws.emit('message', 'non è JSON {{{')
}).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith('Failed to send error message:', expect.any(Error))
})
it('sendError: se ws.send lancia rispondendo a un\'azione invalida, non deve propagare l\'errore', () => {
setupWebSocketHandler(wss)
const controller = new MockWebSocket()
wss.emit('connection', controller)
wss.clients.add(controller)
controller.emit('message', JSON.stringify({ type: 'register', role: 'controller' }))
controller.send.mockImplementation(() => { throw new Error('socket chiuso a metà') })
expect(() => {
controller.emit('message', JSON.stringify({ type: 'action' }))
}).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith('Failed to send error message:', expect.any(Error))
})
})
describe('handleError: ws.terminate che lancia', () => {
it('non deve propagare l\'errore se la chiusura forzata fallisce', async () => {
vi.resetModules()
const { setupWebSocketHandler } = await import('../../src/websocket-handler.js')
const wss = new MockWebSocketServer()
setupWebSocketHandler(wss)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const ws = new MockWebSocket()
ws.terminate.mockImplementation(() => { throw new Error('già chiusa') })
wss.emit('connection', ws)
const err = new Error('Invalid UTF8')
err.code = 'WS_ERR_INVALID_UTF8'
expect(() => ws.emit('error', err)).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith('Error closing connection:', expect.any(Error))
})
})
describe('handleAction: applyAction che lancia', () => {
it('non deve propagare l\'errore, ripristina lo stato precedente e notifica il client', async () => {
vi.resetModules()
vi.doMock('../../src/gameState.js', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
applyAction: vi.fn((state, action) => {
if (action.type === 'boom') throw new Error('regola di gioco rotta')
return actual.applyAction(state, action)
}),
}
})
const { setupWebSocketHandler } = await import('../../src/websocket-handler.js')
const wss = new MockWebSocketServer()
const handler = setupWebSocketHandler(wss)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const controller = new MockWebSocket()
wss.emit('connection', controller)
wss.clients.add(controller)
controller.emit('message', JSON.stringify({ type: 'register', role: 'controller' }))
const statoPrecedente = handler.getState()
controller.send.mockClear()
expect(() => {
controller.emit('message', JSON.stringify({ type: 'action', action: { type: 'boom' } }))
}).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith('Error applying action:', expect.any(Error))
expect(handler.getState()).toEqual(statoPrecedente)
const msg = JSON.parse(controller.send.mock.calls.at(-1)[0])
expect(msg.type).toBe('error')
expect(msg.message).toContain('Failed to apply action')
})
})
})
+123 -8
View File
@@ -1,7 +1,11 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setupWebSocketHandler } from '../../src/websocket-handler.js'
import { punteggio } from '../../src/gameState.js'
import { EventEmitter } from 'events'
// Il punteggio non è memorizzato nello stato: si ricava dalla striscia.
const puntHome = (state) => punteggio(state.sp.striscia).home
// Mock parziale di una WebSocket e del Server
class MockWebSocket extends EventEmitter {
constructor() {
@@ -103,7 +107,7 @@ describe('WebSocket Integration (websocket-handler.js)', () => {
expect(controller.send).toHaveBeenCalled()
const sentMsg = lastSent(controller)
expect(sentMsg.type).toBe('state')
expect(sentMsg.state.sp.punt.home).toBe(1)
expect(puntHome(sentMsg.state)).toBe(1)
})
it('dovrebbe impedire al display di inviare azioni', () => {
@@ -160,6 +164,117 @@ describe('WebSocket Integration (websocket-handler.js)', () => {
})
})
// =============================================
// TIME OUT (timer server-side)
// =============================================
describe('Time out', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
function inviaStartTimeout(controller, overrides = {}) {
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'startTimeout', team: 'home', video: 'spot.mp4', ...overrides }
}))
}
it('dovrebbe iniettare startedAt lato server (ignorando quello del client)', () => {
vi.setSystemTime(50000)
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller, { startedAt: 12345 })
const state = handler.getState()
expect(state.timeout).toBe(true)
expect(state.timeoutStartedAt).toBe(50000)
})
it('dovrebbe chiudere automaticamente il time out dopo 30 secondi', () => {
const controller = connectAndRegister(wss, 'controller')
const display = connectAndRegister(wss, 'display')
inviaStartTimeout(controller)
expect(handler.getState().timeout).toBe(true)
vi.advanceTimersByTime(29999)
expect(handler.getState().timeout).toBe(true)
vi.advanceTimersByTime(1)
const state = handler.getState()
expect(state.timeout).toBe(false)
expect(state.timeoutTeam).toBe(null)
expect(state.timeoutVideo).toBe(null)
expect(state.timeoutStartedAt).toBe(null)
// La chiusura automatica viene broadcastata ai client
const msg = lastSent(display)
expect(msg.type).toBe('state')
expect(msg.state.timeout).toBe(false)
})
it('la chiusura automatica dovrebbe persistere lo stato (onStateChange)', () => {
const onStateChange = vi.fn()
wss = new MockWebSocketServer()
handler = setupWebSocketHandler(wss, { onStateChange })
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller)
onStateChange.mockClear()
vi.advanceTimersByTime(30000)
expect(onStateChange).toHaveBeenCalledTimes(1)
expect(onStateChange.mock.calls[0][0].timeout).toBe(false)
})
it('stopTimeout manuale dovrebbe annullare il timer pendente', () => {
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller)
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'stopTimeout' }
}))
expect(handler.getState().timeout).toBe(false)
controller.send.mockClear()
// Allo scadere dei 30s non deve partire nessun broadcast fantasma
vi.advanceTimersByTime(30000)
expect(controller.send).not.toHaveBeenCalled()
})
it('un nuovo startTimeout dovrebbe riarmare il timer da capo', () => {
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller)
vi.advanceTimersByTime(20000)
// Riavvio: il timer riparte da zero
inviaStartTimeout(controller, { team: 'guest' })
vi.advanceTimersByTime(20000)
expect(handler.getState().timeout).toBe(true)
expect(handler.getState().timeoutTeam).toBe('guest')
vi.advanceTimersByTime(10000)
expect(handler.getState().timeout).toBe(false)
})
it('startTimeout con squadra non valida non dovrebbe armare il timer', () => {
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller, { team: 'arbitro' })
expect(handler.getState().timeout).toBe(false)
controller.send.mockClear()
vi.advanceTimersByTime(30000)
expect(controller.send).not.toHaveBeenCalled()
})
})
// =============================================
// BROADCAST MULTI-CLIENT
// =============================================
@@ -182,8 +297,8 @@ describe('WebSocket Integration (websocket-handler.js)', () => {
const msg1 = lastSent(display1)
const msg2 = lastSent(display2)
expect(msg1.type).toBe('state')
expect(msg1.state.sp.punt.home).toBe(1)
expect(msg2.state.sp.punt.home).toBe(1)
expect(puntHome(msg1.state)).toBe(1)
expect(puntHome(msg2.state)).toBe(1)
})
it('non dovrebbe inviare a client con readyState != OPEN', () => {
@@ -302,7 +417,7 @@ describe('WebSocket Integration (websocket-handler.js)', () => {
const msg = lastSent(controller)
expect(msg.type).toBe('state')
expect(msg.state.sp.punt.home).toBe(1)
expect(puntHome(msg.state)).toBe(1)
})
})
@@ -374,15 +489,15 @@ describe('WebSocket Integration (websocket-handler.js)', () => {
describe('API pubblica', () => {
it('getState dovrebbe restituire lo stato corrente', () => {
const state = handler.getState()
expect(state.sp.punt.home).toBe(0)
expect(state.sp.punt.guest).toBe(0)
expect(puntHome(state)).toBe(0)
expect(punteggio(state.sp.striscia).guest).toBe(0)
})
it('setState dovrebbe sovrascrivere lo stato', () => {
const newState = handler.getState()
newState.sp.punt.home = 99
newState.sp.striscia.at(-1).ris = 'hh'
handler.setState(newState)
expect(handler.getState().sp.punt.home).toBe(99)
expect(puntHome(handler.getState())).toBe(2)
})
it('broadcastState dovrebbe inviare a tutti i client', () => {
+8 -4
View File
@@ -1,7 +1,11 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setupWebSocketHandler } from '../../src/websocket-handler.js'
import { punteggio } from '../../src/gameState.js'
import { EventEmitter } from 'events'
// Il punteggio si ricava dalla striscia, non è memorizzato nello stato.
const punt = (state) => punteggio(state.sp.striscia)
class MockWebSocket extends EventEmitter {
constructor() {
super()
@@ -57,7 +61,7 @@ describe('Stress Test WebSocket', () => {
expect(display.send).toHaveBeenCalled()
const msg = JSON.parse(display.send.mock.calls[display.send.mock.calls.length - 1][0])
expect(msg.type).toBe('state')
expect(msg.state.sp.punt.home).toBe(1)
expect(punt(msg.state).home).toBe(1)
}
})
@@ -81,11 +85,11 @@ describe('Stress Test WebSocket', () => {
// Lo stato finale dipende da checkVittoria che blocca a 25+2
// Home arriva a 25-0 → vittoria → blocca. Quindi punti home = 25
const state = handler.getState()
expect(state.sp.punt.home).toBe(25)
expect(punt(state).home).toBe(25)
// Guest: non può segnare dopo vittoria? No, checkVittoria blocca solo il team che ha vinto?
// Controlliamo: checkVittoria controlla ENTRAMBI i team.
// A 25-0 → vittoria=true → incPunt per guest è anche bloccato
expect(state.sp.punt.guest).toBe(0)
expect(punt(state).guest).toBe(0)
})
it('dovrebbe garantire che tutti i display ricevano ogni update sotto carico', () => {
@@ -112,7 +116,7 @@ describe('Stress Test WebSocket', () => {
// Verifica stato finale su tutti i display
for (const display of displays) {
const lastMsg = JSON.parse(display.send.mock.calls[4][0])
expect(lastMsg.state.sp.punt.home).toBe(5)
expect(punt(lastMsg.state).home).toBe(5)
}
})
})
+433 -134
View File
@@ -1,5 +1,39 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createInitialState, applyAction, checkVittoria } from '../../src/gameState.js'
import {
createInitialState, applyAction, checkVittoria, checkVittoriaPartita,
punteggio, setVinti, servizio,
} from '../../src/gameState.js'
// =============================================
// HELPER
// Lo stato non memorizza più punteggio/set/servizio direttamente:
// si ricavano dalla striscia tramite le funzioni pure esportate.
// =============================================
// Punteggio del set in corso
const puntDi = (s) => punteggio(s.sp.striscia)
// Set vinti nel match
const setDi = (s) => setVinti(s.sp.striscia)
// true se serve Home
const servDi = (s) => servizio(s.sp.striscia)
// Imposta chi serve a inizio set (set in corso a 0-0)
function setServizioIniziale(state, team) {
state.sp.striscia.at(-1).serv = team === 'home' ? 'h' : 'g'
}
// Imposta il punteggio del set in corso costruendo una ris coerente
function setPunteggio(state, home, guest) {
state.sp.striscia.at(-1).ris = 'h'.repeat(home) + 'g'.repeat(guest)
}
// Aggiunge set già conclusi (vinti) PRIMA del set in corso
function setSetVinti(state, home, guest) {
const conclusi = []
for (let i = 0; i < home; i++) conclusi.push({ serv: 'h', ris: '', vinc: 'h' })
for (let i = 0; i < guest; i++) conclusi.push({ serv: 'g', ris: '', vinc: 'g' })
state.sp.striscia = [...conclusi, state.sp.striscia.at(-1)]
}
describe('Game Logic (gameState.js)', () => {
let state
@@ -13,17 +47,17 @@ describe('Game Logic (gameState.js)', () => {
// =============================================
describe('Stato iniziale', () => {
it('dovrebbe iniziare con 0-0', () => {
expect(state.sp.punt.home).toBe(0)
expect(state.sp.punt.guest).toBe(0)
expect(puntDi(state).home).toBe(0)
expect(puntDi(state).guest).toBe(0)
})
it('dovrebbe avere i set a 0', () => {
expect(state.sp.set.home).toBe(0)
expect(state.sp.set.guest).toBe(0)
expect(setDi(state).home).toBe(0)
expect(setDi(state).guest).toBe(0)
})
it('dovrebbe avere servizio Home', () => {
expect(state.sp.servHome).toBe(true)
expect(servDi(state)).toBe(true)
})
it('dovrebbe avere formazione di default [1-6]', () => {
@@ -33,8 +67,8 @@ describe('Game Logic (gameState.js)', () => {
it('dovrebbe avere la striscia iniziale con un set vuoto', () => {
expect(state.sp.striscia).toHaveLength(1)
expect(state.sp.striscia[0].serv).toBe('home')
expect(state.sp.striscia[0].r).toEqual([])
expect(state.sp.striscia[0].serv).toBe('h')
expect(state.sp.striscia[0].ris).toBe('')
})
it('dovrebbe avere modalità 3/5 di default', () => {
@@ -45,6 +79,13 @@ describe('Game Logic (gameState.js)', () => {
expect(state.visuForm).toBe(false)
expect(state.visuStriscia).toBe(true)
})
it('dovrebbe avere timeout false e campi timeout nulli', () => {
expect(state.timeout).toBe(false)
expect(state.timeoutTeam).toBe(null)
expect(state.timeoutVideo).toBe(null)
expect(state.timeoutStartedAt).toBe(null)
})
})
// =============================================
@@ -69,43 +110,43 @@ describe('Game Logic (gameState.js)', () => {
describe('incPunt', () => {
it('dovrebbe incrementare i punti Home', () => {
const newState = applyAction(state, { type: 'incPunt', team: 'home' })
expect(newState.sp.punt.home).toBe(1)
expect(newState.sp.punt.guest).toBe(0)
expect(puntDi(newState).home).toBe(1)
expect(puntDi(newState).guest).toBe(0)
})
it('dovrebbe incrementare i punti Guest', () => {
const newState = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(newState.sp.punt.guest).toBe(1)
expect(newState.sp.punt.home).toBe(0)
expect(puntDi(newState).guest).toBe(1)
expect(puntDi(newState).home).toBe(0)
})
it('dovrebbe gestire il cambio palla (Guest segna, batteva Home)', () => {
state.sp.servHome = true
setServizioIniziale(state, 'home')
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(s1.sp.servHome).toBe(false)
expect(servDi(s1)).toBe(false)
})
it('dovrebbe gestire il cambio palla (Home segna, batteva Guest)', () => {
state.sp.servHome = false
setServizioIniziale(state, 'guest')
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
expect(s1.sp.servHome).toBe(true)
expect(servDi(s1)).toBe(true)
})
it('non dovrebbe cambiare palla se segna chi batte', () => {
state.sp.servHome = true
setServizioIniziale(state, 'home')
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
expect(s1.sp.servHome).toBe(true)
expect(servDi(s1)).toBe(true)
})
it('dovrebbe ruotare la formazione al cambio palla', () => {
state.sp.servHome = true
setServizioIniziale(state, 'home')
state.sp.form.guest = ["1", "2", "3", "4", "5", "6"]
const newState = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(newState.sp.form.guest).toEqual(["2", "3", "4", "5", "6", "1"])
})
it('non dovrebbe ruotare la formazione se non c\'è cambio palla', () => {
state.sp.servHome = true
setServizioIniziale(state, 'home')
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const newState = applyAction(state, { type: 'incPunt', team: 'home' })
expect(newState.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
@@ -113,26 +154,25 @@ describe('Game Logic (gameState.js)', () => {
it('dovrebbe aggiornare la striscia per punto Home', () => {
const s = applyAction(state, { type: 'incPunt', team: 'home' })
expect(s.sp.striscia.at(-1).r).toEqual(['home'])
expect(s.sp.striscia.at(-1).ris).toBe('h')
})
it('dovrebbe aggiornare la striscia per punto Guest', () => {
const s = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(s.sp.striscia.at(-1).r).toEqual(['guest'])
expect(s.sp.striscia.at(-1).ris).toBe('g')
})
it('dovrebbe registrare scorer nella striscia', () => {
let s = applyAction(state, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, { type: 'incPunt', team: 'home' })
expect(s.sp.striscia.at(-1).r).toEqual(['home', 'guest', 'home'])
expect(s.sp.striscia.at(-1).ris).toBe('hgh')
})
it('non dovrebbe incrementare i punti dopo vittoria', () => {
state.sp.punt.home = 25
state.sp.punt.guest = 23
setPunteggio(state, 25, 23)
const s = applyAction(state, { type: 'incPunt', team: 'home' })
expect(s.sp.punt.home).toBe(25)
expect(puntDi(s).home).toBe(25)
})
})
@@ -143,33 +183,33 @@ describe('Game Logic (gameState.js)', () => {
it('dovrebbe annullare l\'ultimo punto Home', () => {
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
const s2 = applyAction(s1, { type: 'decPunt' })
expect(s2.sp.punt.home).toBe(0)
expect(s2.sp.punt.guest).toBe(0)
expect(puntDi(s2).home).toBe(0)
expect(puntDi(s2).guest).toBe(0)
})
it('dovrebbe annullare l\'ultimo punto Guest', () => {
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
const s2 = applyAction(s1, { type: 'decPunt' })
expect(s2.sp.punt.home).toBe(0)
expect(s2.sp.punt.guest).toBe(0)
expect(puntDi(s2).home).toBe(0)
expect(puntDi(s2).guest).toBe(0)
})
it('non dovrebbe fare nulla sullo stato iniziale', () => {
const s = applyAction(state, { type: 'decPunt' })
expect(s.sp.punt.home).toBe(0)
expect(s.sp.punt.guest).toBe(0)
expect(puntDi(s).home).toBe(0)
expect(puntDi(s).guest).toBe(0)
})
it('dovrebbe ripristinare il servizio dopo undo con cambio palla', () => {
state.sp.servHome = true
setServizioIniziale(state, 'home')
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(s1.sp.servHome).toBe(false)
expect(servDi(s1)).toBe(false)
const s2 = applyAction(s1, { type: 'decPunt' })
expect(s2.sp.servHome).toBe(true)
expect(servDi(s2)).toBe(true)
})
it('dovrebbe invertire la rotazione dopo undo con cambio palla', () => {
state.sp.servHome = true
setServizioIniziale(state, 'home')
state.sp.form.guest = ["1", "2", "3", "4", "5", "6"]
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(s1.sp.form.guest).toEqual(["2", "3", "4", "5", "6", "1"])
@@ -180,7 +220,7 @@ describe('Game Logic (gameState.js)', () => {
it('dovrebbe ripristinare la striscia', () => {
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
const s2 = applyAction(s1, { type: 'decPunt' })
expect(s2.sp.striscia.at(-1).r).toEqual([])
expect(s2.sp.striscia.at(-1).ris).toBe('')
})
it('dovrebbe gestire undo multipli in sequenza', () => {
@@ -188,14 +228,66 @@ describe('Game Logic (gameState.js)', () => {
s = applyAction(s, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, { type: 'incPunt', team: 'home' })
expect(s.sp.punt.home).toBe(2)
expect(s.sp.punt.guest).toBe(1)
expect(puntDi(s).home).toBe(2)
expect(puntDi(s).guest).toBe(1)
s = applyAction(s, { type: 'decPunt' })
expect(s.sp.punt.home).toBe(1)
expect(puntDi(s).home).toBe(1)
s = applyAction(s, { type: 'decPunt' })
expect(s.sp.punt.guest).toBe(0)
expect(puntDi(s).guest).toBe(0)
s = applyAction(s, { type: 'decPunt' })
expect(s.sp.punt.home).toBe(0)
expect(puntDi(s).home).toBe(0)
})
})
// =============================================
// FORMAZIONE DI PARTENZA (formInizio)
// =============================================
describe('formInizio', () => {
it('dovrebbe salvare la formazione corrente al primo punto del set', () => {
const s = applyAction(state, { type: 'incPunt', team: 'home' })
expect(s.sp.striscia.at(-1).formInizio).toEqual({
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
})
})
it('formInizio è uno snapshot: la rotazione successiva non lo modifica', () => {
setServizioIniziale(state, 'home')
// 1° punto Home: nessun cambio palla, salva formInizio
let s = applyAction(state, { type: 'incPunt', team: 'home' })
// 2° punto Guest: cambio palla → ruota la formazione guest
s = applyAction(s, { type: 'incPunt', team: 'guest' })
expect(s.sp.form.guest).toEqual(["2", "3", "4", "5", "6", "1"])
// lo snapshot resta quello iniziale
expect(s.sp.striscia.at(-1).formInizio.guest).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('decPunt che riporta il set a 0-0 cancella formInizio', () => {
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
expect(s1.sp.striscia.at(-1).formInizio).toBeDefined()
const s2 = applyAction(s1, { type: 'decPunt' })
expect(s2.sp.striscia.at(-1).formInizio).toBeUndefined()
})
it('decPunt con punti ancora presenti NON cancella formInizio', () => {
let s = applyAction(state, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'decPunt' })
expect(s.sp.striscia.at(-1).ris).toBe('h')
expect(s.sp.striscia.at(-1).formInizio).toBeDefined()
})
it('ogni set mantiene la propria formInizio', () => {
const custom = ['7', '8', '9', '10', '11', '12']
let s = applyAction(state, { type: 'setFormazione', team: 'home', form: custom })
// primo punto del set 1: salva formInizio custom
s = applyAction(s, { type: 'incPunt', team: 'home' })
// chiude il set 1 e ne apre uno nuovo (formazioni resettate a default)
s = applyAction(s, { type: 'nuovoSet', team: 'home' })
// primo punto del set 2: salva formInizio default
s = applyAction(s, { type: 'incPunt', team: 'home' })
expect(s.sp.striscia[0].formInizio.home).toEqual(custom)
expect(s.sp.striscia.at(-1).formInizio.home).toEqual(['1', '2', '3', '4', '5', '6'])
})
})
@@ -205,24 +297,27 @@ describe('Game Logic (gameState.js)', () => {
describe('incSet', () => {
it('dovrebbe incrementare il set Home', () => {
const s = applyAction(state, { type: 'incSet', team: 'home' })
expect(s.sp.set.home).toBe(1)
expect(setDi(s).home).toBe(1)
})
it('dovrebbe incrementare il set Guest', () => {
const s = applyAction(state, { type: 'incSet', team: 'guest' })
expect(s.sp.set.guest).toBe(1)
expect(setDi(s).guest).toBe(1)
})
it('dovrebbe fare wrap da 2 a 0', () => {
state.sp.set.home = 2
const s = applyAction(state, { type: 'incSet', team: 'home' })
expect(s.sp.set.home).toBe(0)
let s = applyAction(state, { type: 'incSet', team: 'home' })
s = applyAction(s, { type: 'incSet', team: 'home' })
expect(setDi(s).home).toBe(2)
s = applyAction(s, { type: 'incSet', team: 'home' })
expect(setDi(s).home).toBe(0)
})
it('dovrebbe incrementare da 1 a 2', () => {
state.sp.set.home = 1
const s = applyAction(state, { type: 'incSet', team: 'home' })
expect(s.sp.set.home).toBe(2)
let s = applyAction(state, { type: 'incSet', team: 'home' })
expect(setDi(s).home).toBe(1)
s = applyAction(s, { type: 'incSet', team: 'home' })
expect(setDi(s).home).toBe(2)
})
})
@@ -231,31 +326,29 @@ describe('Game Logic (gameState.js)', () => {
// =============================================
describe('nuovoSet', () => {
it('dovrebbe incrementare il set della squadra vincente', () => {
state.sp.punt.home = 25
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(s.sp.set.home).toBe(1)
expect(s.sp.set.guest).toBe(0)
expect(setDi(s).home).toBe(1)
expect(setDi(s).guest).toBe(0)
})
it('dovrebbe azzerare i punti', () => {
state.sp.punt.home = 25
state.sp.punt.guest = 10
it('dovrebbe azzerare i punti nel nuovo set', () => {
setPunteggio(state, 25, 10)
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(s.sp.punt.home).toBe(0)
expect(s.sp.punt.guest).toBe(0)
expect(puntDi(s).home).toBe(0)
expect(puntDi(s).guest).toBe(0)
})
it('dovrebbe aggiungere un nuovo set vuoto alla striscia', () => {
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(s.sp.striscia).toHaveLength(2)
expect(s.sp.striscia.at(-1).r).toEqual([])
expect(s.sp.striscia.at(-1).serv).toBe('home')
expect(s.sp.striscia.at(-1).ris).toBe('')
expect(s.sp.striscia.at(-1).serv).toBe('h')
})
it('dovrebbe conservare il set precedente nella striscia', () => {
state.sp.striscia[0].r = ['home', 'guest', 'home']
state.sp.striscia[0].ris = 'hgh'
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(s.sp.striscia[0].r).toEqual(['home', 'guest', 'home'])
expect(s.sp.striscia[0].ris).toBe('hgh')
})
it('dovrebbe resettare le formazioni', () => {
@@ -268,8 +361,77 @@ describe('Game Logic (gameState.js)', () => {
it('dovrebbe ignorare team non valido', () => {
const s = applyAction(state, { type: 'nuovoSet', team: 'invalid' })
expect(s.sp.set.home).toBe(0)
expect(s.sp.set.guest).toBe(0)
expect(setDi(s).home).toBe(0)
expect(setDi(s).guest).toBe(0)
})
it('dovrebbe incrementare il set guest e servire guest nel nuovo set', () => {
const s = applyAction(state, { type: 'nuovoSet', team: 'guest' })
expect(setDi(s).home).toBe(0)
expect(setDi(s).guest).toBe(1)
expect(s.sp.striscia.at(-1).serv).toBe('g')
})
it('in 2/3 alla palla match registra il set vincente senza aprirne uno nuovo', () => {
state.modalitaPartita = '2/3'
setSetVinti(state, 1, 0)
setPunteggio(state, 25, 18)
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(setDi(s).home).toBe(2)
expect(puntDi(s).home).toBe(25)
expect(puntDi(s).guest).toBe(18)
})
it('in 3/5 alla palla match registra il set vincente senza aprirne uno nuovo', () => {
state.modalitaPartita = '3/5'
setSetVinti(state, 2, 0)
setPunteggio(state, 25, 20)
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(setDi(s).home).toBe(3)
expect(puntDi(s).home).toBe(25)
expect(puntDi(s).guest).toBe(20)
})
it('dovrebbe ignorare nuovoSet se la partita è già finita', () => {
state.modalitaPartita = '2/3'
setSetVinti(state, 2, 0)
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(setDi(s).home).toBe(2)
})
})
// =============================================
// VITTORIA PARTITA (checkVittoriaPartita)
// =============================================
describe('checkVittoriaPartita', () => {
it('in 2/3 restituisce false se nessuno ha 2 set', () => {
state.modalitaPartita = '2/3'
setSetVinti(state, 1, 0)
expect(checkVittoriaPartita(state)).toBe(false)
})
it('in 2/3 restituisce true se home ha 2 set', () => {
state.modalitaPartita = '2/3'
setSetVinti(state, 2, 0)
expect(checkVittoriaPartita(state)).toBe(true)
})
it('in 3/5 restituisce false se nessuno ha 3 set', () => {
state.modalitaPartita = '3/5'
setSetVinti(state, 2, 2)
expect(checkVittoriaPartita(state)).toBe(false)
})
it('in 3/5 restituisce true se guest ha 3 set', () => {
state.modalitaPartita = '3/5'
setSetVinti(state, 0, 3)
expect(checkVittoriaPartita(state)).toBe(true)
})
it('in amichevole restituisce sempre false', () => {
state.modalitaPartita = 'amichevole'
setSetVinti(state, 5, 0)
expect(checkVittoriaPartita(state)).toBe(false)
})
})
@@ -278,27 +440,29 @@ describe('Game Logic (gameState.js)', () => {
// =============================================
describe('cambiaPalla', () => {
it('dovrebbe invertire il servizio a 0-0', () => {
expect(state.sp.servHome).toBe(true)
expect(servDi(state)).toBe(true)
const s = applyAction(state, { type: 'cambiaPalla' })
expect(s.sp.servHome).toBe(false)
expect(servDi(s)).toBe(false)
})
it('dovrebbe tornare a Home con doppio toggle', () => {
let s = applyAction(state, { type: 'cambiaPalla' })
s = applyAction(s, { type: 'cambiaPalla' })
expect(s.sp.servHome).toBe(true)
expect(servDi(s)).toBe(true)
})
it('non dovrebbe cambiare palla se il punteggio non è 0-0', () => {
state.sp.punt.home = 1
setPunteggio(state, 1, 0)
const prima = servDi(state)
const s = applyAction(state, { type: 'cambiaPalla' })
expect(s.sp.servHome).toBe(true)
expect(servDi(s)).toBe(prima)
})
it('non dovrebbe cambiare palla se Guest ha punti', () => {
state.sp.punt.guest = 3
setPunteggio(state, 0, 3)
const prima = servDi(state)
const s = applyAction(state, { type: 'cambiaPalla' })
expect(s.sp.servHome).toBe(true)
expect(servDi(s)).toBe(prima)
})
})
@@ -329,6 +493,98 @@ describe('Game Logic (gameState.js)', () => {
const s = applyAction(state, { type: 'toggleOrder' })
expect(s.order).toBe(false)
})
})
// =============================================
// TIME OUT (startTimeout, stopTimeout)
// =============================================
describe('Time out', () => {
it('startTimeout dovrebbe attivare il time out con squadra, video e inizio', () => {
const s = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4', startedAt: 1000 })
expect(s.timeout).toBe(true)
expect(s.timeoutTeam).toBe('home')
expect(s.timeoutVideo).toBe('spot.mp4')
expect(s.timeoutStartedAt).toBe(1000)
})
it('startTimeout senza video dovrebbe lasciare timeoutVideo null', () => {
const s = applyAction(state, { type: 'startTimeout', team: 'guest', startedAt: 1000 })
expect(s.timeout).toBe(true)
expect(s.timeoutTeam).toBe('guest')
expect(s.timeoutVideo).toBe(null)
})
it('startTimeout senza startedAt dovrebbe lasciare timeoutStartedAt null', () => {
const s = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4' })
expect(s.timeout).toBe(true)
expect(s.timeoutStartedAt).toBe(null)
})
it('startTimeout senza squadra valida dovrebbe essere ignorato', () => {
for (const team of [undefined, null, 'arbitro', '']) {
const s = applyAction(state, { type: 'startTimeout', team, video: 'spot.mp4', startedAt: 1000 })
expect(s.timeout).toBe(false)
expect(s.timeoutTeam).toBe(null)
expect(s.timeoutVideo).toBe(null)
expect(s.timeoutStartedAt).toBe(null)
}
})
it('stopTimeout dovrebbe azzerare tutti i campi del time out', () => {
const attivo = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4', startedAt: 1000 })
const s = applyAction(attivo, { type: 'stopTimeout' })
expect(s.timeout).toBe(false)
expect(s.timeoutTeam).toBe(null)
expect(s.timeoutVideo).toBe(null)
expect(s.timeoutStartedAt).toBe(null)
})
it('stopTimeout su time out già inattivo non dovrebbe avere effetti', () => {
const s = applyAction(state, { type: 'stopTimeout' })
expect(s.timeout).toBe(false)
expect(s.timeoutTeam).toBe(null)
})
it('startTimeout dovrebbe registrare l\'evento nel set corrente con il punteggio', () => {
let s = state
for (let i = 0; i < 3; i++) s = applyAction(s, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 1000 })
expect(s.sp.striscia.at(-1).timeouts).toEqual([
{ team: 'guest', punteggio: { home: 3, guest: 1 } },
])
})
it('startTimeout su time out già attivo non dovrebbe registrare un secondo evento', () => {
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
s = applyAction(s, { type: 'startTimeout', team: 'home', startedAt: 2000 })
expect(s.sp.striscia.at(-1).timeouts).toHaveLength(1)
})
it('time out successivi dovrebbero accumularsi nel set corrente', () => {
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
s = applyAction(s, { type: 'stopTimeout' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 2000 })
expect(s.sp.striscia.at(-1).timeouts).toEqual([
{ team: 'home', punteggio: { home: 0, guest: 0 } },
{ team: 'guest', punteggio: { home: 0, guest: 1 } },
])
})
it('un nuovo set non dovrebbe ereditare i time out del precedente', () => {
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
s = applyAction(s, { type: 'stopTimeout' })
s = applyAction(s, { type: 'nuovoSet', team: 'home' })
expect(s.sp.striscia.at(-1).timeouts).toBeUndefined()
expect(s.sp.striscia.at(-2).timeouts).toHaveLength(1)
})
it('startTimeout ignorato non dovrebbe registrare eventi', () => {
const s = applyAction(state, { type: 'startTimeout', team: 'arbitro', startedAt: 1000 })
expect(s.sp.striscia.at(-1).timeouts).toBeUndefined()
})
})
// =============================================
@@ -401,6 +657,12 @@ describe('Game Logic (gameState.js)', () => {
// CAMBI GIOCATORI (confermaCambi)
// =============================================
describe('confermaCambi', () => {
it('senza array cambi non dovrebbe modificare la formazione', () => {
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, { type: 'confermaCambi', team: 'home' })
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('dovrebbe effettuare una sostituzione valida', () => {
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, {
@@ -502,6 +764,64 @@ describe('Game Logic (gameState.js)', () => {
expect(s.sp.form.home).not.toContain("1")
expect(s.sp.form.home).not.toContain("10")
})
it('dovrebbe registrare il cambio nel set corrente con il punteggio', () => {
let s = state
for (let i = 0; i < 2; i++) s = applyAction(s, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "10", out: "3" }]
})
expect(s.sp.striscia.at(-1).cambi).toEqual([
{ team: 'home', in: "10", out: "3", punteggio: { home: 2, guest: 1 } },
])
})
it('dovrebbe registrare ogni cambio di una doppia sostituzione', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'guest',
cambi: [
{ in: "10", out: "1" },
{ in: "11", out: "2" }
]
})
expect(s.sp.striscia.at(-1).cambi).toEqual([
{ team: 'guest', in: "10", out: "1", punteggio: { home: 0, guest: 0 } },
{ team: 'guest', in: "11", out: "2", punteggio: { home: 0, guest: 0 } },
])
})
it('non dovrebbe registrare nulla se la validazione fallisce', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "abc", out: "1" }]
})
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
})
it('non dovrebbe registrare nulla se tutti i cambi sono vuoti', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "", out: "" }]
})
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
})
it('un nuovo set non dovrebbe ereditare i cambi del precedente', () => {
let s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "10", out: "3" }]
})
s = applyAction(s, { type: 'nuovoSet', team: 'home' })
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
expect(s.sp.striscia.at(-2).cambi).toHaveLength(1)
})
})
// =============================================
@@ -509,44 +829,37 @@ describe('Game Logic (gameState.js)', () => {
// =============================================
describe('checkVittoria', () => {
it('non dovrebbe dare vittoria a 24-24', () => {
state.sp.punt.home = 24
state.sp.punt.guest = 24
setPunteggio(state, 24, 24)
expect(checkVittoria(state)).toBe(false)
})
it('dovrebbe dare vittoria a 25-23', () => {
state.sp.punt.home = 25
state.sp.punt.guest = 23
setPunteggio(state, 25, 23)
expect(checkVittoria(state)).toBe(true)
})
it('non dovrebbe dare vittoria a 25-24 (serve 2 punti di scarto)', () => {
state.sp.punt.home = 25
state.sp.punt.guest = 24
setPunteggio(state, 25, 24)
expect(checkVittoria(state)).toBe(false)
})
it('dovrebbe dare vittoria a 26-24', () => {
state.sp.punt.home = 26
state.sp.punt.guest = 24
setPunteggio(state, 26, 24)
expect(checkVittoria(state)).toBe(true)
})
it('dovrebbe dare vittoria Guest a 25-20', () => {
state.sp.punt.home = 20
state.sp.punt.guest = 25
setPunteggio(state, 20, 25)
expect(checkVittoria(state)).toBe(true)
})
it('dovrebbe dare vittoria ai vantaggi (30-28)', () => {
state.sp.punt.home = 30
state.sp.punt.guest = 28
setPunteggio(state, 30, 28)
expect(checkVittoria(state)).toBe(true)
})
it('non dovrebbe dare vittoria ai vantaggi senza scarto (28-27)', () => {
state.sp.punt.home = 28
state.sp.punt.guest = 27
setPunteggio(state, 28, 27)
expect(checkVittoria(state)).toBe(false)
})
})
@@ -557,82 +870,64 @@ describe('Game Logic (gameState.js)', () => {
describe('Set decisivo', () => {
it('modalità 3/5: set decisivo dopo 4 set totali → vittoria a 15', () => {
state.modalitaPartita = "3/5"
state.sp.set.home = 2
state.sp.set.guest = 2
state.sp.punt.home = 15
state.sp.punt.guest = 10
setSetVinti(state, 2, 2)
setPunteggio(state, 15, 10)
expect(checkVittoria(state)).toBe(true)
})
it('modalità 3/5: non vittoria a 14-10 nel set decisivo', () => {
state.modalitaPartita = "3/5"
state.sp.set.home = 2
state.sp.set.guest = 2
state.sp.punt.home = 14
state.sp.punt.guest = 10
setSetVinti(state, 2, 2)
setPunteggio(state, 14, 10)
expect(checkVittoria(state)).toBe(false)
})
it('modalità 3/5: vittoria a 15-13 nel set decisivo', () => {
state.modalitaPartita = "3/5"
state.sp.set.home = 2
state.sp.set.guest = 2
state.sp.punt.home = 15
state.sp.punt.guest = 13
setSetVinti(state, 2, 2)
setPunteggio(state, 15, 13)
expect(checkVittoria(state)).toBe(true)
})
it('modalità 3/5: non vittoria a 15-14 nel set decisivo (serve scarto)', () => {
state.modalitaPartita = "3/5"
state.sp.set.home = 2
state.sp.set.guest = 2
state.sp.punt.home = 15
state.sp.punt.guest = 14
setSetVinti(state, 2, 2)
setPunteggio(state, 15, 14)
expect(checkVittoria(state)).toBe(false)
})
it('modalità 3/5: vittoria a 16-14 nel set decisivo', () => {
state.modalitaPartita = "3/5"
state.sp.set.home = 2
state.sp.set.guest = 2
state.sp.punt.home = 16
state.sp.punt.guest = 14
setSetVinti(state, 2, 2)
setPunteggio(state, 16, 14)
expect(checkVittoria(state)).toBe(true)
})
it('modalità 2/3: set decisivo dopo 2 set totali → vittoria a 15', () => {
state.modalitaPartita = "2/3"
state.sp.set.home = 1
state.sp.set.guest = 1
state.sp.punt.home = 15
state.sp.punt.guest = 10
setSetVinti(state, 1, 1)
setPunteggio(state, 15, 10)
expect(checkVittoria(state)).toBe(true)
})
it('modalità 2/3: non vittoria a 24-20 nel set decisivo (soglia 15)', () => {
it('modalità 2/3: non vittoria a 14-10 nel set decisivo (soglia 15)', () => {
state.modalitaPartita = "2/3"
state.sp.set.home = 1
state.sp.set.guest = 1
state.sp.punt.home = 14
state.sp.punt.guest = 10
setSetVinti(state, 1, 1)
setPunteggio(state, 14, 10)
expect(checkVittoria(state)).toBe(false)
})
it('modalità 2/3: set non decisivo (1-0) → soglia 25', () => {
state.modalitaPartita = "2/3"
state.sp.set.home = 1
state.sp.set.guest = 0
state.sp.punt.home = 15
state.sp.punt.guest = 10
setSetVinti(state, 1, 0)
setPunteggio(state, 15, 10)
expect(checkVittoria(state)).toBe(false)
})
it('modalità 3/5: set non decisivo (2-1) → soglia 25', () => {
state.modalitaPartita = "3/5"
state.sp.set.home = 2
state.sp.set.guest = 1
state.sp.punt.home = 15
state.sp.punt.guest = 10
setSetVinti(state, 2, 1)
setPunteggio(state, 15, 10)
expect(checkVittoria(state)).toBe(false)
})
})
@@ -642,15 +937,13 @@ describe('Game Logic (gameState.js)', () => {
// =============================================
describe('Reset', () => {
it('dovrebbe resettare punti e set a zero', () => {
state.sp.punt.home = 10
state.sp.punt.guest = 8
state.sp.set.home = 1
state.sp.set.guest = 1
setSetVinti(state, 1, 1)
setPunteggio(state, 10, 8)
const s = applyAction(state, { type: 'resetta' })
expect(s.sp.punt.home).toBe(0)
expect(s.sp.punt.guest).toBe(0)
expect(s.sp.set.home).toBe(0)
expect(s.sp.set.guest).toBe(0)
expect(puntDi(s).home).toBe(0)
expect(puntDi(s).guest).toBe(0)
expect(setDi(s).home).toBe(0)
expect(setDi(s).guest).toBe(0)
})
it('dovrebbe resettare formazioni a default', () => {
@@ -661,10 +954,16 @@ describe('Game Logic (gameState.js)', () => {
})
it('dovrebbe resettare la striscia a un set vuoto', () => {
state.sp.striscia = [{ serv: 'home', r: ['home', 'guest', 'home'] }, { serv: 'home', r: ['guest'] }]
state.sp.striscia = [{ serv: 'h', ris: 'hgh', vinc: 'h' }, { serv: 'h', ris: 'g', vinc: null }]
const s = applyAction(state, { type: 'resetta' })
expect(s.sp.striscia).toHaveLength(1)
expect(s.sp.striscia[0].r).toEqual([])
expect(s.sp.striscia[0].ris).toBe('')
})
it('con striscia vuota dovrebbe usare "h" come servizio iniziale di default', () => {
state.sp.striscia = []
const s = applyAction(state, { type: 'resetta' })
expect(s.sp.striscia).toEqual([{ serv: 'h', ris: '', vinc: null }])
})
it('dovrebbe impostare visuForm a false', () => {
@@ -688,8 +987,8 @@ describe('Game Logic (gameState.js)', () => {
describe('Azione sconosciuta', () => {
it('dovrebbe restituire lo stato invariato con azione non riconosciuta', () => {
const s = applyAction(state, { type: 'azioneInesistente' })
expect(s.sp.punt.home).toBe(0)
expect(s.sp.punt.guest).toBe(0)
expect(puntDi(s).home).toBe(0)
expect(puntDi(s).guest).toBe(0)
})
})
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock dell'I/O su disco: i test non toccano il filesystem reale né dipendono
// dal path relativo a src/.
vi.mock('fs', () => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
existsSync: vi.fn(),
}))
import * as fs from 'fs'
import { loadState, saveState } from '../../src/persist.js'
import { createInitialState } from '../../src/gameState.js'
describe('Persistenza stato (persist.js)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('saveState', () => {
it('crea la directory e scrive lo stato serializzato', () => {
const state = createInitialState()
saveState(state)
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true })
expect(fs.writeFileSync).toHaveBeenCalled()
const [, contenuto, encoding] = fs.writeFileSync.mock.calls[0]
expect(JSON.parse(contenuto)).toEqual(state)
expect(encoding).toBe('utf8')
})
it('non lancia eccezioni se la scrittura fallisce', () => {
fs.mkdirSync.mockImplementation(() => { throw new Error('EACCES') })
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => saveState(createInitialState())).not.toThrow()
expect(errSpy).toHaveBeenCalled()
})
})
describe('loadState', () => {
it('legge e fa il parse di un file valido', () => {
const salvato = createInitialState()
salvato.sp.nomi.home = 'Squadra X'
fs.existsSync.mockReturnValue(true)
fs.readFileSync.mockReturnValue(JSON.stringify(salvato))
expect(loadState()).toEqual(salvato)
})
it('ritorna lo stato iniziale se il file non esiste', () => {
fs.existsSync.mockReturnValue(false)
expect(loadState()).toEqual(createInitialState())
})
it('ritorna lo stato iniziale se il JSON è corrotto', () => {
fs.existsSync.mockReturnValue(true)
fs.readFileSync.mockReturnValue('{ questo non è json')
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(loadState()).toEqual(createInitialState())
expect(warnSpy).toHaveBeenCalled()
})
})
})
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest'
import { buildRefertoHtml } from '../../src/referto.js'
import { createInitialState } from '../../src/gameState.js'
// Data fissa per asserzioni deterministiche
const NOW = new Date('2026-03-14T20:30:00')
// Costruisce uno stato con una striscia di set arbitraria
function statoConSet(striscia, extra = {}) {
const state = createInitialState()
state.sp.striscia = striscia
state.sp.nomi = { home: 'Antoniana', guest: 'Rivali' }
return { ...state, ...extra }
}
describe('buildRefertoHtml (referto.js)', () => {
it('esclude i set _phantom dal referto', () => {
const striscia = [
{ serv: 'h', ris: 'h'.repeat(25) + 'g'.repeat(20), vinc: 'h' },
{ serv: 'g', ris: '', vinc: 'g', _phantom: true },
{ serv: 'h', ris: 'h'.repeat(25) + 'g'.repeat(18), vinc: 'h' },
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
// due set reali → numeri "1" e "2" nella barra SET, mai "3"
expect(html).toContain('<span class="set-numero">1</span>')
expect(html).toContain('<span class="set-numero">2</span>')
expect(html).not.toContain('<span class="set-numero">3</span>')
})
it('calcola il punteggio finale di ogni set dalla ris', () => {
const striscia = [
{ serv: 'h', ris: 'h'.repeat(25) + 'g'.repeat(20), vinc: 'h' },
{ serv: 'h', ris: '', vinc: null },
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
// header del set: "Antoniana 25 · 20 Rivali"
expect(html).toContain('<strong>25</strong>')
expect(html).toContain('<strong>20</strong>')
})
it('conta i set vinti usando vinc', () => {
const striscia = [
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'g', ris: '', vinc: 'g' },
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'h', ris: '', vinc: null },
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
// risultato 2 1
expect(html).toContain('2 1')
})
it('ricava il vincitore dal conteggio punti se vinc è nullo', () => {
const striscia = [
{ serv: 'h', ris: 'h'.repeat(25) + 'g'.repeat(23), vinc: null },
{ serv: 'h', ris: '', vinc: null },
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
// il primo set, pur con vinc null, conta come vinto da home → 1 0
expect(html).toContain('1 0')
})
it('ricava il vincitore guest dal conteggio punti se vinc è nullo', () => {
const striscia = [
{ serv: 'h', ris: 'g'.repeat(25) + 'h'.repeat(20), vinc: null },
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
// conteggio punti guest > home, pur con vinc null → 0 1
expect(html).toContain('0 1')
})
it('include la progressione punto-punto con classi per squadra', () => {
const striscia = [
{ serv: 'h', ris: 'hhg', vinc: null },
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
expect(html).toContain('punto-h')
expect(html).toContain('punto-g')
expect(html).toContain('1-0')
expect(html).toContain('2-0')
expect(html).toContain('2-1')
})
it('rende la formazione di partenza quando presente', () => {
const striscia = [
{
serv: 'h', ris: 'h', vinc: null,
formInizio: { home: ['4', '8', '15'], guest: ['16', '23', '42'] },
},
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
expect(html).toContain('Formazione di partenza')
expect(html).toContain('>4<')
expect(html).toContain('>42<')
})
it('mostra "non disponibile" se manca formInizio', () => {
const striscia = [{ serv: 'h', ris: 'h', vinc: null }]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
expect(html).toContain('non disponibile')
})
it('mostra "Nessun punto registrato" per un set senza punti', () => {
const striscia = [{ serv: 'h', ris: '', vinc: null }]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
expect(html).toContain('Nessun punto registrato')
})
it('elenca time out e cambi del set con squadra e punteggio', () => {
const striscia = [{
serv: 'h', ris: 'hhhg', vinc: null,
timeouts: [{ team: 'guest', punteggio: { home: 3, guest: 1 } }],
cambi: [{ team: 'home', in: '10', out: '3', punteggio: { home: 2, guest: 0 } }],
}]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
expect(html).toContain('Cambi e time out')
expect(html).toContain('Time out <strong>Rivali</strong> sul 3-1')
expect(html).toContain('Cambio <strong>Antoniana</strong>: entra 10 per 3 sul 2-0')
})
it('ordina gli eventi per punteggio crescente', () => {
const striscia = [{
serv: 'h', ris: 'hhhgg', vinc: null,
timeouts: [{ team: 'home', punteggio: { home: 3, guest: 2 } }],
cambi: [{ team: 'guest', in: '9', out: '4', punteggio: { home: 1, guest: 0 } }],
}]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
const idxCambio = html.indexOf('entra 9 per 4')
const idxTimeout = html.indexOf('Time out')
expect(idxCambio).toBeGreaterThan(-1)
expect(idxTimeout).toBeGreaterThan(-1)
expect(idxCambio).toBeLessThan(idxTimeout)
})
it('omette la sezione eventi se il set non ne ha', () => {
const striscia = [{ serv: 'h', ris: 'h', vinc: null }]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
expect(html).not.toContain('Cambi e time out')
})
it('header contiene nomi squadre, modalità e data iniettata', () => {
const striscia = [{ serv: 'h', ris: '', vinc: null }]
const state = statoConSet(striscia)
state.modalitaPartita = '2/3'
const html = buildRefertoHtml(state, NOW)
expect(html).toContain('Antoniana')
expect(html).toContain('Rivali')
expect(html).toContain('Modalità: 2/3')
expect(html).toContain('14/03/2026')
})
})
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
// getNetworkIPs() senza argomenti dipende dalla piattaforma (fs, child_process,
// os.networkInterfaces): qui mockiamo quei moduli per esercitare in modo
// deterministico i rami WSL/non-WSL, altrimenti scoperti dagli altri test
// (che iniettano sempre `nets` per bypassare questo codice).
describe('server-utils.js — rilevamento piattaforma', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
vi.doUnmock('fs')
vi.doUnmock('child_process')
vi.doUnmock('os')
})
it('senza WSL usa os.networkInterfaces()', async () => {
vi.doMock('fs', () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(),
}))
vi.doMock('child_process', () => ({
execSync: vi.fn(),
}))
vi.doMock('os', () => ({
networkInterfaces: vi.fn(() => ({
eth0: [{ family: 'IPv4', internal: false, address: '10.0.0.9' }],
})),
}))
const { getNetworkIPs } = await import('../../src/server-utils.js')
expect(getNetworkIPs()).toEqual(['10.0.0.9'])
})
it('su WSL interroga PowerShell e filtra gli IP LAN', async () => {
vi.doMock('fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => 'Microsoft WSL2'),
}))
vi.doMock('child_process', () => ({
execSync: vi.fn(() => Buffer.from('192.168.1.42\n127.0.0.1\n172.17.0.1\n')),
}))
vi.doMock('os', () => ({
networkInterfaces: vi.fn(),
}))
const { getNetworkIPs } = await import('../../src/server-utils.js')
expect(getNetworkIPs()).toEqual(['192.168.1.42'])
})
it('su WSL se PowerShell fallisce restituisce array vuoto', async () => {
vi.doMock('fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => 'Microsoft WSL2'),
}))
vi.doMock('child_process', () => ({
execSync: vi.fn(() => { throw new Error('powershell non disponibile') }),
}))
vi.doMock('os', () => ({
networkInterfaces: vi.fn(),
}))
const { getNetworkIPs } = await import('../../src/server-utils.js')
expect(getNetworkIPs()).toEqual([])
})
it('se la lettura di /proc/sys/kernel/osrelease lancia, tratta la piattaforma come non-WSL', async () => {
vi.doMock('fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => { throw new Error('permesso negato') }),
}))
vi.doMock('child_process', () => ({
execSync: vi.fn(),
}))
vi.doMock('os', () => ({
networkInterfaces: vi.fn(() => ({})),
}))
const { getNetworkIPs } = await import('../../src/server-utils.js')
expect(getNetworkIPs()).toEqual([])
})
})
+36 -61
View File
@@ -1,14 +1,9 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import * as os from 'os'
import { getNetworkIPs, collectIPs, printServerInfo } from '../../src/server-utils.js'
vi.mock('os', async (importOriginal) => {
return {
...await importOriginal(),
networkInterfaces: vi.fn(() => ({}))
}
})
import { getNetworkIPs, printServerInfo } from '../../src/server-utils.js'
// Nota: gli IP vengono iniettati nei test (oggetto stile os.networkInterfaces o
// array di IP), così i risultati sono deterministici su qualsiasi piattaforma —
// incluso WSL, dove getNetworkIPs() userebbe altrimenti PowerShell.
describe('Server Utils', () => {
@@ -17,85 +12,73 @@ describe('Server Utils', () => {
})
// =============================================
// getNetworkIPs
// getNetworkIPs / collectIPs
// =============================================
describe('getNetworkIPs', () => {
it('dovrebbe restituire indirizzi IPv4 non-loopback', () => {
os.networkInterfaces.mockReturnValue({
eth0: [
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
]
})
expect(getNetworkIPs()).toEqual(['192.168.1.100'])
expect(getNetworkIPs({
eth0: [{ family: 'IPv4', internal: false, address: '192.168.1.100' }]
})).toEqual(['192.168.1.100'])
})
it('dovrebbe escludere indirizzi loopback (internal)', () => {
os.networkInterfaces.mockReturnValue({
lo: [
{ family: 'IPv4', internal: true, address: '127.0.0.1' }
],
eth0: [
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
]
const ips = getNetworkIPs({
lo: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
eth0: [{ family: 'IPv4', internal: false, address: '192.168.1.100' }]
})
const ips = getNetworkIPs()
expect(ips).not.toContain('127.0.0.1')
expect(ips).toContain('192.168.1.100')
})
it('dovrebbe escludere indirizzi IPv6', () => {
os.networkInterfaces.mockReturnValue({
const ips = getNetworkIPs({
eth0: [
{ family: 'IPv6', internal: false, address: 'fe80::1' },
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
]
})
const ips = getNetworkIPs()
expect(ips).toEqual(['192.168.1.100'])
})
it('dovrebbe escludere bridge Docker 172.17.x.x', () => {
os.networkInterfaces.mockReturnValue({
docker0: [
{ family: 'IPv4', internal: false, address: '172.17.0.1' }
],
eth0: [
{ family: 'IPv4', internal: false, address: '10.0.0.5' }
]
const ips = getNetworkIPs({
docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }],
eth0: [{ family: 'IPv4', internal: false, address: '10.0.0.5' }]
})
const ips = getNetworkIPs()
expect(ips).not.toContain('172.17.0.1')
expect(ips).toContain('10.0.0.5')
})
it('dovrebbe escludere bridge Docker 172.18.x.x', () => {
os.networkInterfaces.mockReturnValue({
br0: [
{ family: 'IPv4', internal: false, address: '172.18.0.1' }
]
expect(getNetworkIPs({
br0: [{ family: 'IPv4', internal: false, address: '172.18.0.1' }]
})).toEqual([])
})
expect(getNetworkIPs()).toEqual([])
it('dovrebbe escludere indirizzi link-local 169.254.x.x', () => {
expect(getNetworkIPs({
eth0: [{ family: 'IPv4', internal: false, address: '169.254.1.1' }]
})).toEqual([])
})
it('dovrebbe restituire array vuoto se nessuna interfaccia disponibile', () => {
os.networkInterfaces.mockReturnValue({})
expect(getNetworkIPs()).toEqual([])
expect(getNetworkIPs({})).toEqual([])
})
it('dovrebbe restituire più indirizzi da interfacce diverse', () => {
os.networkInterfaces.mockReturnValue({
eth0: [
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
],
wlan0: [
{ family: 'IPv4', internal: false, address: '192.168.1.101' }
]
const ips = getNetworkIPs({
eth0: [{ family: 'IPv4', internal: false, address: '192.168.1.100' }],
wlan0: [{ family: 'IPv4', internal: false, address: '192.168.1.101' }]
})
const ips = getNetworkIPs()
expect(ips).toHaveLength(2)
expect(ips).toContain('192.168.1.100')
expect(ips).toContain('192.168.1.101')
})
it('collectIPs gestisce input vuoto/undefined senza errori', () => {
expect(collectIPs()).toEqual([])
expect(collectIPs({})).toEqual([])
})
})
// =============================================
@@ -103,9 +86,8 @@ describe('Server Utils', () => {
// =============================================
describe('printServerInfo', () => {
it('dovrebbe stampare la porta di default (3000)', () => {
os.networkInterfaces.mockReturnValue({})
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
printServerInfo()
printServerInfo(3000, [])
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
expect(allLogs).toContain('3000')
expect(allLogs).toContain('/display')
@@ -114,22 +96,16 @@ describe('Server Utils', () => {
})
it('dovrebbe stampare la porta personalizzata', () => {
os.networkInterfaces.mockReturnValue({})
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
printServerInfo(8080)
printServerInfo(8080, [])
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
expect(allLogs).toContain('8080')
consoleSpy.mockRestore()
})
it('dovrebbe mostrare gli URL remoti se ci sono IP di rete', () => {
os.networkInterfaces.mockReturnValue({
eth0: [
{ family: 'IPv4', internal: false, address: '192.168.1.50' }
]
})
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
printServerInfo(3000)
printServerInfo(3000, ['192.168.1.50'])
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
expect(allLogs).toContain('192.168.1.50')
expect(allLogs).toContain('remoti')
@@ -137,9 +113,8 @@ describe('Server Utils', () => {
})
it('non dovrebbe mostrare sezione remoti se nessun IP di rete', () => {
os.networkInterfaces.mockReturnValue({})
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
printServerInfo(3000)
printServerInfo(3000, [])
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
expect(allLogs).not.toContain('remoti')
consoleSpy.mockRestore()
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { listSpotVideos } from '../../src/spot-utils.js'
describe('listSpotVideos (spot-utils.js)', () => {
let dir
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'spot-test-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('ritorna [] se la cartella non esiste', () => {
expect(listSpotVideos(join(dir, 'inesistente'))).toEqual([])
})
it('ritorna [] se la cartella è vuota', () => {
expect(listSpotVideos(dir)).toEqual([])
})
it('elenca solo i file .mp4, ordinati alfabeticamente', () => {
writeFileSync(join(dir, 'b.mp4'), '')
writeFileSync(join(dir, 'a.mp4'), '')
writeFileSync(join(dir, 'note.txt'), '')
writeFileSync(join(dir, 'foto.png'), '')
expect(listSpotVideos(dir)).toEqual(['a.mp4', 'b.mp4'])
})
it('riconosce l\'estensione .mp4 senza distinzione di maiuscole', () => {
writeFileSync(join(dir, 'SPOT.MP4'), '')
expect(listSpotVideos(dir)).toEqual(['SPOT.MP4'])
})
})
+16
View File
@@ -1,7 +1,13 @@
import { WebSocketServer } from 'ws'
import express from 'express'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js'
import { loadState, saveState } from './src/persist.js'
import { listSpotVideos } from './src/spot-utils.js'
const SPOT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'spot')
export default function websocketPlugin() {
return {
@@ -10,6 +16,16 @@ export default function websocketPlugin() {
const wss = new WebSocketServer({ noServer: true })
setupWebSocketHandler(wss, { initialState: loadState(), onStateChange: saveState })
// Video spot del time out: stesso comportamento di server.js (elenco + statici)
server.middlewares.use('/spot', (req, res, next) => {
if (req.url === '/list' || req.url === '/list/') {
res.setHeader('Content-Type', 'application/json')
return res.end(JSON.stringify(listSpotVideos(SPOT_DIR)))
}
next()
})
server.middlewares.use('/spot', express.static(SPOT_DIR))
// Rewrite /display → / (index.html) e /controller → /controller.html
server.middlewares.use((req, _res, next) => {
if (req.url === '/display' || req.url === '/display/') req.url = '/'