95 Commits

Author SHA1 Message Date
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
davide b1a400cf81 refactor: rimuove terminal controller CLI 2026-05-12 14:54:40 +02:00
davide 4bfc12fb00 docs(readme): riscrive documentazione con guida utente e architettura 2.0.0 2026-05-12 14:49:11 +02:00
davide 496266039b chore: versione 2.0.0 2026-05-12 14:43:48 +02:00
davide 0e49d361fe docs(changelog): aggiunge versione 2.0.0 2026-05-12 14:42:55 +02:00
davide 9bbf303be9 chore(docker): usa immagine da registro Gitea v1.0.0 2026-05-12 14:38:42 +02:00
davide f38c0eaf72 chore(docker): ristruttura Dockerfile e docker-compose per produzione
- Multi-stage build: builder (npm ci + vite build) + runtime minimale
- Immagine runtime senza devDependencies e senza sorgenti frontend
- docker-compose: porta singola 3000, volume .segnapunti per persistenza stato
- Aggiunge .dockerignore per escludere node_modules, test, dist dal contesto
2026-05-12 14:21:36 +02:00
davide 1a43864919 chore: rimuove script e dipendenze inutilizzati in dev
- Rimuove script preview e start (duplicati di serve)
- Rimuove dipendenza concurrently (mai usata)
- Aggiunge persistenza stato al plugin dev WebSocket
2026-05-12 14:17:19 +02:00
davide 15dac9f965 feat(persist): salva stato su .segnapunti/state.json ad ogni azione
All'avvio il server carica lo stato dal file se esiste; ad ogni azione
lo riscrive. Il riavvio del server riprende dall'ultimo punto salvato.
2026-05-12 14:08:10 +02:00
davide 0ba49ead5d chore: rimuove dipendenze e file inutilizzati
- Rimuove wave-ui e vue-router (mai usati nell'app)
- Elimina playwright.config.ts (duplicato di .cjs)
- Elimina asset template Vite (vite.svg, vue.svg, serve.png)
- Sostituisce favicon con segnap-192x192.png
- Aggiunge dev-dist/ a .gitignore
- Rimuove selettori CSS .w-input__* (morti senza wave-ui)
2026-05-12 14:00:16 +02:00
davide c900153eed refactor(striscia): nuova struttura array-di-set, elimina storicoServizio
La striscia diventa un array di set: ogni elemento è { serv, r[] }
dove r è la sequenza di scorer ('home'|'guest') del set.

- Un rally = un elemento in r: minimo non-derivabile
- Tutti i set (passati e corrente) sono conservati nell'array
- Dal set corrente si derivano: punteggio, servizio, cambio palla, rotazione
- Dal set completo si derivano: vincitore (r.at(-1)), score finale (count)
- storicoServizio eliminato: l'undo legge l'entry precedente di r

DisplayPage calcola le strip visive (home/guest) tramite computed
da striscia.at(-1).r senza dati ridondanti nel modello.
2026-05-12 13:49:51 +02:00
davide 5f9e37062c fix(controller): aggiunge tasto indietro nel dialog set vinto
Permette di annullare l'ultimo punto (decPunt) nel caso in cui
l'ultimo punto del set sia stato assegnato per errore.
2026-05-12 13:26:19 +02:00
davide 3188994299 feat(controller): dialog set vinto con transizione automatica al set successivo
Quando una squadra raggiunge il punteggio di vittoria (25 con +2 di
scarto, 15 nel set decisivo), il controller mostra un dialog "SET VINTO"
con il nome della squadra vincente.

Alla conferma: invia l'azione nuovoSet (incrementa il set, azzera punti,
striscia, storico servizio e formazioni) e apre automaticamente il dialog
di configurazione per inserire le formazioni del set successivo.
2026-05-12 13:19:02 +02:00
davide eec4ef0526 chore: aggiunge CLAUDE.md con architettura e comandi del progetto 2026-05-12 12:22:42 +02:00
davide 16a3fb912a fix(gameState): striscia simmetrica + structuredClone + cambioPalla dedup
- Inizializza striscia con { home: [0], guest: [0] } invece di usare
  [" "] per il team non servente; corregge anche reset e cambiaPalla.
- Sostituisce JSON.parse/stringify con structuredClone (nativo, più veloce).
- Calcola cambioPalla una sola volta in incPunt invece di due volte.
2026-05-12 12:22:28 +02:00
davide 2fe1808fc9 refactor(server): porta singola, /display e /controller come percorsi
Unifica i due server Express (display :3000, controller :3001) in un
unico processo su PORT (default 3000). Le route /display e /controller
servono rispettivamente index.html e controller.html.

In sviluppo elimina il server proxy su :3001; il plugin Vite riscrive
/display → / e /controller → /controller.html internamente.
printServerInfo aggiornata alla firma a porta singola.
2026-05-12 12:22:50 +02:00
davide b3d114c108 chore(deps): risolve tutte le vulnerabilità npm
- vite 4 → 7.3.1 (fix esbuild/rollup CVE)
- @vitejs/plugin-vue 4 → 6.0.5 (compatibilità vite 7)
- vite-plugin-pwa 0.16 → 1.2.0 (compatibilità vite 7)
- override serialize-javascript → ^7.0.5 (fix CVE via workbox-build)

0 vulnerabilità rimanenti (erano 24).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:25:46 +02:00
davide b9aed683c6 test(cli): aggiunge suite unit per il terminal controller
32 test che coprono registrazione WebSocket, parsing di tutti i comandi
(shortcut inclusi), validazioni input, conferma reset e gestione uscita.
2026-04-01 19:19:21 +02:00
davide 606b2c1ee6 feat(cli): aggiunge terminal controller da riga di comando
Nuovo script cli.js che si connette al server via WebSocket come
controller e permette di gestire la partita da terminale con comandi
testuali, colori ANSI, tab-completion e history dei comandi.

Aggiunge script npm "cli" / "cli:dev" e documenta tutti i comandi nel README
2026-04-01 19:12:09 +02:00
davide 27e29a78e7 aggiorna README 2026-04-01 18:58:02 +02:00
davide d1e8279608 feat(striscia): nomi fissi a sinistra, punti scorrono verso destra
- Layout ora usa CSS Grid (max-content 1fr) per allineare le colonne
  dei nomi indipendentemente dalla loro lunghezza
- I punti crescono da sinistra verso destra; un watcher Vue imposta
  scrollLeft al massimo ad ogni aggiornamento, mantenendo visibili
  gli ultimi punti a destra quando la striscia va oltre lo schermo
- Le celle vuote (spazio al posto del punto) non mostrano più
  lo sfondo giallo-verde (classe item-vuoto)
2026-02-21 11:19:38 +01:00
davide 6bc74ab3e0 fix(striscia): mostra lo zero iniziale solo per la squadra che serve
- All'inizio del set lo 0 compare solo nella riga della squadra che batte;
  la squadra non servente mostra uno spazio per mantenere l'allineamento dei nomi
- cambiaPalla aggiorna la striscia dopo il flip del servizio (a 0-0)
- resetta inizializza la striscia in base al servente corrente anziché mostrare 0 per entrambe
- Corretto il guard dell'undo: usa storicoServizio.length > 0 invece di
  striscia.home.length > 1, che avrebbe bloccato l'annulla quando la riga
  della squadra non servente contiene un solo elemento
2026-02-20 18:25:44 +01:00
attilio 668140e5b7 Aggiunge Dockerfile e compose.yaml
Vengono usate 3000 dispplay e 3001 controller.
2026-02-15 18:58:29 +01:00
davide aa88e2b7a1 Aggiorna README.md 2026-02-12 19:44:17 +01:00
davide e4d212eea3 docs(tests): riscrive la guida test con approccio per principianti
- spiega obiettivi e differenze tra unit, integration, component, stress ed E2E
- aggiunge istruzioni passo-passo per esecuzione e lettura risultati
- documenta gestione snapshot visual e troubleshooting errori comuni
- include checklist pratica pre-push
2026-02-12 19:35:22 +01:00
davide 33e2583b4d fix(ui): migliora accessibilita icone servizio e contrasto del pulsante reset
- aggiunge attributi alt alle icone di servizio su Display e Controller
- migliora il contrasto colore del pulsante Reset per rispettare i controlli a11y
- include piccoli aggiustamenti collegati ai test E2E/accessibility
2026-02-12 19:34:17 +01:00
davide be286ec069 test(e2e): migra gli end-to-end a CommonJS e stabilizza l'esecuzione Playwright
- aggiunge configurazione playwright.config.cjs per compatibilita runtime
- aggiorna playwright.config.ts con progetto Mobile Chrome
- migra i test E2E da .js a .spec.cjs
- rimuove i vecchi file E2E non piu usati
- allinea i test visual con snapshot baseline aggiornate
2026-02-12 19:33:54 +01:00
davide 0b154d9e56 test(vitest): amplia la suite con test unitari, integrazione, componenti e stress
- aggiunge test per gameState e utilita server
- aggiunge test di integrazione WebSocket
- aggiunge test componenti Vue (ControllerPage/DisplayPage)
- aggiunge test stress su carico WebSocket
- aggiorna configurazione Vitest per includere nuove cartelle e ambiente componenti
- aggiorna script npm e dipendenze di test
2026-02-12 19:33:29 +01:00
davide 71119da727 feat(test): implementazione infrastruttura completa (Unit, Integration, E2E) con Vitest e Playwright
- Introduce Vitest per Unit e Integration Test.
- Introduce Playwright per End-to-End Test.
- Aggiuge documentazione dettagliata in tests/README.md.
- Aggiorna .gitignore per escludere i report di coverage
2026-02-12 15:13:04 +01:00
davide 331ab0bbeb fix(prod): correzione preview script e routing server produzione
- Modificato [package.json](cci:7://file:///home/davide/segnapunti/package.json:0:0-0:0): lo script "preview" ora esegue "node server.js" per abilitare il backend WebSocket.
- Aggiornato [server.js](cci:7://file:///home/davide/segnapunti/server.js:0:0-0:0): impedito al Controller di servire la Display App per default (opzione `index: false`).
- Corretta sintassi rotte: sostituito `*` con regex `/.*/` per compatibilità con Express 5
2026-02-12 14:11:05 +01:00
davide 94a0b0735f feat(controller): valida cambi giocatori già in formazione lato client 2026-02-12 00:30:36 +01:00
davide 581a567c17 fix(voce): riproduce la sintesi vocale sul display invece che sul controller
Il controller invia un comando 'speak' via WebSocket. Il server inoltra il messaggio solo ai client display, che eseguono speechSynthesis con preferenza per voce italiana.
2026-02-11 19:35:09 +01:00
davide 43194c4fbe Merge branch 'wip-client-server' 2026-02-11 08:02:14 +01:00
davide 917850502d fix(dev): evita problemi IPv6 di localhost su ws e proxy controller
Usa fallback a 127.0.0.1 quando l'hostname è localhost/::1 nei client websocket display/controller.

Instrada il proxy del controller dev verso Vite tramite DEV_PROXY_HOST (default 127.0.0.1).

Mostra gli URL locali del server con 127.0.0.1 per una diagnostica coerente su Raspberry/Linux.
2026-02-11 00:52:06 +01:00
davide ad7a8575c6 feat(client): aggiungi supporto query parameter wsHost per WebSocket
Permette di specificare manualmente l'host del WebSocket tramite il
parametro ?wsHost=[host:port], utile per scenari di sviluppo con WSL2
o quando si accede da dispositivi remoti.

- Aggiunge parsing del parametro wsHost in DisplayPage e ControllerPage
- Mantiene fallback automatico a location.host se non specificato
- Migliora diagnostica con log della URL WebSocket effettiva
2026-02-11 00:37:31 +01:00
davide f84f3805cd feat: separazione display e controller su porte distinte (5173/3001)
- Creati entry point separati per il Display (porta 5173) e il Controller (porta 3001).
- Aggiunti controller.html e src/controller-main.js per l'app di controllo remoto.
- Semplificato src/main.js per montare direttamente DisplayPage, rimuovendo vue-router.
- Implementato un server di sviluppo proxy per il controller in vite-plugin-websocket.js.
- Aggiornato server.js per gestire due istanze Express (display e controller) in produzione.
- Aggiornata la configurazione di Vite per il supporto alla build multi-pagina
2026-02-10 23:45:58 +01:00
davide 9598d587c6 chore(dev): aggiorna workflow locale e configurazione Vite
Introduce script di sviluppo concorrenti (frontend + server) con concurrently.

Aggiorna dipendenze lockfile e rimuove dipendenze non più necessarie.

Aggiunge configurazione server/proxy Vite e include plugin WebSocket dedicato.
2026-02-10 09:54:38 +01:00
davide f44138efd3 chore(cleanup): rimuove l'implementazione HomePage legacy non più utilizzata
Elimina componenti, template e stili della vecchia HomePage.

Riduce codice morto e semplifica la manutenzione del progetto.
2026-02-10 09:54:31 +01:00
davide 082a52dc3e feat(client): migliora robustezza connessioni WebSocket su display e controller
Aggiunge gestione riconnessione con backoff esponenziale e protezione da reconnect multipli.

Migliora cleanup su unmount/HMR per evitare listener e timeout pendenti.

Uniforma gestione errori e stato connessione lato client.

Semplifica etichette pulsanti controller rimuovendo emoji e aggiorna commenti.
2026-02-10 09:54:10 +01:00
davide f7c4fdc2ef refactor(server): separa la logica WebSocket e centralizza le utility di avvio
Estrae la gestione dei messaggi WebSocket in un modulo dedicato.

Rende server.js più snello e focalizzato su bootstrap HTTP/WS.

Introduce utility per stampa URL di accesso e discovery IP di rete.

Mantiene la logica di stato partita condivisa in gameState.js.
2026-02-10 09:53:46 +01:00
davide a40fad7194 Separa app in client-server con WebSocket
- Aggiunto server Express + WebSocket (server.js)
- Creata pagina Display (solo visualizzazione punteggio)
- Creata pagina Controller (pannello comandi da mobile)
- Aggiunto Vue Router con rotte / e /controller
- Estratta logica di gioco condivisa in gameState.js
2026-02-10 00:42:48 +01:00
78 changed files with 12519 additions and 5965 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
}
}
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
dev-dist
.segnapunti
tests
playwright-report
test-results
*.md
.git
.gitignore
.vscode
+13
View File
@@ -13,6 +13,9 @@ currentCommit.txt
node_modules
dist
dist-ssr
dev-dist
.segnapunti
spot
*.local
# Editor directories and files
@@ -25,3 +28,13 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
# Vitest
coverage/
+29
View File
@@ -7,6 +7,35 @@ e questo progetto aderisce al [Versionamento Semantico](https://semver.org/lang/
---
## [2.0.0] - 2026-05-12
### Aggiunto
- Architettura client-server con WebSocket: server Express (`server.js`) + handler (`src/websocket-handler.js`) come unica fonte di verità; display e controller sono client separati sincronizzati in tempo reale
- Interfaccia display (`/display`) e controller (`/controller`) su porta singola `:3000`
- Robustezza connessione WebSocket: reconnect automatico con backoff esponenziale, indicatore stato connessione sul display
- Supporto query parameter `?wsHost=` per scenari WSL2 / development remoto
- Validazione cambi giocatori già in formazione lato client
- Sintesi vocale inoltrata dal controller al display via WebSocket
- Dialog set vinto sul controller al raggiungimento dei 25 punti: mostra il vincitore, permette di annullare l'ultimo punto (INDIETRO) o avanzare al set successivo con reset automatico formazioni
- Struttura striscia ottimizzata: array di set `[{ serv, r[] }]` che registra la sequenza dei punti e preserva la storia di tutti i set; elimina `storicoServizio`
- Persistenza stato su `.segnapunti/state.json`: salvato ad ogni azione, ricaricato all'avvio del server
- Suite di test completa: unit (Vitest), integration, component (Vue Test Utils + Happy-DOM), stress (50+ client), E2E (Playwright su Chromium, Firefox, Mobile Chrome)
- Dockerfile multi-stage (builder + runtime minimale) e docker-compose con volume per persistenza stato; immagine pubblicata su registro Gitea
### Modificato
- `applyAction` usa `structuredClone` al posto di `JSON.parse/stringify`
- Calcolo cambio palla deduplicato in `applyAction`
- Undo punto (`decPunt`) ricostruisce il servizio precedente dalla storia `r[]`
- `nuovoSet` come azione dedicata per la progressione regolare tra set
### Rimosso
- Terminal controller CLI (`cli.js`)
- Dipendenze inutilizzate: `wave-ui`, `vue-router`, `concurrently`
- Script npm ridondanti: `preview`, `start`, `cli`, `cli:dev`
- Asset template Vite: `vite.svg`, `vue.svg`, `serve.png`
---
## [1.0.0] - 2026-02-10
Rilascio iniziale di **Segnapunti Anto**, un'applicazione web Progressive Web App (PWA) professionale per il tracciamento in tempo reale dei punteggi durante partite di pallavolo.
+100
View File
@@ -0,0 +1,100 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Scopo del progetto
**Segnapunti Anto** è una PWA per il segnapunti pallavolo in tempo reale. Un server Express/WebSocket gestisce lo stato di gioco; due interfacce Vue separate — **display** (tabellone pubblico) e **controller** (pannello operatore) — si sincronizzano via WebSocket.
## Comandi
```bash
npm run dev # Vite dev server — display: :5173/display, controller: :5173/controller
npm run serve # Build + avvio produzione — display: :3000/display, controller: :3000/controller
npm run test # Vitest in modalità watch
npm run test:all # Tutte le suite Vitest in una sola esecuzione
npm run test:unit # Solo unit + integration
npm run test:component # Test 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
```
Per eseguire un singolo file di test: `npx vitest run tests/unit/gameState.test.js`
## Architettura
```
Controller (Vue) ──WebSocket──┐
Display (Vue) ──WebSocket──┤── websocket-handler.js ── gameState.js
│ │
│ └── persist.js ── .segnapunti/state.json
```
**Tutta la logica di gioco** è in `src/gameState.js` come funzioni pure esportate:
- `createInitialState()` — restituisce lo stato iniziale
- `applyAction(state, action)` — reducer immutabile (deep-clone via `structuredClone`)
- `checkVittoria(state)` — condizioni di vittoria set (25 punti, vantaggio di 2; 15 punti nel set decisivo)
- `punteggio` / `setVinti` / `servizio` — derivano punteggio corrente, set vinti e chi serve dalla `striscia` (lo stato NON memorizza questi valori direttamente)
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.
Ogni voce della `striscia` accumula anche lo storico di cambi e 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`: array di `{ team, punteggio: { home, guest } }`, appeso da `startTimeout` (solo se il time out non è già attivo, per non duplicare l'evento su un restart).
- `set.cambi`: array di `{ team, in, out, punteggio }`, uno per ogni sostituzione effettivamente applicata da `confermaCambi` (nulla viene registrato se la validazione del cambio fallisce).
`src/websocket-handler.js` riceve i messaggi WebSocket, valida che il mittente sia un `controller` (non un `display`), chiama `applyAction`, fa il broadcast del nuovo stato a tutti i client, poi invoca `onStateChange` per persistere su disco.
`src/wsMixin.js` è il mixin Vue lato client: gestisce connessione/riconnessione WebSocket (backoff esponenziale) ed espone i computed `punt`/`servHome`/`set`.
`src/referto.js` genera il referto di gara: `buildRefertoHtml(state, now)` è una funzione pura che restituisce l'HTML (testabile), mentre `generaReferto(state)` è il wrapper che apre la finestra con `window.open` e avvia la stampa. Per ogni set, oltre alla formazione di partenza e alla griglia punti, mostra (se presenti) l'elenco di `set.timeouts` e `set.cambi` ordinato per punteggio crescente.
`src/persist.js` carica/salva lo stato su `.segnapunti/state.json`. `src/server-utils.js` ricava gli IP di rete (`getNetworkIPs`, con sorgente iniettabile) e stampa gli URL all'avvio. `src/spot-utils.js` espone `listSpotVideos(spotDir)`, che elenca i `.mp4` (ordinati) della cartella `spot/`; ritorna `[]` se la cartella manca.
**Time out / spot video** — le action `startTimeout` (richiede `team`, `video` opzionale) e `stopTimeout` impostano/azzerano `state.timeout`, `timeoutTeam`, `timeoutVideo` e `timeoutStartedAt`. Il timestamp `startedAt` lo aggiunge `websocket-handler.js` (non il client), così `gameState.js` resta puro e deterministico; lo stesso handler arma un timer server-side di 30 secondi che invia automaticamente `stopTimeout` (sopravvive a refresh o disconnessione del controller). Sul controller il pulsante Time Out apre una modal per scegliere la squadra e uno dei video `.mp4` della cartella `spot/` nella root (non versionata), oppure nessun video; durante il time out il pulsante mostra il countdown e ripremerlo chiude in anticipo. Il display copre il tabellone con un overlay a tutto schermo con countdown: riproduce il video scelto (riallineandosi al punto giusto in caso di riconnessione a metà time out) e ripiega sulla scritta "TIME OUT <squadra>" se non c'è video o se il video finisce prima dei 30 secondi, con dissolvenza nell'ultimo secondo. La lista è esposta dall'endpoint HTTP `/spot/list` (JSON) e i file sono serviti staticamente da `/spot/<file>.mp4`, sia in produzione (`server.js`) sia in sviluppo (`vite-plugin-websocket.js`). I video partono con audio; se il browser blocca l'autoplay con suono si ripiega automaticamente su muto — per avere l'audio reale il browser del display (schermo senza input) 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 entrambe le interfacce sulla porta 3000: `/display``dist/index.html`, `/controller``dist/controller.html`. Singolo endpoint WebSocket su `/ws`. L'avvio automatico parte solo se il file è eseguito come entrypoint.
In sviluppo, `vite-plugin-websocket.js` incorpora il server WebSocket dentro il dev server Vite con middleware di URL rewrite per `/display` e `/controller`.
## Vincoli di design
- **Tutta la logica sul server** — 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 possono inviare azioni.
- **Stato immutabile** — `applyAction` non muta mai lo stato, restituisce sempre un nuovo oggetto.
- **Un solo controller** — il design prevede un controller e un display attivi; non esiste conflict resolution per controller simultanei.
- **Stato persistente** — `src/persist.js` salva lo stato in `.segnapunti/state.json` dopo ogni azione e lo carica all'avvio del server.
## 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) |
I test E2E girano in serie (`workers: 1`) per evitare race condition sullo stato WebSocket. Eseguire `npm run serve` prima di `npm run test:e2e`.
La copertura statement/linee della suite 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), si mocka il modulo con `vi.mock`/`vi.doMock` (vedi `tests/integration/startServer.test.js` e `tests/unit/server-utils-platform.test.js`) invece di eseguire il codice reale.
## Deploy
Il progetto si distribuisce tramite Docker. L'immagine è pubblicata sul registry Gitea self-hosted (`santantonio.sytes.net`):
```bash
docker compose up -d # Avvia con immagine dal registry privato (porta 3000)
```
Il volume `./.segnapunti` persiste lo stato tra i riavvii del container.
## Istruzioni operative
- Per ogni nuova funzionalità: analizza come si inserisce nel flusso `action → applyAction → broadcast`, poi decidi se aggiungere un nuovo tipo di action o estendere uno esistente.
- Qualsiasi modifica alle regole di gioco va fatta esclusivamente in `src/gameState.js`.
- Qualsiasi modifica al protocollo WebSocket va fatta in `src/websocket-handler.js`.
- Aggiungere test unit in `tests/unit/gameState.test.js` per ogni nuovo action type o regola di gioco.
- Il codice deve essere scritto in italiano per commenti e nomi di variabili di dominio (es. `servHome`, `striscia`, `nomi`), ma in inglese per nomi tecnici standard (`state`, `action`, `handler`).
- Non inserire emoji in codice, UI (label, testi dei bottoni, banner) o commenti, a meno che l'utente non le richieda esplicitamente.
+22
View File
@@ -0,0 +1,22 @@
# Stage 1: build frontend
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000
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/spot-utils.js ./src/
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "server.js"]
+219 -213
View File
@@ -1,255 +1,261 @@
# Segnapunti Anto
# Segnapunti
Applicazione web **Progressive Web App (PWA)** per tracciare i punteggi di partite di pallavolo in tempo reale.
![Version](https://img.shields.io/badge/versione-2.0.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)
Segnapunti digitale in tempo reale per partite di pallavolo. Un server centrale gestisce lo stato della partita; un **display** mostra il tabellone pubblico e un **controller** (smartphone o tablet) permette all'operatore di gestire punti, formazioni e cambi.
---
## Panoramica
## Indice
**Segnapunti Anto** è un'applicazione digitale per il tracciamento dei punteggi durante partite di pallavolo, ottimizzata per l'uso su tablet e smartphone. Sviluppata per il team Antoniana, l'app fornisce un'interfaccia fullscreen touch-friendly con supporto offline e controlli da tastiera.
### Funzionalità Principali
- **Gestione Completa Partite**
- Tracciamento punti in tempo reale per entrambe le squadre
- Conteggio automatico dei set (modalità 2/3 o 3/5)
- Indicatore visivo del servizio
- Blocco incremento punti a set concluso
- Cronologia punti con striscia visiva
- **Formazioni Squadra**
- Visualizzazione interattiva dei 6 giocatori in campo
- Rotazione automatica regolamentare al cambio palla
- Configurazione manuale dei numeri di maglia
- Dialog cambi con uno o due cambi (IN → OUT) e validazioni
- Supporto logica pallavolo ufficiale (25 punti + 2 di vantaggio, tie-break a 15 nel set decisivo)
- **Controlli Multimodali**
- Scorciatoie da tastiera complete (vedi sezione [Shortcuts](#shortcuts))
- Sintesi vocale per annunci punteggio in italiano (Web Speech API)
- **Personalizzazione**
- Configurazione dinamica nomi squadre
- Selettore modalità partita: al meglio di 3 o al meglio di 5
- Toggle layout orizzontale (inverti home/guest)
- Modalità visualizzazione: punteggio semplice o formazioni complete
- Nascondi/mostra controlli e cronologia
- [Architettura](#architettura)
- [Guida utente](#guida-utente)
- [Funzionalità](#funzionalità)
- [Deploy con Docker](#deploy-con-docker)
- [Sviluppo](#sviluppo)
- [Test](#test)
---
## Requisiti
## Architettura
### Requisiti di Sistema
```
Controller (smartphone) ──WebSocket──┐
├── Server Node.js ── gameState.js
Display (schermo) ──WebSocket──┘ │
└── .segnapunti/state.json
```
#### Per Sviluppo
- **Sistema Operativo**: Linux, macOS, Windows (WSL2 consigliato)
- **Node.js**: v20.2.0 o superiore (LTS consigliato)
- **npm**: v9.0.0 o superiore (incluso con Node.js)
- **RAM**: Minimo 2GB, consigliato 4GB
- **Spazio Disco**: ~500MB per dipendenze e build
Il server è l'unica fonte di verità. Ogni azione del controller viene elaborata e trasmessa in broadcast a tutti i client connessi. Lo stato viene salvato su disco ad ogni azione e ricaricato all'avvio, sopravvivendo ai riavvii del server.
#### Per Deployment
- **Server Web**: Qualsiasi server statico (nginx, Apache, Vercel, Netlify)
- **HTTPS**: Obbligatorio per Service Worker e PWA (eccetto localhost)
- **Connessione Internet**: Solo per primo caricamento (poi funziona offline)
### Requisiti Browser (Utente Finale)
| Requisito | Dettaglio | Necessità |
|-----------|-----------|-----------|
| **JavaScript ES6+** | Supporto moduli, arrow functions, async/await | Obbligatorio |
| **Service Worker API** | Per funzionalità offline PWA | Obbligatorio |
| **Fullscreen API** | Per modalità schermo intero | Consigliato |
| **Web Speech API** | Per sintesi vocale punteggi | Opzionale |
| **Local Storage** | Per persistenza configurazioni | Consigliato |
### Browser Testati e Supportati
| Browser | Versione Minima | Supporto | Note |
|---------|-----------------|----------|------|
| Chrome/Chromium | 90+ | ✅ Completo | Consigliato per tutte le features |
| Firefox | 88+ | ✅ Completo | Supporto completo PWA e Speech API |
| Percorso | Ruolo |
|---|---|
| `http://<host>:3000/display` | Tabellone pubblico — sola lettura |
| `http://<host>:3000/controller` | Pannello operatore — gestione partita |
| `ws://<host>:3000/ws` | WebSocket endpoint |
---
## Installazione e Setup
## Guida utente
### Prerequisiti
### Scenario tipico: schermo fisso + smartphone operatore
- **Node.js** v20.2.0 (consigliato)
- **npm** o **yarn**
### Installazione con NVM (consigliato)
#### 1. Avvia il server
```bash
# Installa la versione corretta di Node.js
nvm install v20.2.0
nvm use v20.2.0
docker compose up -d
```
# Clona il repository
git clone <repository-url>
cd segnapunti
All'avvio il terminale mostra gli URL locali e di rete.
# Installa le dipendenze
npm install
#### 2. Apri il display
Collega il PC/server allo schermo via HDMI e apri il browser a schermo intero:
```
http://localhost:3000/display
```
Se il display è su un dispositivo separato nella stessa rete:
```
http://<IP-del-server>:3000/display
```
> **Trovare l'IP:** il server lo stampa all'avvio. In alternativa usa `ip a` su Linux.
#### 3. Apri il controller sullo smartphone
Connetti il telefono alla stessa rete Wi-Fi e apri:
```
http://<IP-del-server>:3000/controller
```
> **Installazione come app:** nel browser tocca *"Aggiungi a schermata Home"* per avere il controller come icona dedicata.
---
## Funzionalità
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.
### Display (tabellone pubblico)
Pagina di sola visualizzazione, pensata per uno schermo grande o un proiettore. Mostra:
- **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 |
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
```bash
docker compose up -d
```
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
```bash
docker compose pull && docker compose up -d
```
### Build e pubblicazione immagine
```bash
docker build \
-t santantonio.sytes.net/attilio/segnapunti:2.0.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:latest
```
---
## Comandi per Sviluppo
## Sviluppo
### Dev Server
### Requisiti
Avvia il server di sviluppo con hot-reload:
| Strumento | Versione minima |
|---|---|
| Node.js | >= 18 |
| npm | >= 9 |
### Avvio
```bash
npm install
npm run dev
```
L'applicazione sarà disponibile su [http://localhost:5173](http://localhost:5173)
| URL | Interfaccia |
|---|---|
| `http://localhost:5173/display` | Display |
| `http://localhost:5173/controller` | Controller |
### Modalità Sviluppo
- Hot Module Replacement (HMR) attivo
- Source maps per debugging
- Vue DevTools supportato
- Errori e warnings in console
Lo stato viene salvato in `.segnapunti/state.json` anche in modalità dev.
### Comandi disponibili
| Comando | Descrizione |
|---|---|
| `npm run dev` | Dev server con hot reload |
| `npm run build` | Build di produzione in `dist/` |
| `npm run serve` | Build + avvio server produzione |
---
## Comandi per Build
## Test
### Build Produzione
| Comando | Descrizione |
|---|---|
| `npm run test:unit` | Unit + integration (Vitest) |
| `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 |
Genera i file ottimizzati per il deployment:
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%.
```bash
npm run build
```
**Output:**
- Cartella `/dist` con file statici ottimizzati
- Service Worker generato automaticamente
- PWA manifest configurato
- Assets minificati e con hash per cache busting
- Base path: `/segnap` (modificabile in `vite.config.js`)
### Preview Build
Anteprima locale della build di produzione:
```bash
npm run preview
```
Serve i file dalla cartella `/dist` per testare la build prima del deploy.
> I test E2E richiedono il server in esecuzione (`npm run serve`) e i browser Playwright installati:
> ```bash
> npx playwright install chromium firefox
> ```
---
## Shortcuts
## Changelog
### Controlli Tastiera Squadra Home
| Scorciatoia | Azione |
|-------------|--------|
| `Ctrl + ↑` | Incrementa punti |
| `Ctrl + ↓` | Decrementa punti |
| `Ctrl + →` | Incrementa set |
| `Ctrl + C` | Apri dialog cambi |
### Controlli Tastiera Squadra Guest
| Scorciatoia | Azione |
|-------------|--------|
| `Shift + ↑` | Incrementa punti |
| `Shift + ↓` | Decrementa punti |
| `Shift + →` | Incrementa set |
| `Shift + C` | Apri dialog cambi |
### Comandi Globali
| Scorciatoia | Azione |
|-------------|--------|
| `Ctrl + ←` | Cambio palla (servizio) - **solo a 0-0** |
| `Ctrl + M` | Apri configurazione nomi squadre e formazioni |
| `Ctrl + B` | Toggle visibilità barra pulsanti |
| `Ctrl + F` | Attiva/disattiva fullscreen |
| `Ctrl + S` | Annuncio vocale punteggio corrente |
| `Ctrl + Z` | Switch tra visualizzazione formazioni e punteggio |
---
## Configurazione PWA
L'applicazione è configurata come **Progressive Web App** nel file [vite.config.js](vite.config.js):
```javascript
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: "app_segnap",
short_name: "segnap",
description: "Segnapunti standalone.",
background_color: "#eee",
theme_color: '#ffffff',
display: "fullscreen",
orientation: "landscape",
icons: [
{ src: 'segnap-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'segnap-512x512.png', sizes: '512x512', type: 'image/png' }
]
}
})
```
### Caratteristiche PWA
- **Display**: Fullscreen per massimizzare lo spazio visivo
- **Orientamento**: Landscape (orizzontale) ottimizzato per tablet
- **Auto-update**: Service Worker con aggiornamento automatico
- **Offline**: Funzionamento completo senza connessione internet
- **Installabile**: Aggiungibile alla home screen come app nativa
### Installazione PWA
**Android/Desktop (Chrome):**
- Menu → "Installa app" o icona (⊕) nella barra degli indirizzi
**iOS (Safari):**
- Share (□↑) → "Aggiungi a Home"
---
## Logica Regolamentare Pallavolo
### Vittoria Set
- **Set regolari (1-4)**: Primo a 25 punti con almeno 2 di vantaggio
- **Set decisivo**:
- Modalità 2/3: 3° set a 15 punti con almeno 2 di vantaggio
- Modalità 3/5: 5° set a 15 punti con almeno 2 di vantaggio
- **Blocco automatico**: Non consente assegnare punti oltre la vittoria
### Rotazione Formazione
La rotazione avviene **automaticamente** quando:
1. La squadra **conquista il servizio** (cambio palla)
2. Il punteggio è diverso da 0-0
**Limitazione cambio palla manuale:**
- Il cambio manuale del servizio (`Ctrl + ←`) è consentito **solo a 0-0**
- Questa limitazione previene errori nella rotazione delle formazioni
### Formazione in Campo
Visualizzazione a 6 posizioni standard:
```
Rete
┌─────┬─────┬─────┐
│ 4 │ 3 │ 2 │ ← Fila anteriore
├─────┼─────┼─────┤
│ 5 │ 6 │ 1 │ ← Fila posteriore
└─────┴─────┴─────┘
```
La rotazione avviene in senso orario: 1→6→5→4→3→2→1
Vedere [CHANGELOG.md](CHANGELOG.md) per la storia delle versioni.
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/segnap-192x192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Segnapunti - Controller</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/controller-main.js"></script>
</body>
</html>
-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
+9
View File
@@ -0,0 +1,9 @@
services:
segnapunti:
image: santantonio.sytes.net/attilio/segnapunti:2.0.0
container_name: segnapunti
ports:
- "3000:3000"
volumes:
- ./.segnapunti:/app/.segnapunti
restart: unless-stopped
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/segnap-192x192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Segnapunti - Anto</title>
</head>
+3885 -1162
View File
File diff suppressed because it is too large Load Diff
+28 -8
View File
@@ -1,21 +1,41 @@
{
"name": "segnapuntianto",
"private": true,
"version": "0.0.0",
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
"serve": "vite build && node server.js",
"test": "vitest",
"test:unit": "vitest run tests/unit tests/integration",
"test:component": "vitest run tests/component",
"test:stress": "vitest run tests/stress",
"test:all": "vitest run",
"test:ui": "vitest --ui",
"test:e2e": "playwright test --config=playwright.config.cjs",
"test:e2e:ui": "playwright test --config=playwright.config.cjs --ui",
"test:e2e:codegen": "playwright codegen --config=playwright.config.cjs"
},
"dependencies": {
"nosleep.js": "^0.12.0",
"express": "^5.2.1",
"vue": "^3.2.47",
"wave-ui": "^3.3.0"
"ws": "^8.19.0"
},
"overrides": {
"serialize-javascript": "^7.0.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.1.0",
"vite": "^4.3.9",
"vite-plugin-pwa": "^0.16.0"
"@axe-core/playwright": "^4.11.1",
"@playwright/test": "^1.58.2",
"@types/node": "^25.2.3",
"@vitejs/plugin-vue": "^6.0.5",
"@vitest/ui": "^4.0.18",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^20.6.1",
"jsdom": "^28.0.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.18"
}
}
+38
View File
@@ -0,0 +1,38 @@
const { defineConfig, devices } = require('@playwright/test');
/**
* See https://playwright.dev/docs/test-configuration.
*/
module.exports = defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run serve',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+69
View File
@@ -0,0 +1,69 @@
import { createServer } from 'http'
import express from 'express'
import { WebSocketServer } from 'ws'
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 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'))
})
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 })
server.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname
if (pathname === '/ws') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)
})
} else {
socket.destroy()
}
})
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()
}
+1 -5
View File
@@ -1,7 +1,3 @@
<script setup>
import HomePage from './components/HomePage/index.vue'
</script>
<template>
<HomePage />
<router-view />
</template>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

File diff suppressed because it is too large Load Diff
+396
View File
@@ -0,0 +1,396 @@
<template>
<section class="display-page">
<div class="campo">
<span v-if="state.order">
<!-- Ordine visualizzazione: home / guest -->
<div class="hea home">
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.home }}
<span class="serv-slot">
<img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
</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">{{ punt.guest }}</span>
<span class="serv-slot">
<img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
{{ state.sp.nomi.guest }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ set.guest }}</span>
</div>
<span v-if="state.visuForm">
<div class="col form home">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'hf'+x">
{{ state.sp.form.home[x] }}
</div>
</div>
<div class="col form guest">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'gf'+x">
{{ state.sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt home">{{ punt.home }}</div>
<div class="col punt guest">{{ punt.guest }}</div>
</div>
</span>
</span>
<span v-else>
<!-- Ordine visualizzazione: guest / home -->
<div class="hea guest">
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.guest }}
<span class="serv-slot">
<img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
</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">{{ punt.home }}</span>
<span class="serv-slot">
<img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
{{ state.sp.nomi.home }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ set.home }}</span>
</div>
<span v-if="state.visuForm">
<div class="col form guest">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'gf2'+x">
{{ state.sp.form.guest[x] }}
</div>
</div>
<div class="col form home">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'hf2'+x">
{{ state.sp.form.home[x] }}
</div>
</div>
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt guest">{{ punt.guest }}</div>
<div class="col punt home">{{ punt.home }}</div>
</div>
</span>
</span>
<div class="striscia" v-if="state.visuStriscia">
<span class="striscia-nome text-bold">{{ state.sp.nomi.home }}</span>
<div class="striscia-items" ref="homeItems">
<div v-for="(h, i) in stricciaStrip.home" :key="'sh'+i"
class="item" :class="{ 'item-vuoto': h === ' ' }">
{{ h }}
</div>
</div>
<span class="striscia-nome text-bold guest-striscia">{{ state.sp.nomi.guest }}</span>
<div class="striscia-items guest-striscia" ref="guestItems">
<div v-for="(h, i) in stricciaStrip.guest" :key="'sg'+i"
class="item" :class="{ 'item-vuoto': h === ' ' }">
{{ h }}
</div>
</div>
</div>
<!-- Indicatore stato connessione -->
<div class="connection-status" :class="{ connected: wsConnected, disconnected: !wsConnected }">
<span class="dot"></span>
{{ 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 {
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() {
if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {}
}
if (this.state.timeout) this.avviaTimeout()
},
beforeUnmount() {
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.ris) {
if (scorer === 'h') { h++; home.push(h); guest.push(' ') }
else { g++; guest.push(g); home.push(' ') }
}
return { home, guest }
},
},
watch: {
'state.sp.striscia': {
deep: true,
handler() {
this.$nextTick(() => {
if (this.$refs.homeItems) this.$refs.homeItems.scrollLeft = this.$refs.homeItems.scrollWidth
if (this.$refs.guestItems) this.$refs.guestItems.scrollLeft = this.$refs.guestItems.scrollWidth
})
}
},
'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)
},
speakOnDisplay(text) {
if (typeof text !== 'string' || !text.trim() || !('speechSynthesis' in window)) return
const utterance = new SpeechSynthesisUtterance(text.trim())
const voices = window.speechSynthesis.getVoices()
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>
<style scoped>
.display-page {
width: 100%;
height: 100vh;
background: #111;
font-family: 'Inter', system-ui, sans-serif;
overflow: hidden;
}
.connection-status {
position: fixed;
top: 8px;
right: 8px;
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 5px;
z-index: 100;
transition: opacity 0.5s;
}
.connection-status.connected {
opacity: 0;
}
.connection-status.disconnected {
background: #c62828;
color: white;
opacity: 1;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.connected .dot {
background: #2e7d32;
}
.disconnected .dot {
background: #ff6b6b;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.guest-striscia {
color: white;
}
.punteggio-container {
width: 100%;
display: flex;
}
.punt {
font-size: 60vh;
font-weight: 900;
font-variant-numeric: tabular-nums;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
min-width: 50vw;
max-width: 50vw;
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>
-220
View File
@@ -1,220 +0,0 @@
<script>
import NoSleep from "nosleep.js";
export default {
name: "HomePage",
components: {},
data() {
return {
voices: null,
diaNomi: {
show: false,
home: "",
guest: "",
},
visuForm: false,
visuButt: true,
sp: {
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"],
},
},
}
},
mounted() {
this.voices = window.speechSynthesis.getVoices();
if (this.isMobile()) {
this.speak();
var noSleep = new NoSleep();
noSleep.enable();
document.documentElement.requestFullscreen();
}
this.abilitaTastiSpeciali();
},
methods: {
closeApp() {
var win = window.open("", "_self");
win.close();
},
fullScreen() {
document.documentElement.requestFullscreen();
},
isMobile() {
if (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
) {
return true;
} else {
return false;
}
},
resetta() {
this.$waveui.notify("Punteggio<br />RESETTATO", "success");
this.visuForm = false;
this.sp.punt.home = 0;
this.sp.punt.guest = 0;
this.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
},
incSet(team) {
if (this.sp.set[team] == 2) {
this.sp.set[team] = 0;
} else {
this.sp.set[team]++;
}
},
incPunt(team) {
this.sp.punt[team]++;
this.sp.servHome = (team == "home");
this.sp.form[team].push(this.sp.form[team].shift());
},
decPunt(team) {
// decrementa il punteggio se è > 0.
if (this.sp.punt[team] > 0) {
this.sp.punt[team]--;
this.sp.form[team].unshift(this.sp.form[team].pop());
}
},
speak() {
const msg = new SpeechSynthesisUtterance();
if (this.sp.punt.home + this.sp.punt.guest == 0) {
msg.text = "zero a zero";
} else if (this.sp.punt.home == this.sp.punt.guest) {
msg.text = this.sp.punt.home + " pari";
} else {
if (this.sp.servHome) {
msg.text = this.sp.punt.home + " a " + this.sp.punt.guest;
} else {
msg.text = this.sp.punt.guest + " a " + this.sp.punt.home;
}
}
// msg.volume = 1.0; // speech volume (default: 1.0)
// msg.pitch = 1.0; // speech pitch (default: 1.0)
// msg.rate = 1.0; // speech rate (default: 1.0)
msg.lang = 'it_IT'; // speech language (default: 'en-US')
const voices = window.speechSynthesis.getVoices();
msg.voice = voices.find(voice => voice.name === 'Google italiano'); // voice URI (default: platform-dependent)
// msg.onboundary = function (event) {
// console.log('Speech reached a boundary:', event.name);
// };
// msg.onpause = function (event) {
// console.log('Speech paused:', event.utterance.text.substring(event.charIndex));
// };
window.speechSynthesis.speak(msg);
},
apriDialogConfig() {
this.disabilitaTastiSpeciali();
this.diaNomi.show = true;
},
disabilitaTastiSpeciali() {
window.removeEventListener("keydown", this.funzioneTastiSpeciali);
},
abilitaTastiSpeciali() {
window.addEventListener("keydown", this.funzioneTastiSpeciali);
},
funzioneTastiSpeciali(e) {
e.preventDefault();
if (e.ctrlKey && e.key == "m") {
this.diaNomi.show = true
} else if (e.ctrlKey && e.key == "b") {
this.visuButt = !this.visuButt
} else if (e.ctrlKey && e.key == "f") {
document.documentElement.requestFullscreen();
} else if (e.ctrlKey && e.key == "s") {
this.speak();
} else if (e.ctrlKey && e.key == "z") {
this.visuForm = !this.visuForm
} else if (e.ctrlKey && e.key == "ArrowUp") {
this.incPunt("home")
} else if (e.ctrlKey && e.key == "ArrowDown") {
this.decPunt("home")
} else if (e.ctrlKey && e.key == "ArrowRight") {
this.incSet("home")
} else if (e.shiftKey && e.key == "ArrowUp") {
this.incPunt("guest")
} else if (e.shiftKey && e.key == "ArrowDown") {
this.decPunt("guest")
} else if (e.shiftKey && e.key == "ArrowRight") {
this.incSet("guest")
} else if (e.ctrlKey && e.key == "ArrowLeft") {
this.sp.servHome = !this.sp.servHome
} else { return false }
}
}
};
</script>
<template>
<w-dialog v-model="diaNomi.show" :width="500" @close="abilitaTastiSpeciali()">
<w-input v-model="sp.nomi.home" type="text" class="pa3">Home</w-input>
<w-input v-model="sp.nomi.guest" type="text" class="pa3">Guest</w-input>
<w-button bg-color="success" @click="diaNomi.show = false">
Ok
</w-button>
</w-dialog>
<div class="campo">
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'left' }">
{{ sp.nomi.home }} <img v-if="sp.servHome" src="/serv.png" width="25" />
<span v-if="visuForm">{{ sp.punt.home }}</span>
</span>
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
</div>
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'right' }">
<img v-if="!sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.guest }}
<span v-if="visuForm">{{ sp.punt.guest }}</span>
</span>
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
</div>
<span v-if="visuForm">
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
<div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
</span>
<div class="bot" v-if="visuButt">
<w-flex justify-space-between class="pa2">
<w-confirm right align-bottom v-if="isMobile()" question="CHIUDO ?" cancel="NO" confirm="SI" @confirm="closeApp">
<img src="/exit.png" width="25" />
</w-confirm>
<w-button @click="apriDialogConfig()">
<img src="/gear.png" width="25" />
</w-button>
<w-button @click="sp.servHome = !sp.servHome">
<img src="/serv.png" width="25" />
</w-button>
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
RESET
</w-confirm>
<w-button @click="visuForm = !visuForm">
<span v-if="visuForm">PUNTEGGIO</span>
<span v-if="!visuForm">FORMAZIONI</span>
</w-button>
<w-button @click="speak">
<img src="/speaker.png" width="25" />
</w-button>
</w-flex>
</div>
</div>
</template>
-240
View File
@@ -1,240 +0,0 @@
<section class="homepage">
<w-dialog v-model="diaNomi.show" :width="600" @close="chiudiDialogConfig()">
<w-input v-model="sp.nomi.home" type="text" class="pa3" tabindex="1">Nome Home</w-input>
<w-input v-model="sp.nomi.guest" type="text" class="pa3" tabindex="2">Nome Guest</w-input>
<w-flex justify-center align-center class="pa3">
<span class="mr3">Modalità partita:</span>
<w-button
@click="modalitaPartita = '2/3'"
:bg-color="modalitaPartita === '2/3' ? 'success' : 'grey-light4'"
:dark="modalitaPartita === '2/3'"
class="ma1"
tabindex="-1">
2/3
</w-button>
<w-button
@click="modalitaPartita = '3/5'"
:bg-color="modalitaPartita === '3/5' ? 'success' : 'grey-light4'"
:dark="modalitaPartita === '3/5'"
class="ma1"
tabindex="-1">
3/5
</w-button>
</w-flex>
<w-flex justify-space-around class="pa3">
<div class="campo-config">
<div class="text-bold mb3 text-center">Formazione Home</div>
<div class="campo-pallavolo">
<!-- Fila anteriore - index [3, 2, 1] - VICINO ALLA RETE (prima fila visualizzata) -->
<w-flex justify-center class="fila-anteriore">
<w-input v-model="sp.form.home[3]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="6"></w-input>
<w-input v-model="sp.form.home[2]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="5"></w-input>
<w-input v-model="sp.form.home[1]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="4"></w-input>
</w-flex>
<!-- Linea dei 3 metri -->
<div class="linea-tre-metri"></div>
<!-- Fila posteriore - index [4, 5, 0] - ZONA DIFESA (seconda fila visualizzata) -->
<w-flex justify-center class="fila-posteriore">
<w-input v-model="sp.form.home[4]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="7"></w-input>
<w-input v-model="sp.form.home[5]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="8"></w-input>
<w-input v-model="sp.form.home[0]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="3"></w-input>
</w-flex>
</div>
</div>
<div class="campo-config">
<div class="text-bold mb3 text-center">Formazione Guest</div>
<div class="campo-pallavolo">
<!-- Fila anteriore - index [3, 2, 1] - VICINO ALLA RETE (prima fila visualizzata) -->
<w-flex justify-center class="fila-anteriore">
<w-input v-model="sp.form.guest[3]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="12"></w-input>
<w-input v-model="sp.form.guest[2]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="11"></w-input>
<w-input v-model="sp.form.guest[1]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="10"></w-input>
</w-flex>
<!-- Linea dei 3 metri -->
<div class="linea-tre-metri"></div>
<!-- Fila posteriore - index [4, 5, 0] - ZONA DIFESA (seconda fila visualizzata) -->
<w-flex justify-center class="fila-posteriore">
<w-input v-model="sp.form.guest[4]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="13"></w-input>
<w-input v-model="sp.form.guest[5]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="14"></w-input>
<w-input v-model="sp.form.guest[0]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="9"></w-input>
</w-flex>
</div>
</div>
</w-flex>
<w-button @click="order = !order" class="ma2" tabindex="-1">Inverti ordine</w-button>
<w-button bg-color="success" @click="diaNomi.show = false" class="ma2" tabindex="-1">
Ok
</w-button>
</w-dialog>
<w-dialog v-model="diaCambiTeam.show" :width="420" @close="abilitaTastiSpeciali()">
<div class="text-bold text-center mb2">Scegli squadra</div>
<w-flex justify-center class="pa3">
<w-button class="ma2" @click="selezionaTeamCambi('home')">{{ sp.nomi.home }}</w-button>
<w-button class="ma2" @click="selezionaTeamCambi('guest')">{{ sp.nomi.guest }}</w-button>
</w-flex>
</w-dialog>
<w-dialog v-model="diaCambi.show" :width="360" @close="chiudiDialogCambi">
<div class="cambi-dialog">
<div class="cambi-title">{{ sp.nomi[diaCambi.team] }}: CAMBIO</div>
<div class="cambi-rows">
<div class="cambi-row">
<w-input v-model="diaCambi[diaCambi.team].cambi[0].in" type="text" class="cambi-input cambi-in"></w-input>
<span class="cambi-arrow"></span>
<w-input v-model="diaCambi[diaCambi.team].cambi[0].out" type="text" class="cambi-input cambi-out"></w-input>
</div>
<div class="cambi-row">
<w-input v-model="diaCambi[diaCambi.team].cambi[1].in" type="text" class="cambi-input cambi-in"></w-input>
<span class="cambi-arrow"></span>
<w-input v-model="diaCambi[diaCambi.team].cambi[1].out" type="text" class="cambi-input cambi-out"></w-input>
</div>
</div>
</div>
<w-flex justify-end class="pa3">
<w-button bg-color="success" :disabled="!cambiConfermabili" @click="confermaCambi">
CONFERMA
</w-button>
</w-flex>
</w-dialog>
<div class="campo">
<span v-if="order">
<!-- home guest -->
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'left' }">
{{ sp.nomi.home }}
<span class="serv-slot">
<img v-show="sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="visuForm" class="score-inline">{{ sp.punt.home }}</span>
</span>
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
</div>
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'right' }">
<span v-if="visuForm" class="score-inline">{{ sp.punt.guest }}</span>
<span class="serv-slot">
<img v-show="!sp.servHome" src="/serv.png" width="25" />
</span>
{{ sp.nomi.guest }}
</span>
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
</div>
<span v-if="visuForm">
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<w-flex class="punteggio-container">
<w-flex justify-center align-center class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</w-flex>
<w-flex justify-center align-center class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</w-flex>
</w-flex>
</span>
</span>
<span v-else>
<!-- guest home -->
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'left' }">
{{ sp.nomi.guest }}
<span class="serv-slot">
<img v-show="!sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="visuForm" class="score-inline">{{ sp.punt.guest }}</span>
</span>
<span @click="incSet('guest')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.guest }}</span>
</div>
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'right' }">
<span v-if="visuForm" class="score-inline">{{ sp.punt.home }}</span>
<span class="serv-slot">
<img v-show="sp.servHome" src="/serv.png" width="25" />
</span>
{{ sp.nomi.home }}
</span>
<span @click="incSet('home')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.home }}</span>
</div>
<span v-if="visuForm">
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
</span>
<span v-else>
<w-flex class="punteggio-container">
<w-flex justify-center align-center class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</w-flex>
<w-flex justify-center align-center class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</w-flex>
</w-flex>
</span>
</span>
<div class="striscia" v-if="visuStriscia">
<div>
<span class="text-bold mr1">{{ sp.nomi.home }}</span>
<div v-for="h in sp.striscia.home" class="item">
{{String(h)}}
</div>
</div>
<div class="guest">
<span class="text-bold mr1">{{ sp.nomi.guest }}</span>
<div v-for="h in sp.striscia.guest" class="item">
{{String(h)}}
</div>
</div>
</div>
<div class="bot" v-if="visuButt">
<w-flex justify-space-between class="pa2">
<w-confirm right align-bottom v-if="isMobile()" question="CHIUDO ?" cancel="NO" confirm="SI" @confirm="closeApp">
<img src="/exit.png" width="25" />
</w-confirm>
<w-button @click="apriDialogConfig()">
<img src="/gear.png" width="25" />
</w-button>
<w-button @click="cambiaPalla" :disabled="!isPunteggioZeroZero">
<img src="/serv.png" width="25" />
</w-button>
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
RESET
</w-confirm>
<w-button @click="visuForm = !visuForm">
<span v-if="visuForm">PUNTEGGIO</span>
<span v-if="!visuForm">FORMAZIONI</span>
</w-button>
<w-button @click="apriDialogCambi">
CAMBI
</w-button>
<w-button @click="visuStriscia = !visuStriscia">
STRISCIA
</w-button>
<w-button @click="speak">
<img src="/speaker.png" width="25" />
</w-button>
</w-flex>
</div>
</div>
</section>
-479
View File
@@ -1,479 +0,0 @@
import NoSleep from "nosleep.js";
export default {
name: "HomePage",
components: {},
data() {
return {
order: true,
voices: null,
diaNomi: {
show: false,
home: "",
guest: "",
},
diaCambi: {
show: false,
team: "home",
guest: { cambi: [{ in: "", out: "" }, { in: "", out: "" }] },
home: { cambi: [{ in: "", out: "" }, { in: "", out: "" }] },
},
diaCambiTeam: {
show: false,
},
visuForm: false,
visuButt: true,
visuStriscia: true,
modalitaPartita: "3/5", // "2/3" o "3/5"
sp: {
striscia: { home: [0], guest: [0] },
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"],
},
storicoServizio: [], // Stack per tracciare lo stato del servizio prima di ogni punto
},
}
},
mounted() {
this.voices = window.speechSynthesis.getVoices();
if (this.isMobile()) {
this.speak();
var noSleep = new NoSleep();
noSleep.enable();
document.documentElement.requestFullscreen();
}
this.abilitaTastiSpeciali();
},
computed: {
isPunteggioZeroZero() {
return this.sp.punt.home === 0 && this.sp.punt.guest === 0;
},
cambiConfermabili() {
const team = this.diaCambi.team;
const cambi = this.diaCambi[team].cambi || [];
let hasComplete = false;
let allValid = true;
cambi.forEach((cambio) => {
const teamIn = (cambio.in || "").trim();
const teamOut = (cambio.out || "").trim();
if (!teamIn && !teamOut) {
return;
}
if (!teamIn || !teamOut) {
allValid = false;
return;
}
hasComplete = true;
});
return allValid && hasComplete;
}
},
methods: {
closeApp() {
var win = window.open("", "_self");
win.close();
},
fullScreen() {
document.documentElement.requestFullscreen();
},
isMobile() {
if (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
) {
return true;
} else {
return false;
}
},
resetta() {
this.$waveui.notify("Punteggio<br />RESETTATO", "success");
this.visuForm = false;
this.sp.punt.home = 0;
this.sp.punt.guest = 0;
this.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
this.sp.striscia = { home: [0], guest: [0] }
this.sp.storicoServizio = []
},
cambiaPalla() {
if (!this.isPunteggioZeroZero) {
this.$waveui.notify("Cambio palla consentito solo a inizio set (0-0)", "warning");
return;
}
this.sp.servHome = !this.sp.servHome;
},
incSet(team) {
if (this.sp.set[team] == 2) {
this.sp.set[team] = 0;
} else {
this.sp.set[team]++;
}
},
incPunt(team) {
// Se il set è già terminato, evita ulteriori incrementi
if (this.checkVittoria()) {
return;
}
// Salva lo stato del servizio PRIMA di modificarlo
this.sp.storicoServizio.push({
servHome: this.sp.servHome,
cambioPalla: (team == "home" && !this.sp.servHome) || (team == "guest" && this.sp.servHome)
});
this.sp.punt[team]++;
if (team == 'home') {
this.sp.striscia.home.push(this.sp.punt.home)
this.sp.striscia.guest.push(' ')
} else {
this.sp.striscia.guest.push(this.sp.punt.guest)
this.sp.striscia.home.push(' ')
}
// Ruota la formazione solo se c'è cambio palla (conquista del servizio)
const cambioPalla = (team == "home" && !this.sp.servHome) || (team == "guest" && this.sp.servHome);
if (cambioPalla) {
this.sp.form[team].push(this.sp.form[team].shift());
}
this.sp.servHome = (team == "home");
},
checkVittoria() {
const puntHome = this.sp.punt.home;
const puntGuest = this.sp.punt.guest;
const setHome = this.sp.set.home;
const setGuest = this.sp.set.guest;
const totSet = setHome + setGuest;
// Determina se siamo nel set decisivo in base alla modalità partita
let isSetDecisivo = false;
if (this.modalitaPartita === "2/3") {
// Tie-break al 3° set (quando totSet >= 2)
isSetDecisivo = totSet >= 2;
} else {
// Tie-break al 5° set (quando totSet >= 4)
isSetDecisivo = totSet >= 4;
}
const punteggioVittoria = isSetDecisivo ? 15 : 25;
// Vittoria con punteggio >= 25 (o 15 per set decisivo) e almeno 2 punti di vantaggio
if (puntHome >= punteggioVittoria && puntHome - puntGuest >= 2) {
return true; // Home ha vinto
}
if (puntGuest >= punteggioVittoria && puntGuest - puntHome >= 2) {
return true; // Guest ha vinto
}
return false;
},
decPunt() {
if (this.sp.striscia.home.length > 1 && this.sp.storicoServizio.length > 0) {
var tmpHome = this.sp.striscia.home.pop()
var tmpGuest = this.sp.striscia.guest.pop()
var statoServizio = this.sp.storicoServizio.pop() // Recupera lo stato completo del servizio
if (tmpHome == ' ') {
this.sp.punt.guest--
// Ruota indietro solo se c'era stato un cambio palla
if (statoServizio.cambioPalla) {
this.sp.form.guest.unshift(this.sp.form.guest.pop());
}
} else {
this.sp.punt.home--
// Ruota indietro solo se c'era stato un cambio palla
if (statoServizio.cambioPalla) {
this.sp.form.home.unshift(this.sp.form.home.pop());
}
}
// Ripristina il servizio allo stato precedente
this.sp.servHome = statoServizio.servHome;
}
},
// decPunt(team) {
// // decrementa il punteggio se è > 0.
// if (this.sp.punt[team] > 0) {
// this.sp.punt[team]--;
// this.sp.striscia.home.pop()
// this.sp.striscia.guest.pop()
// this.sp.form[team].unshift(this.sp.form[team].pop());
// }
// },
speak() {
const msg = new SpeechSynthesisUtterance();
if (this.sp.punt.home + this.sp.punt.guest == 0) {
msg.text = "zero a zero";
} else if (this.sp.punt.home == this.sp.punt.guest) {
msg.text = this.sp.punt.home + " pari";
} else {
if (this.sp.servHome) {
msg.text = this.sp.punt.home + " a " + this.sp.punt.guest;
} else {
msg.text = this.sp.punt.guest + " a " + this.sp.punt.home;
}
}
// msg.volume = 1.0; // speech volume (default: 1.0)
// msg.pitch = 1.0; // speech pitch (default: 1.0)
// msg.rate = 1.0; // speech rate (default: 1.0)
// msg.lang = 'it_IT'; // speech language (default: 'en-US')
const voices = window.speechSynthesis.getVoices();
msg.voice = voices.find(voice => voice.name === 'Google italiano');
// voice URI (default: platform-dependent)
// msg.onboundary = function (event) {
// console.log('Speech reached a boundary:', event.name);
// };
// msg.onpause = function (event) {
// console.log('Speech paused:', event.utterance.text.substring(event.charIndex));
// };
window.speechSynthesis.speak(msg);
},
apriDialogConfig() {
this.disabilitaTastiSpeciali();
this.diaNomi.show = true;
// Aggiungi gestore Tab per il dialog
this.dialogConfigTabHandler = (e) => {
if (e.key === 'Tab' && this.diaNomi.show) {
e.preventDefault();
e.stopPropagation();
const dialog = document.querySelector('.w-dialog');
if (!dialog) return;
const allInputs = Array.from(dialog.querySelectorAll('input[type="text"]'))
.sort((a, b) => {
const tabA = parseInt(a.closest('[tabindex]')?.getAttribute('tabindex') || '0');
const tabB = parseInt(b.closest('[tabindex]')?.getAttribute('tabindex') || '0');
return tabA - tabB;
});
if (allInputs.length === 0) return;
// Verifica se il focus è già dentro il dialog
const focusInDialog = dialog.contains(document.activeElement);
// Se non è nel dialog o non è in un input, vai al primo
if (!focusInDialog || !allInputs.includes(document.activeElement)) {
allInputs[0].focus();
return;
}
// Navigazione normale tra i campi
const currentIndex = allInputs.indexOf(document.activeElement);
let nextIndex;
if (e.shiftKey) {
nextIndex = currentIndex <= 0 ? allInputs.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex >= allInputs.length - 1 ? 0 : currentIndex + 1;
}
if (allInputs[nextIndex]) {
allInputs[nextIndex].focus();
}
}
};
window.addEventListener('keydown', this.dialogConfigTabHandler, true);
// Focus immediato + retry con timeout
this.$nextTick(() => {
const focusFirst = () => {
const dialog = document.querySelector('.w-dialog');
if (dialog) {
const firstInput = dialog.querySelector('input[type="text"]');
if (firstInput) {
firstInput.focus();
firstInput.select();
return true;
}
}
return false;
};
// Prova immediatamente
if (!focusFirst()) {
// Se fallisce, riprova dopo un breve delay
setTimeout(focusFirst, 100);
}
});
},
chiudiDialogConfig() {
if (this.dialogConfigTabHandler) {
window.removeEventListener('keydown', this.dialogConfigTabHandler, true);
this.dialogConfigTabHandler = null;
}
this.abilitaTastiSpeciali();
},
resettaCambi(team) {
const teams = team ? [team] : ["home", "guest"];
teams.forEach((t) => {
this.diaCambi[t].cambi.forEach((cambio) => {
cambio.in = "";
cambio.out = "";
});
});
},
apriDialogCambi() {
this.disabilitaTastiSpeciali();
this.diaCambiTeam.show = true;
},
apriDialogCambiTeam(team) {
this.disabilitaTastiSpeciali();
this.diaCambi.team = team;
this.resettaCambi(team);
this.diaCambi.show = true;
},
selezionaTeamCambi(team) {
this.diaCambiTeam.show = false;
this.apriDialogCambiTeam(team);
},
chiudiDialogCambi() {
this.diaCambi.show = false;
this.resettaCambi(this.diaCambi.team);
this.abilitaTastiSpeciali();
},
confermaCambi() {
if (!this.cambiConfermabili) {
return;
}
const team = this.diaCambi.team;
const cambi = (this.diaCambi[team].cambi || [])
.map((cambio) => ({
team,
in: (cambio.in || "").trim(),
out: (cambio.out || "").trim(),
}))
.filter((cambio) => cambio.in || cambio.out);
const form = this.sp.form[team].map((val) => String(val).trim());
const formAggiornata = [...form];
for (const cambio of cambi) {
if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) {
this.$waveui.notify("Inserisci solo numeri nei campi", "warning");
return;
}
if (cambio.in === cambio.out) {
this.$waveui.notify(`Numero IN e OUT uguali per ${cambio.team}`, "warning");
return;
}
if (formAggiornata.includes(cambio.in)) {
this.$waveui.notify(`Numero ${cambio.in} già presente in formazione ${cambio.team}`, "warning");
return;
}
if (!formAggiornata.includes(cambio.out)) {
this.$waveui.notify(`Numero ${cambio.out} non presente in formazione ${cambio.team}`, "warning");
return;
}
const idx = formAggiornata.findIndex((val) => String(val).trim() === cambio.out);
if (idx !== -1) {
formAggiornata.splice(idx, 1, cambio.in);
}
}
this.sp.form[team] = formAggiornata;
this.chiudiDialogCambi();
},
disabilitaTastiSpeciali() {
window.removeEventListener("keydown", this.funzioneTastiSpeciali);
},
abilitaTastiSpeciali() {
window.addEventListener("keydown", this.funzioneTastiSpeciali);
},
funzioneTastiSpeciali(e) {
if (this.diaNomi.show || this.diaCambi.show || this.diaCambiTeam.show) {
return;
}
const target = e.target;
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
const elements = [target, ...path].filter(Boolean);
const isTypingField = elements.some((el) => {
if (!el || !el.tagName) {
return false;
}
const tag = String(el.tagName).toLowerCase();
if (tag === "input" || tag === "textarea") {
return true;
}
if (el.isContentEditable) {
return true;
}
if (el.classList && (el.classList.contains("w-input") || el.classList.contains("w-textarea"))) {
return true;
}
const contentEditable = el.getAttribute && el.getAttribute("contenteditable");
return contentEditable === "true";
});
if (isTypingField) {
return;
}
let handled = false;
if (e.ctrlKey && e.key == "m") {
this.diaNomi.show = true
handled = true;
} else if (e.ctrlKey && e.key == "b") {
this.visuButt = !this.visuButt
handled = true;
} else if (e.ctrlKey && e.key == "f") {
document.documentElement.requestFullscreen();
handled = true;
} else if (e.ctrlKey && e.key == "s") {
this.speak();
handled = true;
} else if (e.ctrlKey && e.key == "z") {
this.visuForm = !this.visuForm
handled = true;
} else if (e.ctrlKey && e.key == "ArrowUp") {
this.incPunt("home")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowDown") {
this.decPunt("home")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowRight") {
this.incSet("home")
handled = true;
} else if (e.shiftKey && e.key == "ArrowUp") {
this.incPunt("guest")
handled = true;
} else if (e.shiftKey && e.key == "ArrowDown") {
this.decPunt("guest")
handled = true;
} else if (e.shiftKey && e.key == "ArrowRight") {
this.incSet("guest")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowLeft") {
this.cambiaPalla()
handled = true;
} else if (e.ctrlKey && (e.key == "c" || e.key == "C")) {
this.apriDialogCambiTeam("home")
handled = true;
} else if (e.shiftKey && (e.key == "c" || e.key == "C")) {
this.apriDialogCambiTeam("guest")
handled = true;
} else { return false }
if (handled) {
e.preventDefault();
}
}
}
}
-112
View File
@@ -1,112 +0,0 @@
.homepage {
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
touch-action: pan-x pan-y;
height: 100%
}
body {
overscroll-behavior-y: contain;
margin: 0;
place-items: center;
min-width: 320px;
width: 100%;
min-height: 100vh;
background-color: #000;
}
button {
margin-left: 10px;
margin-right: 10px;
border-radius: 8px;
border: 1px solid #fff;
padding: 0.6em 1.2em;
font-size: 0.8em;
font-weight: 500;
font-family: inherit;
color: #fff;
background-color: #000;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
background-color: #333;
}
button:focus, button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
#app {
margin: 0 auto;
text-align: center;
}
.campo {
user-select: none;
width: 100%;
display: table;
color: #fff;
}
.hea {
float: left;
width: 50%;
font-size: xx-large;
}
.hea span {
/* border: 1px solid #f3fb00; */
padding-left: 10px;
padding-right: 10px;
border-radius: 5px;
}
.tal {
text-align: left;
}
.tar {
text-align: right;
}
.bot {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
margin-top: 10px;
margin-bottom: 1px;
background-color: #111;
}
.col {
margin-left: auto;
margin-right: auto;
text-align: center;
float: left;
width: 50%;
}
.punt {
font-size: 60vh;
}
.form {
font-size: 5vh;
border-top: #fff dashed 25px;
padding-top: 50px;
}
.formtit {
font-size: 5vh;
margin-top: 30px;
margin-bottom: 20px;
}
.formdiv {
font-size: 20vh;
float: left;
width: 32%;
}
.home {
background-color: black;
color: yellow;
}
.guest {
background-color: blue;
color: white
}
.item-stri {
background-color: #fff;
}
}
-3
View File
@@ -1,3 +0,0 @@
<template src="./HomePage.html"></template>
<script src="./HomePage.js"></script>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import ControllerPage from './components/ControllerPage.vue'
const app = createApp(ControllerPage)
app.mount('#app')
+256
View File
@@ -0,0 +1,256 @@
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: 'h', ris: '', vinc: null }],
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
},
}
}
export function checkVittoria(state) {
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
if (puntHome >= punteggioVittoria && puntHome - puntGuest >= 2) return true
if (puntGuest >= punteggioVittoria && puntGuest - puntHome >= 2) return true
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)
switch (action.type) {
case "incPunt": {
const team = action.team
if (checkVittoria(s)) break
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())
}
break
}
case "decPunt": {
const currentSet = s.sp.striscia.at(-1)
if (currentSet.ris.length === 0) break
const lastScorerShort = currentSet.ris.at(-1)
const prevServerShort = currentSet.ris.length >= 2
? currentSet.ris.at(-2)
: currentSet.serv
const wasCambioPalla = lastScorerShort !== prevServerShort
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())
}
break
}
case "incSet": {
const team = action.team
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 {
// 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
}
case "nuovoSet": {
const team = action.team
if (team !== 'home' && team !== 'guest') break
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"],
}
break
}
case "cambiaPalla": {
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
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"],
}
break
}
case "toggleFormazione": {
s.visuForm = !s.visuForm
break
}
case "toggleStriscia": {
s.visuStriscia = !s.visuStriscia
break
}
case "toggleOrder": {
s.order = !s.order
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
break
}
case "setModalita": {
s.modalitaPartita = action.modalita
break
}
case "setFormazione": {
if (action.team && action.form) {
s.sp.form[action.team] = [...action.form]
}
break
}
case "confermaCambi": {
const team = action.team
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) {
const cin = (cambio.in || "").trim()
const cout = (cambio.out || "").trim()
if (!cin || !cout) continue
if (!/^\d+$/.test(cin) || !/^\d+$/.test(cout)) { valid = false; break }
if (cin === cout) { valid = false; break }
if (formAggiornata.includes(cin)) { valid = false; break }
if (!formAggiornata.includes(cout)) { valid = false; break }
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
}
default:
break
}
return s
}
+2 -6
View File
@@ -1,10 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import WaveUI from 'wave-ui'
import 'wave-ui/dist/wave-ui.css'
import DisplayPage from './components/DisplayPage.vue'
const app = createApp(App)
app.use(WaveUI)
const app = createApp(DisplayPage)
app.mount('#app')
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { createInitialState } from './gameState.js'
const STATE_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '.segnapunti', 'state.json')
export function loadState() {
try {
if (existsSync(STATE_PATH)) {
return JSON.parse(readFileSync(STATE_PATH, 'utf8'))
}
} catch (err) {
console.warn('[Persist] Stato non leggibile, si riparte da zero:', err.message)
}
return createInitialState()
}
export function saveState(state) {
try {
mkdirSync(dirname(STATE_PATH), { recursive: true })
writeFileSync(STATE_PATH, JSON.stringify(state), 'utf8')
} catch (err) {
console.error('[Persist] Salvataggio fallito:', err.message)
}
}
+223
View File
@@ -0,0 +1,223 @@
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">${nomi.home}</div>
<div class="giocatori">${formazioneSet.home.map(giocatoreHtml).join('')}</div>
</div>
<div class="form-team">
<div class="form-team-name">${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 ? ` &nbsp;·&nbsp; vinto da <strong>${nomeVinc}</strong>` : ''
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">
${nomi.home} <strong>${h}</strong> · <strong>${g}</strong> ${nomi.guest}${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; width: 22px; height: 22px; border-radius: 50%; 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; }
.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: #d0e8ff; color: #003a6e; }
.punto-g { background: #ffddc0; color: #6e2700; }
.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; }
.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">A</span>
<span class="nome-sq">${nomi.home}</span>
<span class="vs">vs</span>
<span class="nome-sq">${nomi.guest}</span>
<span class="badge-ab">B</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>A</th><th>B</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()
w.print()
}
+70
View File
@@ -0,0 +1,70 @@
import { networkInterfaces } from 'os'
import { readFileSync, existsSync } from 'fs'
import { execSync } from 'child_process'
function isWSL() {
try {
return existsSync('/proc/sys/kernel/osrelease') &&
readFileSync('/proc/sys/kernel/osrelease', 'utf8').toLowerCase().includes('microsoft')
} catch { return false }
}
// 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 && isLanIPv4(net.address)) {
out.push(net.address)
}
}
}
return out
}
// 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 [] }
}
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 Da dispositivi remoti:`)
networkIPs.forEach(ip => {
console.log(` Display: http://${ip}:${port}/display`)
console.log(` Controller: http://${ip}:${port}/controller`)
})
}
console.log()
}
+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 []
}
}
+100 -25
View File
@@ -46,14 +46,34 @@ 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 {
/* border: 1px solid #f3fb00; */
/* Bordo di debug: border: 1px solid #f3fb00; */
padding-left: 10px;
padding-right: 10px;
border-radius: 5px;
@@ -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,30 +167,71 @@ 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;
text-align: right;
position: fixed;
bottom: 50px;
left: 10px;
right: 10px;
margin-left: -10000px;
display: grid;
grid-template-columns: max-content 1fr;
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: 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;
min-width: 25px;
text-align: center;
font-weight: bold;
display: inline-block;
background-color: rgb(206, 247, 3);
color: blue;
border-radius: 5px;
flex-shrink: 0;
border-radius: 6px;
}
.striscia .item:not(.item-vuoto) {
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 {
@@ -233,9 +316,7 @@ button:focus-visible {
max-width: 64px;
}
.cambi-input input,
.cambi-input .w-input__input,
.cambi-input .w-input__field {
.cambi-input input {
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
padding: 6px 10px;
@@ -244,21 +325,15 @@ button:focus-visible {
box-sizing: border-box;
}
.cambi-in input,
.cambi-in .w-input__input,
.cambi-in .w-input__field {
.cambi-in input {
background: rgba(120, 200, 120, 0.4);
}
.cambi-out input,
.cambi-out .w-input__input,
.cambi-out .w-input__field {
.cambi-out input {
background: rgba(200, 120, 120, 0.4);
}
.cambi-input input:focus,
.cambi-input .w-input__input:focus,
.cambi-input .w-input__field:focus {
.cambi-input input:focus {
border-color: rgba(255, 255, 255, 0.7);
outline: none;
}
+233
View File
@@ -0,0 +1,233 @@
import { createInitialState, applyAction } from './gameState.js'
/**
* Crea e configura il server WebSocket per la gestione dello stato di gioco.
* @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()
// 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
*/
function handleMessage(ws, data) {
try {
// Converte il payload in stringa in modo sicuro, anche se arriva come Buffer.
const dataStr = typeof data === 'string' ? data : data.toString('utf8')
const msg = JSON.parse(dataStr)
if (msg.type === 'register') {
return handleRegister(ws, msg)
}
if (msg.type === 'action') {
return handleAction(ws, msg)
}
if (msg.type === 'speak') {
return handleSpeak(ws, msg)
}
} catch (err) {
console.error('Error processing message:', err, 'data:', data)
// Invia l'errore solo se la connessione e ancora aperta.
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
sendError(ws, 'Invalid message format')
} catch (sendErr) {
console.error('Error sending error message:', sendErr)
}
}
}
}
/**
* Gestisce la registrazione di un client (display o controller)
*/
function handleRegister(ws, msg) {
const role = msg.role || 'display'
// Valida il ruolo dichiarato dal client.
if (!['display', 'controller'].includes(role)) {
sendError(ws, 'Invalid role')
return
}
clients.set(ws, { role })
console.log(`[WebSocket] Client registered as: ${role} (total clients: ${clients.size})`)
// Invia subito lo stato corrente, se la connessione e aperta.
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
ws.send(JSON.stringify({ type: 'state', state: gameState }))
} catch (err) {
console.error('Error sending initial state:', err)
}
}
}
/**
* Gestisce un'azione di gioco dal controller
*/
function handleAction(ws, msg) {
// Solo i client controller possono inviare azioni.
const client = clients.get(ws)
if (!client || client.role !== 'controller') {
sendError(ws, 'Only controllers can send actions')
return
}
// Verifica il formato dell'azione ricevuta.
if (!msg.action || !msg.action.type) {
sendError(ws, 'Invalid action format')
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, action)
} catch (err) {
console.error('Error applying action:', err)
sendError(ws, 'Failed to apply action')
gameState = previousState
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)
}
/**
* Gestisce una richiesta di sintesi vocale proveniente dal controller.
* Il messaggio viene inoltrato solo ai client registrati come display.
*/
function handleSpeak(ws, msg) {
const client = clients.get(ws)
if (!client || client.role !== 'controller') {
sendError(ws, 'Only controllers can request speech')
return
}
if (typeof msg.text !== 'string' || msg.text.trim() === '') {
sendError(ws, 'Invalid speak payload')
return
}
const speakMsg = JSON.stringify({ type: 'speak', text: msg.text.trim() })
clients.forEach((meta, clientWs) => {
if (meta.role === 'display' && clientWs.readyState === 1) {
clientWs.send(speakMsg)
}
})
}
/**
* Invia un messaggio di errore al client
*/
function sendError(ws, message) {
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
ws.send(JSON.stringify({ type: 'error', message }))
} catch (err) {
console.error('Failed to send error message:', err)
}
}
}
/**
* Invia lo stato corrente a tutti i client connessi.
*/
function broadcastState() {
const stateMsg = JSON.stringify({ type: 'state', state: gameState })
wss.clients.forEach((client) => {
if (client.readyState === 1) { // Stato WebSocket.OPEN
client.send(stateMsg)
}
})
}
/**
* Gestisce la chiusura della connessione
*/
function handleClose(ws) {
const client = clients.get(ws)
const role = client?.role || 'unknown'
console.log(`[WebSocket] Client disconnected (role: ${role})`)
clients.delete(ws)
}
/**
* Gestisce gli errori WebSocket
*/
function handleError(err, ws) {
console.error('WebSocket error:', err)
// In caso di frame non validi, chiude forzatamente la connessione.
if (err.code === 'WS_ERR_INVALID_CLOSE_CODE' || err.code === 'WS_ERR_INVALID_UTF8') {
try {
if (ws && ws.readyState === 1) { // Stato WebSocket.OPEN
ws.terminate() // Chiusura forzata senza handshake di chiusura.
}
} catch (closeErr) {
console.error('Error closing connection:', closeErr)
}
}
}
// Registra gli handler per ogni nuova connessione.
wss.on('connection', (ws) => {
// Imposta il tipo binario per ridurre i problemi di codifica.
ws.binaryType = 'arraybuffer'
ws.on('message', (data) => handleMessage(ws, data))
ws.on('close', () => handleClose(ws))
ws.on('error', (err) => handleError(err, ws))
})
// Espone un'API pubblica per controllo esterno, se necessario.
return {
getState: () => gameState,
setState: (newState) => { gameState = newState },
broadcastState,
getClients: () => clients,
}
}
+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
}
},
},
}
}
+323
View File
@@ -0,0 +1,323 @@
# Guida ai Test
Obiettivo della guida:
- capire che tipi di test esistono nel progetto
- capire a cosa servono davvero
- sapere come lanciarli senza errori
- sapere come leggere i risultati
- sapere cosa fare quando qualcosa fallisce
## 0) Perche facciamo i test?
Un test è un controllo automatico.
In pratica:
- tu cambi il codice
- lanci i test
- i test ti dicono se hai rotto qualcosa
Se i test sono verdi, hai una buona probabilita che il progetto sia ancora stabile.
Se i test sono rossi, c'e un problema da capire e sistemare.
## 1) Struttura delle cartelle test
```text
tests/
├── unit/ # Test della logica pura (molto veloci)
├── integration/ # Test di moduli che comunicano tra loro
├── component/ # Test dei componenti Vue in isolamento
├── stress/ # Test sotto carico (molti client/azioni)
├── e2e/ # Test end-to-end su browser reale
└── README.md # Questa guida
```
## 2) Tecnologie usate
- `Vitest`: per `unit`, `integration`, `component`, `stress`
- `Playwright`: per `e2e`
Tradotto in modo semplice:
- Vitest controlla parti interne del progetto
- Playwright controlla il comportamento reale dell'app nel browser
## 3) Prerequisiti (prima di tutto)
### 3.1 Installa dipendenze
Dalla root del progetto:
```bash
npm install
```
### 3.2 Installa browser Playwright (solo E2E)
```bash
npx playwright install chromium firefox
```
Se non fai questo passo, gli E2E possono fallire subito con errore tipo:
- `Executable doesn't exist`
## 4) Comandi principali
```bash
npm run test # Vitest in watch mode (resta in ascolto)
npm run test:all # Tutta la suite Vitest una volta
npm run test:unit # Unit + integration
npm run test:component # Solo component test
npm run test:stress # Solo stress test
npm run test:e2e # Tutti gli E2E Playwright
npm run test:e2e:ui # Playwright con interfaccia grafica
```
Ordine consigliato (quando vuoi verificare tutto):
1. `npm run test:all`
2. `npm run test:e2e`
## 5) Cosa testa ogni suite (spiegato semplice)
### 5.1 Unit (`tests/unit`)
Cosa sono:
- test piccoli e veloci sulla logica del gioco
A cosa servono:
- trovano subito bug in regole punteggio/set/reset
Esempi reali nel progetto:
- incremento/decremento punti
- 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:
- test su pezzi che lavorano insieme (es. WebSocket + handler)
A cosa servono:
- verificano che i messaggi si muovano nel modo corretto
Esempi reali nel progetto:
- registrazione client `display`/`controller`
- 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
### 5.3 Component (`tests/component`)
Cosa sono:
- test dei componenti Vue senza browser completo
A cosa servono:
- controllano rendering e comportamento UI locale
Esempi reali nel progetto:
- punteggio mostrato correttamente
- 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
### 5.4 Stress (`tests/stress`)
Cosa sono:
- test per simulare carico elevato
A cosa servono:
- verificano che il sistema regga molti client e molte azioni rapide
Esempi reali nel progetto:
- tanti client display connessi insieme
- burst di azioni consecutive
Quando falliscono:
- possono emergere problemi di performance o consistenza stato
### 5.5 End-to-End (`tests/e2e`)
Cosa sono:
- test realistici nel browser
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
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
### 6.1 Risultati Vitest
Caso OK (verde):
```text
Test Files 15 passed (15)
Tests 362 passed (362)
```
Significa:
- tutti i test Vitest sono passati
Caso KO (rosso):
- guarda prima il nome del file test
- poi il nome del test (`describe/test`)
- poi la riga con `expected` e `received`
- infine vai alla riga indicata nello stack trace
### 6.2 Risultati Playwright
Caso OK:
```text
81 passed
```
Caso KO:
- apri il report HTML
```bash
npx playwright show-report
```
Nel report puoi vedere:
- step del test
- errori precisi
- screenshot/diff
- trace
## 7) Visual Regression (screenshot)
I test visual confrontano immagini attuali con immagini baseline.
Cartella baseline:
- `tests/e2e/visual-regression.spec.cjs-snapshots/`
Se cambia la UI in modo intenzionale:
- aggiorna snapshot
```bash
npm run test:e2e -- --update-snapshots
```
Poi rilancia controllo:
```bash
npm run test:e2e
```
Se la UI non doveva cambiare:
- non aggiornare snapshot
- correggi prima il codice UI/CSS
## 8) Errori comuni e soluzione veloce
- Errore Playwright `Executable doesn't exist`:
- esegui `npx playwright install chromium firefox`
- E2E instabili con punteggi strani:
- assicurati che i test restino seriali (`workers: 1`)
- assicurati che ogni test parta da stato pulito (reset)
- Selettore ambiguo (esempio bottone `Cambi`):
- usa selector piu specifici, ad esempio `getByRole(..., { exact: true })`
- Failure accessibilita:
- controlla prima `alt` delle immagini e contrasto colori
## 9) Mini checklist prima di fare push
Esegui:
```bash
npm run test:all
npm run test:e2e
```
Se hai cambiato UI e i visual falliscono per differenze volute:
```bash
npm run test:e2e -- --update-snapshots
npm run test:e2e
```
## 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
- `tests/integration` per moduli che dialogano
- `tests/component` per Vue in isolamento
- `tests/stress` per carico
- `tests/e2e` per flussi reali
- mantieni nomi chiari:
- il titolo del test deve spiegare cosa verifica
- evita test dipendenti da ordine:
- ogni test deve potersi eseguire da solo
- negli E2E:
- porta sempre il sistema in stato iniziale
- usa selector robusti
- limita `waitForTimeout` e preferisci attese su condizioni reali
Se segui questi punti, i test restano stabili e facili da capire anche per chi entra ora nel progetto.
File diff suppressed because it is too large Load Diff
+536
View File
@@ -0,0 +1,536 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import DisplayPage from '../../src/components/DisplayPage.vue'
// Mock globale WebSocket per jsdom
class MockWebSocket {
static OPEN = 1
static CONNECTING = 0
readyState = 0
onopen = null
onclose = null
onmessage = null
onerror = null
send = vi.fn()
close = vi.fn()
constructor() {
setTimeout(() => {
this.readyState = 1
if (this.onopen) this.onopen()
}, 0)
}
}
vi.stubGlobal('WebSocket', MockWebSocket)
// Mock requestFullscreen e speechSynthesis
vi.stubGlobal('speechSynthesis', {
speak: vi.fn(),
cancel: vi.fn(),
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: {
stubs: { 'w-app': true }
}
})
}
describe('DisplayPage.vue', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// =============================================
// RENDERING PUNTEGGIO
// =============================================
describe('Rendering punteggio', () => {
it('dovrebbe mostrare i nomi dei team', () => {
const wrapper = mountDisplay()
const text = wrapper.text()
expect(text).toContain('Antoniana')
expect(text).toContain('Guest')
})
it('dovrebbe mostrare punteggio iniziale 0-0', () => {
const wrapper = mountDisplay()
const punti = wrapper.findAll('.punt')
expect(punti[0].text()).toBe('0')
expect(punti[1].text()).toBe('0')
})
it('dovrebbe mostrare i set corretti', () => {
const wrapper = mountDisplay()
const text = wrapper.text()
expect(text).toContain('set 0')
})
it('dovrebbe aggiornare il punteggio quando lo stato cambia', async () => {
const wrapper = mountDisplay()
// 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')
expect(punti[1].text()).toBe('12')
})
})
// =============================================
// ORDINE TEAM
// =============================================
describe('Ordine team', () => {
it('order=true → Home prima di Guest', () => {
const wrapper = mountDisplay()
const headers = wrapper.findAll('.hea')
expect(headers[0].classes()).toContain('home')
expect(headers[1].classes()).toContain('guest')
})
it('order=false → Guest prima di Home', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.order = false
await wrapper.vm.$nextTick()
const headers = wrapper.findAll('.hea')
expect(headers[0].classes()).toContain('guest')
expect(headers[1].classes()).toContain('home')
})
})
// =============================================
// FORMAZIONE vs PUNTEGGIO
// =============================================
describe('visuForm toggle', () => {
it('visuForm=false → mostra punteggio grande', () => {
const wrapper = mountDisplay()
expect(wrapper.find('.punteggio-container').exists()).toBe(true)
expect(wrapper.find('.form').exists()).toBe(false)
})
it('visuForm=true → mostra formazione', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.visuForm = true
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.form').length).toBeGreaterThan(0)
expect(wrapper.find('.punteggio-container').exists()).toBe(false)
})
it('formazione mostra 6 giocatori per team', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.visuForm = true
await wrapper.vm.$nextTick()
const formDivs = wrapper.findAll('.formdiv')
// 6 per home + 6 per guest = 12
expect(formDivs).toHaveLength(12)
})
})
// =============================================
// STRISCIA
// =============================================
describe('visuStriscia toggle', () => {
it('visuStriscia=true → mostra la striscia', () => {
const wrapper = mountDisplay()
expect(wrapper.find('.striscia').exists()).toBe(true)
})
it('visuStriscia=false → nasconde la striscia', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.visuStriscia = false
await wrapper.vm.$nextTick()
expect(wrapper.find('.striscia').exists()).toBe(false)
})
})
// =============================================
// INDICATORE CONNESSIONE
// =============================================
describe('Indicatore connessione', () => {
it('dovrebbe avere classe "disconnected" quando non connesso', () => {
const wrapper = mountDisplay()
const status = wrapper.find('.connection-status')
expect(status.classes()).toContain('disconnected')
})
it('dovrebbe avere classe "connected" quando connesso', async () => {
const wrapper = mountDisplay()
wrapper.vm.wsConnected = true
await wrapper.vm.$nextTick()
const status = wrapper.find('.connection-status')
expect(status.classes()).toContain('connected')
})
it('dovrebbe mostrare "Disconnesso" quando non connesso', () => {
const wrapper = mountDisplay()
const status = wrapper.find('.connection-status')
expect(status.text()).toContain('Disconnesso')
})
})
// =============================================
// 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
// =============================================
describe('Icona servizio', () => {
it('dovrebbe mostrare l\'icona servizio sul team home quando servHome=true', () => {
const wrapper = mountDisplay()
// v-show imposta display:none. In happy-dom controlliamo lo style.
const imgs = wrapper.findAll('.serv-slot img')
// Con state.order=true e servHome=true:
// - la prima img (home) è visibile (no display:none)
// - la seconda img (guest) ha display:none
const homeStyle = imgs[0].attributes('style') || ''
const guestStyle = imgs[1].attributes('style') || ''
expect(homeStyle).not.toContain('display: none')
expect(guestStyle).toContain('display: none')
})
})
})
@@ -0,0 +1,31 @@
// @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', () => {
const popup = {
document: { write: vi.fn(), close: vi.fn() },
print: vi.fn(),
}
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).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()
})
})
})
+76
View File
@@ -0,0 +1,76 @@
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 ({ context }) => {
const page = await openDisplay(context);
await page.waitForTimeout(500);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.disableRules(['color-contrast']) // il display ha sfondo nero con testo grande, valutato separatamente
.analyze();
expect(results.violations).toEqual([]);
});
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
if (results.violations.length > 0) {
console.log('A11y violations:', JSON.stringify(results.violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length
})), null, 2));
}
// Accettiamo solo violazioni minor (non critiche o serie)
const serious = results.violations.filter(v =>
v.impact === 'critical' || v.impact === 'serious'
);
expect(serious).toEqual([]);
});
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');
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const box = await buttons.nth(i).boundingBox();
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
}
});
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();
for (let i = 0; i < count; i++) {
const box = await scoreButtons.nth(i).boundingBox();
expect(box.width).toBeGreaterThanOrEqual(100);
expect(box.height).toBeGreaterThanOrEqual(100);
}
});
});
+80
View File
@@ -0,0 +1,80 @@
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 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 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');
await expect(homeScore).toHaveText('0');
await expect(guestScore).toHaveText('0');
});
test('click +1 Home sul Controller dovrebbe aggiornare il Display', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Click +1 Home
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(200);
// Verifica Controller mostra 1
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('1');
// Verifica Display mostra 1 (il punteggio grande)
await expect(displayPage.locator('.punt.home')).toHaveText('1');
});
test('click +1 Guest sul Controller dovrebbe aggiornare il Display', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Click +1 Guest
await controllerPage.locator('.team-score.guest-bg').click();
await controllerPage.waitForTimeout(200);
// Verifica Controller
await expect(controllerPage.locator('.team-score.guest-bg .team-pts')).toHaveText('1');
// Verifica Display
await expect(displayPage.locator('.punt.guest')).toHaveText('1');
});
test('la sincronizzazione dovrebbe funzionare con punti alternati', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Home +1, Guest +1, Home +1
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(100);
await controllerPage.locator('.team-score.guest-bg').click();
await controllerPage.waitForTimeout(100);
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(200);
// Controller: Home 2, Guest 1
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('2');
await expect(controllerPage.locator('.team-score.guest-bg .team-pts')).toHaveText('1');
// Display: Home 2, Guest 1
await expect(displayPage.locator('.punt.home')).toHaveText('2');
await expect(displayPage.locator('.punt.guest')).toHaveText('1');
});
});
+92
View File
@@ -0,0 +1,92 @@
const { test, expect } = require('@playwright/test');
const { openController, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Full Match Simulation', () => {
test('Partita 2/3: Home vince il primo set', async ({ context }) => {
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Cambia modalità a 2/3
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 vince 25-0 ===
await addPoints(controllerPage, 'home', 25);
// Verifica punteggio 25
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('25');
// 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 openController(context);
await resetGame(controllerPage);
// Cambia modalità a 2/3
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);
// Imposta set 1-1 manualmente (simula set pareggiati)
await controllerPage.locator('.btn-set.home-bg').click();
await controllerPage.waitForTimeout(50);
await controllerPage.locator('.btn-set.guest-bg').click();
await controllerPage.waitForTimeout(100);
// Verifica set 1-1
await expect(controllerPage.locator('.team-score.home-bg .team-set')).toContainText('SET 1');
await expect(controllerPage.locator('.team-score.guest-bg .team-set')).toContainText('SET 1');
// === SET DECISIVO: Home porta a 15 ===
await addPoints(controllerPage, 'home', 15);
// 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');
// 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 openController(context);
await resetGame(controllerPage);
// Porta a 24-24
await addPoints(controllerPage, 'home', 24);
await addPoints(controllerPage, 'guest', 24);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('24');
await expect(controllerPage.locator('.team-score.guest-bg .team-pts')).toHaveText('24');
// Home va a 25 (non è vittoria perché serve scarto di 2)
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(100);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('25');
// Si possono ancora aggiungere punti (non è vittoria a 25-24)
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();
});
});
+154
View File
@@ -0,0 +1,154 @@
const { test, expect } = require('@playwright/test');
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
test.describe('Game Operations', () => {
test('Undo: dovrebbe annullare l\'ultimo punto', async ({ context }) => {
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Incrementa Home a 1
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(100);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('1');
// Annulla
await controllerPage.getByText('ANNULLA PUNTO').click();
await controllerPage.waitForTimeout(100);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('0');
});
test('Reset: dovrebbe azzerare tutto dopo conferma', async ({ context }) => {
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Imposta qualche punto
for (let i = 0; i < 5; i++) {
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(50);
}
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('5');
// Reset
await resetGame(controllerPage);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('0');
await expect(controllerPage.locator('.team-score.guest-bg .team-pts')).toHaveText('0');
});
test('Config: dovrebbe cambiare i nomi dei team', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri config
await controllerPage.getByText('Config').click();
await controllerPage.waitForSelector('.dialog-config');
// Modifica nomi
const inputs = controllerPage.locator('.dialog-config .input-field');
await inputs.first().fill('Padova');
await inputs.nth(1).fill('Milano');
// Salva
await controllerPage.locator('.dialog-config .btn-confirm').click();
await controllerPage.waitForTimeout(300);
// Verifica sul Controller
await expect(controllerPage.locator('.team-score.home-bg .team-name')).toHaveText('Padova');
await expect(controllerPage.locator('.team-score.guest-bg .team-name')).toHaveText('Milano');
// Verifica sul Display
await expect(displayPage.locator('.hea.home')).toContainText('Padova');
await expect(displayPage.locator('.hea.guest')).toContainText('Milano');
});
test('Toggle Formazione: dovrebbe mostrare la formazione sul display', async ({ context }) => {
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();
// Click Formazioni
await controllerPage.getByText('Formazioni').click();
await controllerPage.waitForTimeout(300);
// Il display mostra le formazioni
await expect(displayPage.locator('.form').first()).toBeVisible();
});
test('Toggle Striscia: dovrebbe nascondere/mostrare la striscia', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Inizialmente la striscia è visibile
await expect(displayPage.locator('.striscia')).toBeVisible();
// Toggle off
await controllerPage.getByText('Striscia').click();
await controllerPage.waitForTimeout(300);
await expect(displayPage.locator('.striscia')).not.toBeVisible();
// Toggle on
await controllerPage.getByText('Striscia').click();
await controllerPage.waitForTimeout(300);
await expect(displayPage.locator('.striscia')).toBeVisible();
});
test('Cambi: dovrebbe effettuare una sostituzione giocatore', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Attiva formazione sul display per verificare
await controllerPage.getByText('Formazioni').click();
await controllerPage.waitForTimeout(200);
// Apri cambi → scegli Home
await controllerPage.getByRole('button', { name: 'Cambi', exact: true }).click();
await controllerPage.waitForTimeout(100);
await controllerPage.locator('.dialog .btn-set.home-bg').click();
await controllerPage.waitForTimeout(100);
// Inserisci sostituzione: IN=10, OUT=1
const inField = controllerPage.locator('.cambi-in-field').first();
const outField = controllerPage.locator('.cambi-out-field').first();
await inField.fill('10');
await outField.fill('1');
// Conferma
await controllerPage.locator('.dialog .btn-confirm').click();
await controllerPage.waitForTimeout(300);
// Verifica formazione aggiornata sul display
const formText = await displayPage.locator('.form.home').textContent();
expect(formText).toContain('10');
});
test('Cambi: dovrebbe mostrare errore per giocatore non in formazione', async ({ context }) => {
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri cambi → scegli Home
await controllerPage.getByRole('button', { name: 'Cambi', exact: true }).click();
await controllerPage.waitForTimeout(100);
await controllerPage.locator('.dialog .btn-set.home-bg').click();
await controllerPage.waitForTimeout(100);
// Inserisci sostituzione invalida: OUT=99 (non in formazione)
await controllerPage.locator('.cambi-in-field').first().fill('10');
await controllerPage.locator('.cambi-out-field').first().fill('99');
// Conferma
await controllerPage.locator('.dialog .btn-confirm').click();
await controllerPage.waitForTimeout(200);
// Dovrebbe mostrare errore
await expect(controllerPage.locator('.cambi-error')).toBeVisible();
});
});
+28
View File
@@ -0,0 +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 }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// 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);
// 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');
// 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 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();
});
});
+62
View File
@@ -0,0 +1,62 @@
const { test, expect } = require('@playwright/test');
const { openController, openDisplay, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Visual Regression', () => {
test('Display: screenshot a 0-0', async ({ context }) => {
const controllerPage = await openController(context);
const displayPage = await openDisplay(context);
await resetGame(controllerPage);
// Attende che il display riceva lo stato
await displayPage.waitForTimeout(500);
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 openController(context);
const displayPage = await openDisplay(context);
await resetGame(controllerPage);
// Porta il punteggio a 15-12
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 openController(context);
await resetGame(controllerPage);
await expect(controllerPage).toHaveScreenshot('controller-initial.png', {
maxDiffPixelRatio: 0.05,
});
});
test('Controller: screenshot con modal config aperta', async ({ context }) => {
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri config
await controllerPage.getByText('Config').click();
await controllerPage.waitForSelector('.dialog-config');
await controllerPage.waitForTimeout(300);
await expect(controllerPage).toHaveScreenshot('controller-config-modal.png', {
maxDiffPixelRatio: 0.05,
});
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

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')
})
})
})
+518
View File
@@ -0,0 +1,518 @@
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() {
super()
this.readyState = 1 // OPEN
}
send = vi.fn()
terminate = vi.fn()
}
class MockWebSocketServer extends EventEmitter {
clients = new Set()
}
// Helper: connette e registra un client
function connectAndRegister(wss, role) {
const ws = new MockWebSocket()
wss.emit('connection', ws)
wss.clients.add(ws)
ws.emit('message', JSON.stringify({ type: 'register', role }))
ws.send.mockClear()
return ws
}
// Helper: ultimo messaggio inviato a un ws
function lastSent(ws) {
const calls = ws.send.mock.calls
return JSON.parse(calls[calls.length - 1][0])
}
describe('WebSocket Integration (websocket-handler.js)', () => {
let wss
let handler
beforeEach(() => {
wss = new MockWebSocketServer()
handler = setupWebSocketHandler(wss)
})
afterEach(() => {
vi.restoreAllMocks()
})
// =============================================
// REGISTRAZIONE
// =============================================
describe('Registrazione', () => {
it('dovrebbe registrare un client come "display" e inviare lo stato', () => {
const ws = new MockWebSocket()
wss.emit('connection', ws)
wss.clients.add(ws)
ws.emit('message', JSON.stringify({ type: 'register', role: 'display' }))
expect(ws.send).toHaveBeenCalled()
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
expect(sentMsg.type).toBe('state')
expect(sentMsg.state).toBeDefined()
})
it('dovrebbe registrare un client come "controller"', () => {
connectAndRegister(wss, 'controller')
expect(handler.getClients().size).toBe(1)
})
it('dovrebbe rifiutare ruolo non valido', () => {
const ws = new MockWebSocket()
wss.emit('connection', ws)
wss.clients.add(ws)
ws.emit('message', JSON.stringify({ type: 'register', role: 'hacker' }))
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
expect(sentMsg.type).toBe('error')
expect(sentMsg.message).toContain('Invalid role')
})
it('dovrebbe usare "display" come ruolo default se mancante', () => {
const ws = new MockWebSocket()
wss.emit('connection', ws)
wss.clients.add(ws)
ws.emit('message', JSON.stringify({ type: 'register' }))
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
expect(sentMsg.type).toBe('state')
})
})
// =============================================
// AZIONI
// =============================================
describe('Azioni', () => {
it('dovrebbe permettere al controller di cambiare il punteggio', () => {
const controller = connectAndRegister(wss, 'controller')
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
expect(controller.send).toHaveBeenCalled()
const sentMsg = lastSent(controller)
expect(sentMsg.type).toBe('state')
expect(puntHome(sentMsg.state)).toBe(1)
})
it('dovrebbe impedire al display di inviare azioni', () => {
const display = connectAndRegister(wss, 'display')
display.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
const sentMsg = lastSent(display)
expect(sentMsg.type).toBe('error')
expect(sentMsg.message).toContain('Only controllers')
})
it('dovrebbe impedire azioni da client non registrati', () => {
const ws = new MockWebSocket()
wss.emit('connection', ws)
wss.clients.add(ws)
ws.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
expect(sentMsg.type).toBe('error')
expect(sentMsg.message).toContain('Only controllers')
})
it('dovrebbe rifiutare azione con formato invalido (missing action)', () => {
const controller = connectAndRegister(wss, 'controller')
controller.emit('message', JSON.stringify({
type: 'action'
}))
const sentMsg = lastSent(controller)
expect(sentMsg.type).toBe('error')
expect(sentMsg.message).toContain('Invalid action format')
})
it('dovrebbe rifiutare azione con formato invalido (missing action.type)', () => {
const controller = connectAndRegister(wss, 'controller')
controller.emit('message', JSON.stringify({
type: 'action',
action: { team: 'home' }
}))
const sentMsg = lastSent(controller)
expect(sentMsg.type).toBe('error')
expect(sentMsg.message).toContain('Invalid action format')
})
})
// =============================================
// 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
// =============================================
describe('Broadcast', () => {
it('dovrebbe inviare lo stato a tutti i client dopo un\'azione', () => {
const controller = connectAndRegister(wss, 'controller')
const display1 = connectAndRegister(wss, 'display')
const display2 = connectAndRegister(wss, 'display')
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
// Tutti i client nel set dovrebbero aver ricevuto lo stato
expect(controller.send).toHaveBeenCalled()
expect(display1.send).toHaveBeenCalled()
expect(display2.send).toHaveBeenCalled()
const msg1 = lastSent(display1)
const msg2 = lastSent(display2)
expect(msg1.type).toBe('state')
expect(puntHome(msg1.state)).toBe(1)
expect(puntHome(msg2.state)).toBe(1)
})
it('non dovrebbe inviare a client con readyState != OPEN', () => {
const controller = connectAndRegister(wss, 'controller')
const closedClient = connectAndRegister(wss, 'display')
closedClient.readyState = 3 // CLOSED
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
// closedClient non dovrebbe aver ricevuto il broadcast
expect(closedClient.send).not.toHaveBeenCalled()
})
})
// =============================================
// SPEAK
// =============================================
describe('Speak', () => {
it('dovrebbe inoltrare il messaggio speak solo ai display', () => {
const controller = connectAndRegister(wss, 'controller')
const display = connectAndRegister(wss, 'display')
controller.emit('message', JSON.stringify({
type: 'speak',
text: 'quindici a dieci'
}))
// Il display riceve il messaggio speak
expect(display.send).toHaveBeenCalled()
const msg = lastSent(display)
expect(msg.type).toBe('speak')
expect(msg.text).toBe('quindici a dieci')
})
it('non dovrebbe permettere al display di inviare speak', () => {
const display = connectAndRegister(wss, 'display')
display.emit('message', JSON.stringify({
type: 'speak',
text: 'test'
}))
const msg = lastSent(display)
expect(msg.type).toBe('error')
expect(msg.message).toContain('Only controllers')
})
it('dovrebbe rifiutare speak con testo vuoto', () => {
const controller = connectAndRegister(wss, 'controller')
controller.emit('message', JSON.stringify({
type: 'speak',
text: ' '
}))
const msg = lastSent(controller)
expect(msg.type).toBe('error')
expect(msg.message).toContain('Invalid speak payload')
})
it('dovrebbe rifiutare speak senza testo', () => {
const controller = connectAndRegister(wss, 'controller')
controller.emit('message', JSON.stringify({
type: 'speak'
}))
const msg = lastSent(controller)
expect(msg.type).toBe('error')
})
it('dovrebbe fare trim del testo speak', () => {
const controller = connectAndRegister(wss, 'controller')
const display = connectAndRegister(wss, 'display')
controller.emit('message', JSON.stringify({
type: 'speak',
text: ' dieci a otto '
}))
const msg = lastSent(display)
expect(msg.text).toBe('dieci a otto')
})
})
// =============================================
// MESSAGGI MALFORMATI
// =============================================
describe('Messaggi malformati', () => {
it('dovrebbe gestire JSON non valido senza crash', () => {
const ws = new MockWebSocket()
wss.emit('connection', ws)
wss.clients.add(ws)
expect(() => {
ws.emit('message', 'questo non è JSON {{{')
}).not.toThrow()
const msg = lastSent(ws)
expect(msg.type).toBe('error')
expect(msg.message).toContain('Invalid message format')
})
it('dovrebbe gestire Buffer come input', () => {
const controller = connectAndRegister(wss, 'controller')
const buf = Buffer.from(JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
controller.emit('message', buf)
const msg = lastSent(controller)
expect(msg.type).toBe('state')
expect(puntHome(msg.state)).toBe(1)
})
})
// =============================================
// DISCONNESSIONE
// =============================================
describe('Disconnessione', () => {
it('dovrebbe rimuovere il client dalla mappa alla disconnessione', () => {
const controller = connectAndRegister(wss, 'controller')
expect(handler.getClients().size).toBe(1)
controller.emit('close')
expect(handler.getClients().size).toBe(0)
})
it('i client rimanenti non dovrebbero essere affetti dalla disconnessione', () => {
const controller = connectAndRegister(wss, 'controller')
const display = connectAndRegister(wss, 'display')
expect(handler.getClients().size).toBe(2)
controller.emit('close')
expect(handler.getClients().size).toBe(1)
expect(handler.getClients().has(display)).toBe(true)
})
})
// =============================================
// ERRORI WEBSOCKET
// =============================================
describe('Errori WebSocket', () => {
it('dovrebbe terminare la connessione per errore UTF8 invalido', () => {
const ws = new MockWebSocket()
wss.emit('connection', ws)
const err = new Error('Invalid UTF8')
err.code = 'WS_ERR_INVALID_UTF8'
ws.emit('error', err)
expect(ws.terminate).toHaveBeenCalled()
})
it('dovrebbe terminare la connessione per close code invalido', () => {
const ws = new MockWebSocket()
wss.emit('connection', ws)
const err = new Error('Invalid close code')
err.code = 'WS_ERR_INVALID_CLOSE_CODE'
ws.emit('error', err)
expect(ws.terminate).toHaveBeenCalled()
})
it('non dovrebbe terminare per altri errori', () => {
const ws = new MockWebSocket()
wss.emit('connection', ws)
const err = new Error('Generic error')
ws.emit('error', err)
expect(ws.terminate).not.toHaveBeenCalled()
})
})
// =============================================
// API PUBBLICA
// =============================================
describe('API pubblica', () => {
it('getState dovrebbe restituire lo stato corrente', () => {
const state = handler.getState()
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.striscia.at(-1).ris = 'hh'
handler.setState(newState)
expect(puntHome(handler.getState())).toBe(2)
})
it('broadcastState dovrebbe inviare a tutti i client', () => {
const display = connectAndRegister(wss, 'display')
handler.broadcastState()
expect(display.send).toHaveBeenCalled()
const msg = lastSent(display)
expect(msg.type).toBe('state')
})
it('getClients dovrebbe restituire la mappa dei client', () => {
expect(handler.getClients()).toBeInstanceOf(Map)
expect(handler.getClients().size).toBe(0)
connectAndRegister(wss, 'display')
expect(handler.getClients().size).toBe(1)
})
})
})
+122
View File
@@ -0,0 +1,122 @@
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()
this.readyState = 1
}
send = vi.fn()
terminate = vi.fn()
}
class MockWebSocketServer extends EventEmitter {
clients = new Set()
}
function connectAndRegister(wss, role) {
const ws = new MockWebSocket()
wss.emit('connection', ws)
wss.clients.add(ws)
ws.emit('message', JSON.stringify({ type: 'register', role }))
ws.send.mockClear()
return ws
}
describe('Stress Test WebSocket', () => {
let wss
let handler
beforeEach(() => {
wss = new MockWebSocketServer()
handler = setupWebSocketHandler(wss)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('dovrebbe gestire 50 client display connessi simultaneamente', () => {
const displays = []
for (let i = 0; i < 50; i++) {
displays.push(connectAndRegister(wss, 'display'))
}
expect(handler.getClients().size).toBe(50)
// Un controller invia un'azione
const controller = connectAndRegister(wss, 'controller')
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
// Tutti i display devono aver ricevuto il broadcast
for (const display of displays) {
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(punt(msg.state).home).toBe(1)
}
})
it('dovrebbe gestire 100 azioni rapide in sequenza con stato finale corretto', () => {
const controller = connectAndRegister(wss, 'controller')
// 60 punti home, 40 punti guest
for (let i = 0; i < 60; i++) {
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
}
for (let i = 0; i < 40; i++) {
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'guest' }
}))
}
// 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(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(punt(state).guest).toBe(0)
})
it('dovrebbe garantire che tutti i display ricevano ogni update sotto carico', () => {
const displays = []
for (let i = 0; i < 10; i++) {
displays.push(connectAndRegister(wss, 'display'))
}
const controller = connectAndRegister(wss, 'controller')
// 5 azioni rapide
for (let i = 0; i < 5; i++) {
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
}
// Ogni display deve aver ricevuto esattamente 5 broadcast
for (const display of displays) {
expect(display.send).toHaveBeenCalledTimes(5)
}
// Verifica stato finale su tutti i display
for (const display of displays) {
const lastMsg = JSON.parse(display.send.mock.calls[4][0])
expect(punt(lastMsg.state).home).toBe(5)
}
})
})
+994
View File
@@ -0,0 +1,994 @@
import { describe, it, expect, beforeEach } from 'vitest'
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
beforeEach(() => {
state = createInitialState()
})
// =============================================
// STATO INIZIALE
// =============================================
describe('Stato iniziale', () => {
it('dovrebbe iniziare con 0-0', () => {
expect(puntDi(state).home).toBe(0)
expect(puntDi(state).guest).toBe(0)
})
it('dovrebbe avere i set a 0', () => {
expect(setDi(state).home).toBe(0)
expect(setDi(state).guest).toBe(0)
})
it('dovrebbe avere servizio Home', () => {
expect(servDi(state)).toBe(true)
})
it('dovrebbe avere formazione di default [1-6]', () => {
expect(state.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
expect(state.sp.form.guest).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('dovrebbe avere la striscia iniziale con un set vuoto', () => {
expect(state.sp.striscia).toHaveLength(1)
expect(state.sp.striscia[0].serv).toBe('h')
expect(state.sp.striscia[0].ris).toBe('')
})
it('dovrebbe avere modalità 3/5 di default', () => {
expect(state.modalitaPartita).toBe("3/5")
})
it('dovrebbe avere visuForm false e visuStriscia true', () => {
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)
})
})
// =============================================
// IMMUTABILITÀ
// =============================================
describe('Immutabilità', () => {
it('applyAction non dovrebbe mutare lo stato originale', () => {
const original = JSON.stringify(state)
applyAction(state, { type: 'incPunt', team: 'home' })
expect(JSON.stringify(state)).toBe(original)
})
it('dovrebbe restituire un nuovo oggetto', () => {
const newState = applyAction(state, { type: 'incPunt', team: 'home' })
expect(newState).not.toBe(state)
})
})
// =============================================
// INCREMENTO PUNTI (incPunt)
// =============================================
describe('incPunt', () => {
it('dovrebbe incrementare i punti Home', () => {
const newState = applyAction(state, { type: 'incPunt', team: 'home' })
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(puntDi(newState).guest).toBe(1)
expect(puntDi(newState).home).toBe(0)
})
it('dovrebbe gestire il cambio palla (Guest segna, batteva Home)', () => {
setServizioIniziale(state, 'home')
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(servDi(s1)).toBe(false)
})
it('dovrebbe gestire il cambio palla (Home segna, batteva Guest)', () => {
setServizioIniziale(state, 'guest')
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
expect(servDi(s1)).toBe(true)
})
it('non dovrebbe cambiare palla se segna chi batte', () => {
setServizioIniziale(state, 'home')
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
expect(servDi(s1)).toBe(true)
})
it('dovrebbe ruotare la formazione al cambio palla', () => {
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', () => {
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"])
})
it('dovrebbe aggiornare la striscia per punto Home', () => {
const s = applyAction(state, { type: 'incPunt', team: '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).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).ris).toBe('hgh')
})
it('non dovrebbe incrementare i punti dopo vittoria', () => {
setPunteggio(state, 25, 23)
const s = applyAction(state, { type: 'incPunt', team: 'home' })
expect(puntDi(s).home).toBe(25)
})
})
// =============================================
// DECREMENTO PUNTI (decPunt)
// =============================================
describe('decPunt', () => {
it('dovrebbe annullare l\'ultimo punto Home', () => {
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
const s2 = applyAction(s1, { type: 'decPunt' })
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(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(puntDi(s).home).toBe(0)
expect(puntDi(s).guest).toBe(0)
})
it('dovrebbe ripristinare il servizio dopo undo con cambio palla', () => {
setServizioIniziale(state, 'home')
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(servDi(s1)).toBe(false)
const s2 = applyAction(s1, { type: 'decPunt' })
expect(servDi(s2)).toBe(true)
})
it('dovrebbe invertire la rotazione dopo undo con cambio palla', () => {
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"])
const s2 = applyAction(s1, { type: 'decPunt' })
expect(s2.sp.form.guest).toEqual(["1", "2", "3", "4", "5", "6"])
})
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).ris).toBe('')
})
it('dovrebbe gestire undo multipli in sequenza', () => {
let s = state
s = applyAction(s, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, { type: 'incPunt', team: 'home' })
expect(puntDi(s).home).toBe(2)
expect(puntDi(s).guest).toBe(1)
s = applyAction(s, { type: 'decPunt' })
expect(puntDi(s).home).toBe(1)
s = applyAction(s, { type: 'decPunt' })
expect(puntDi(s).guest).toBe(0)
s = applyAction(s, { type: 'decPunt' })
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'])
})
})
// =============================================
// INCREMENTO SET (incSet)
// =============================================
describe('incSet', () => {
it('dovrebbe incrementare il set Home', () => {
const s = applyAction(state, { type: 'incSet', team: 'home' })
expect(setDi(s).home).toBe(1)
})
it('dovrebbe incrementare il set Guest', () => {
const s = applyAction(state, { type: 'incSet', team: 'guest' })
expect(setDi(s).guest).toBe(1)
})
it('dovrebbe fare wrap da 2 a 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', () => {
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)
})
})
// =============================================
// NUOVO SET (nuovoSet)
// =============================================
describe('nuovoSet', () => {
it('dovrebbe incrementare il set della squadra vincente', () => {
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(setDi(s).home).toBe(1)
expect(setDi(s).guest).toBe(0)
})
it('dovrebbe azzerare i punti nel nuovo set', () => {
setPunteggio(state, 25, 10)
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
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).ris).toBe('')
expect(s.sp.striscia.at(-1).serv).toBe('h')
})
it('dovrebbe conservare il set precedente nella striscia', () => {
state.sp.striscia[0].ris = 'hgh'
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(s.sp.striscia[0].ris).toBe('hgh')
})
it('dovrebbe resettare le formazioni', () => {
state.sp.form.home = ['7', '8', '9', '10', '11', '12']
state.sp.form.guest = ['7', '8', '9', '10', '11', '12']
const s = applyAction(state, { type: 'nuovoSet', team: 'home' })
expect(s.sp.form.home).toEqual(['1', '2', '3', '4', '5', '6'])
expect(s.sp.form.guest).toEqual(['1', '2', '3', '4', '5', '6'])
})
it('dovrebbe ignorare team non valido', () => {
const s = applyAction(state, { type: 'nuovoSet', team: 'invalid' })
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)
})
})
// =============================================
// CAMBIO PALLA (cambiaPalla)
// =============================================
describe('cambiaPalla', () => {
it('dovrebbe invertire il servizio a 0-0', () => {
expect(servDi(state)).toBe(true)
const s = applyAction(state, { type: 'cambiaPalla' })
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(servDi(s)).toBe(true)
})
it('non dovrebbe cambiare palla se il punteggio non è 0-0', () => {
setPunteggio(state, 1, 0)
const prima = servDi(state)
const s = applyAction(state, { type: 'cambiaPalla' })
expect(servDi(s)).toBe(prima)
})
it('non dovrebbe cambiare palla se Guest ha punti', () => {
setPunteggio(state, 0, 3)
const prima = servDi(state)
const s = applyAction(state, { type: 'cambiaPalla' })
expect(servDi(s)).toBe(prima)
})
})
// =============================================
// TOGGLE (toggleFormazione, toggleStriscia, toggleOrder)
// =============================================
describe('Toggle', () => {
it('toggleFormazione: false → true', () => {
expect(state.visuForm).toBe(false)
const s = applyAction(state, { type: 'toggleFormazione' })
expect(s.visuForm).toBe(true)
})
it('toggleFormazione: true → false', () => {
state.visuForm = true
const s = applyAction(state, { type: 'toggleFormazione' })
expect(s.visuForm).toBe(false)
})
it('toggleStriscia: true → false', () => {
expect(state.visuStriscia).toBe(true)
const s = applyAction(state, { type: 'toggleStriscia' })
expect(s.visuStriscia).toBe(false)
})
it('toggleOrder: true → false', () => {
expect(state.order).toBe(true)
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()
})
})
// =============================================
// NOMI (setNomi)
// =============================================
describe('setNomi', () => {
it('dovrebbe aggiornare entrambi i nomi', () => {
const s = applyAction(state, { type: 'setNomi', home: 'Volley A', guest: 'Volley B' })
expect(s.sp.nomi.home).toBe('Volley A')
expect(s.sp.nomi.guest).toBe('Volley B')
})
it('dovrebbe aggiornare solo il nome Home se guest è undefined', () => {
const s = applyAction(state, { type: 'setNomi', home: 'Volley A' })
expect(s.sp.nomi.home).toBe('Volley A')
expect(s.sp.nomi.guest).toBe('Guest')
})
it('dovrebbe aggiornare solo il nome Guest se home è undefined', () => {
const s = applyAction(state, { type: 'setNomi', guest: 'Volley B' })
expect(s.sp.nomi.home).toBe('Antoniana')
expect(s.sp.nomi.guest).toBe('Volley B')
})
})
// =============================================
// MODALITÀ (setModalita)
// =============================================
describe('setModalita', () => {
it('dovrebbe cambiare in 2/3', () => {
const s = applyAction(state, { type: 'setModalita', modalita: '2/3' })
expect(s.modalitaPartita).toBe('2/3')
})
it('dovrebbe cambiare in 3/5', () => {
state.modalitaPartita = '2/3'
const s = applyAction(state, { type: 'setModalita', modalita: '3/5' })
expect(s.modalitaPartita).toBe('3/5')
})
})
// =============================================
// FORMAZIONE (setFormazione)
// =============================================
describe('setFormazione', () => {
it('dovrebbe sostituire la formazione Home', () => {
const nuova = ["10", "11", "12", "13", "14", "15"]
const s = applyAction(state, { type: 'setFormazione', team: 'home', form: nuova })
expect(s.sp.form.home).toEqual(nuova)
})
it('dovrebbe sostituire la formazione Guest', () => {
const nuova = ["7", "8", "9", "10", "11", "12"]
const s = applyAction(state, { type: 'setFormazione', team: 'guest', form: nuova })
expect(s.sp.form.guest).toEqual(nuova)
})
it('non dovrebbe modificare se manca team', () => {
const s = applyAction(state, { type: 'setFormazione', form: ["7", "8", "9", "10", "11", "12"] })
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('non dovrebbe modificare se manca form', () => {
const s = applyAction(state, { type: 'setFormazione', team: 'home' })
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
})
})
// =============================================
// 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, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "10", out: "3" }]
})
expect(s.sp.form.home).toContain("10")
expect(s.sp.form.home).not.toContain("3")
})
it('dovrebbe gestire doppia sostituzione', () => {
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [
{ in: "10", out: "1" },
{ in: "11", out: "2" }
]
})
expect(s.sp.form.home).toContain("10")
expect(s.sp.form.home).toContain("11")
expect(s.sp.form.home).not.toContain("1")
expect(s.sp.form.home).not.toContain("2")
})
it('non dovrebbe accettare input non numerico', () => {
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "abc", out: "1" }]
})
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('non dovrebbe accettare in == out', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "1", out: "1" }]
})
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('non dovrebbe accettare giocatore IN già in formazione', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "2", out: "1" }]
})
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('non dovrebbe accettare giocatore OUT non in formazione', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "10", out: "99" }]
})
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('dovrebbe saltare cambi con campo vuoto', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [
{ in: "", out: "" },
{ in: "10", out: "1" }
]
})
expect(s.sp.form.home).toContain("10")
})
it('dovrebbe mantenere la posizione del giocatore sostituito', () => {
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "10", out: "3" }]
})
expect(s.sp.form.home[2]).toBe("10")
})
it('dovrebbe gestire cambi sequenziali che dipendono l\'uno dall\'altro', () => {
// Sostituisci 1→10, poi 10→20 (il secondo dipende dal risultato del primo)
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [
{ in: "10", out: "1" },
{ in: "20", out: "10" }
]
})
expect(s.sp.form.home).toContain("20")
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)
})
})
// =============================================
// VITTORIA SET (checkVittoria)
// =============================================
describe('checkVittoria', () => {
it('non dovrebbe dare vittoria a 24-24', () => {
setPunteggio(state, 24, 24)
expect(checkVittoria(state)).toBe(false)
})
it('dovrebbe dare vittoria a 25-23', () => {
setPunteggio(state, 25, 23)
expect(checkVittoria(state)).toBe(true)
})
it('non dovrebbe dare vittoria a 25-24 (serve 2 punti di scarto)', () => {
setPunteggio(state, 25, 24)
expect(checkVittoria(state)).toBe(false)
})
it('dovrebbe dare vittoria a 26-24', () => {
setPunteggio(state, 26, 24)
expect(checkVittoria(state)).toBe(true)
})
it('dovrebbe dare vittoria Guest a 25-20', () => {
setPunteggio(state, 20, 25)
expect(checkVittoria(state)).toBe(true)
})
it('dovrebbe dare vittoria ai vantaggi (30-28)', () => {
setPunteggio(state, 30, 28)
expect(checkVittoria(state)).toBe(true)
})
it('non dovrebbe dare vittoria ai vantaggi senza scarto (28-27)', () => {
setPunteggio(state, 28, 27)
expect(checkVittoria(state)).toBe(false)
})
})
// =============================================
// SET DECISIVO (15 punti)
// =============================================
describe('Set decisivo', () => {
it('modalità 3/5: set decisivo dopo 4 set totali → vittoria a 15', () => {
state.modalitaPartita = "3/5"
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"
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"
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"
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"
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"
setSetVinti(state, 1, 1)
setPunteggio(state, 15, 10)
expect(checkVittoria(state)).toBe(true)
})
it('modalità 2/3: non vittoria a 14-10 nel set decisivo (soglia 15)', () => {
state.modalitaPartita = "2/3"
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"
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"
setSetVinti(state, 2, 1)
setPunteggio(state, 15, 10)
expect(checkVittoria(state)).toBe(false)
})
})
// =============================================
// RESET
// =============================================
describe('Reset', () => {
it('dovrebbe resettare punti e set a zero', () => {
setSetVinti(state, 1, 1)
setPunteggio(state, 10, 8)
const s = applyAction(state, { type: 'resetta' })
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', () => {
state.sp.form.home = ["10", "11", "12", "13", "14", "15"]
const s = applyAction(state, { type: 'resetta' })
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
expect(s.sp.form.guest).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('dovrebbe resettare la striscia a un set vuoto', () => {
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].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', () => {
state.visuForm = true
const s = applyAction(state, { type: 'resetta' })
expect(s.visuForm).toBe(false)
})
it('dovrebbe mantenere nomi e modalità', () => {
state.sp.nomi.home = "Squadra A"
state.modalitaPartita = "2/3"
const s = applyAction(state, { type: 'resetta' })
expect(s.sp.nomi.home).toBe("Squadra A")
expect(s.modalitaPartita).toBe("2/3")
})
})
// =============================================
// AZIONE SCONOSCIUTA
// =============================================
describe('Azione sconosciuta', () => {
it('dovrebbe restituire lo stato invariato con azione non riconosciuta', () => {
const s = applyAction(state, { type: 'azioneInesistente' })
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([])
})
})
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { getNetworkIPs, collectIPs, 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', () => {
afterEach(() => {
vi.restoreAllMocks()
})
// =============================================
// getNetworkIPs / collectIPs
// =============================================
describe('getNetworkIPs', () => {
it('dovrebbe restituire indirizzi IPv4 non-loopback', () => {
expect(getNetworkIPs({
eth0: [{ family: 'IPv4', internal: false, address: '192.168.1.100' }]
})).toEqual(['192.168.1.100'])
})
it('dovrebbe escludere indirizzi loopback (internal)', () => {
const ips = getNetworkIPs({
lo: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
eth0: [{ family: 'IPv4', internal: false, address: '192.168.1.100' }]
})
expect(ips).not.toContain('127.0.0.1')
expect(ips).toContain('192.168.1.100')
})
it('dovrebbe escludere indirizzi IPv6', () => {
const ips = getNetworkIPs({
eth0: [
{ family: 'IPv6', internal: false, address: 'fe80::1' },
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
]
})
expect(ips).toEqual(['192.168.1.100'])
})
it('dovrebbe escludere bridge Docker 172.17.x.x', () => {
const ips = getNetworkIPs({
docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }],
eth0: [{ family: 'IPv4', internal: false, address: '10.0.0.5' }]
})
expect(ips).not.toContain('172.17.0.1')
expect(ips).toContain('10.0.0.5')
})
it('dovrebbe escludere bridge Docker 172.18.x.x', () => {
expect(getNetworkIPs({
br0: [{ family: 'IPv4', internal: false, address: '172.18.0.1' }]
})).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', () => {
expect(getNetworkIPs({})).toEqual([])
})
it('dovrebbe restituire più indirizzi da interfacce diverse', () => {
const ips = getNetworkIPs({
eth0: [{ family: 'IPv4', internal: false, address: '192.168.1.100' }],
wlan0: [{ family: 'IPv4', internal: false, address: '192.168.1.101' }]
})
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([])
})
})
// =============================================
// printServerInfo
// =============================================
describe('printServerInfo', () => {
it('dovrebbe stampare la porta di default (3000)', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
printServerInfo(3000, [])
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
expect(allLogs).toContain('3000')
expect(allLogs).toContain('/display')
expect(allLogs).toContain('/controller')
consoleSpy.mockRestore()
})
it('dovrebbe stampare la porta personalizzata', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
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', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
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')
consoleSpy.mockRestore()
})
it('non dovrebbe mostrare sezione remoti se nessun IP di rete', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
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'])
})
})
+51
View File
@@ -0,0 +1,51 @@
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 {
name: 'vite-plugin-websocket',
configureServer(server) {
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 = '/'
else if (req.url === '/controller' || req.url === '/controller/') req.url = '/controller.html'
next()
})
server.httpServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname
if (pathname === '/ws') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)
})
}
})
server.httpServer.once('listening', () => {
const { port } = server.httpServer.address()
setTimeout(() => printServerInfo(port), 100)
})
}
}
}
+20 -2
View File
@@ -1,12 +1,26 @@
import { defineConfig } from 'vite'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import websocketPlugin from './vite-plugin-websocket.js'
// https://vitejs.dev/config/
const __dirname = dirname(fileURLToPath(import.meta.url))
// Configurazione principale di Vite
export default defineConfig({
base: process.env.NODE_ENV === 'production' ? '/segnap' : '/',
base: '/',
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
controller: resolve(__dirname, 'controller.html'),
},
},
},
plugins: [
vue(),
websocketPlugin(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
@@ -32,4 +46,8 @@ export default defineConfig({
}
})
],
server: {
host: '0.0.0.0',
port: 5173,
},
})
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
include: [
'tests/unit/**/*.{test,spec}.js',
'tests/integration/**/*.{test,spec}.js',
'tests/component/**/*.{test,spec}.js',
'tests/stress/**/*.{test,spec}.js',
],
globals: true,
environment: 'node',
environmentMatchGlobs: [
['tests/component/**', 'happy-dom'],
],
},
})