41 Commits

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

- un solo video → loop; più video → sequenza alfabetica con ritorno al primo;
  cartella vuota/assente → overlay "TIME OUT".
- `src/spot-utils.js` (listSpotVideos) elenca i .mp4; endpoint `/spot/list`
  + file statici `/spot/<file>.mp4` serviti sia in prod (server.js) sia in
  dev (vite-plugin-websocket.js).
- il display avvia i video con audio e ripiega su muto se il browser blocca
  l'autoplay con suono.
- il controller resta operativo ma evidenzia la modalità con banner e
  pulsante arancione pulsante, in entrambi i layout (mobile ed esteso).
2026-06-21 18:35:12 +02:00
davide ffba5b368d merge: riparazione e completamento suite di test (Vitest 212, e2e 75)
- ripara la suite Vitest allineandola alla forma attuale dello stato
- migra e completa gli e2e all'architettura a porta singola
- aggiunge copertura per referto, formInizio, persist, wsMixin, routing
- allinea README, CLAUDE.md e tests/README
2026-06-21 18:05:04 +02:00
davide 1ab57654ad docs: allinea README, CLAUDE.md e guida ai test allo stato attuale
- README: referto tra le funzionalità del controller; comando test:ui e aree
  coperte dalle suite
- CLAUDE.md: referto.js (buildRefertoHtml/generaReferto), wsMixin/persist/
  server-utils, createApp/startServer; nota su formInizio e stato derivato
  dalla striscia
- tests/README: conteggi aggiornati (212 Vitest / 75 e2e) e nuove suite
2026-06-21 18:04:16 +02:00
davide c73267d347 test(e2e): visual-regression deterministica e baseline rigenerate
- screenshot stabili: animations disabilitate e mask su .connection-status
- migrata agli helper condivisi
- baseline rigenerate per Chromium, Firefox e Mobile Chrome
2026-06-21 18:04:06 +02:00
davide 5b5dddf2e8 test(e2e): accessibility migrata agli helper ed esclusione color-contrast
- usa openController/openDisplay + resetGame per stato pulito
- esclude la regola color-contrast anche sul controller (colori brand
  giallo/blu valutati separatamente, come già per il display)
2026-06-21 18:03:48 +02:00
davide e6099be0cb test(e2e): spec del referto a PARTITA FINITA
Porta una partita 2/3 a conclusione, intercetta il popup aperto dal bottone
REFERTO e verifica il contenuto (titolo, set, risultato). window.print è
neutralizzato per non bloccare il test.
2026-06-21 18:03:36 +02:00
davide d67f3669d6 test(e2e): helper condivisi e refactor degli spec di gioco
- nuovo tests/e2e/helpers.cjs: openController (viewport portrait per il layout
  mobile), openDisplay, resetGame, addPoints
- resetGame gestisce il dialog di vittoria persistente e reimposta i nomi di
  default (lo stato è condiviso e persistente: serve per il determinismo)
- full-match/game-simulation: gestito il dialog SET VINTO automatico a 25/15
- basic-flow/game-operations: migrati agli helper condivisi
2026-06-21 18:03:29 +02:00
56 changed files with 3097 additions and 4032 deletions
+1
View File
@@ -15,6 +15,7 @@ dist
dist-ssr
dev-dist
.segnapunti
spot
*.local
# Editor directories and files
+31
View File
@@ -7,6 +7,37 @@ e questo progetto aderisce al [Versionamento Semantico](https://semver.org/lang/
---
## [2.1.0] - 2026-07-08
### Aggiunto
- Time out con spot pubblicitari: sul controller una modal fa scegliere squadra e video (o nessuno) tra i file `.mp4` della cartella `spot/`; il display copre il tabellone con un overlay a countdown, riproduce il video scelto (con fallback su testo "TIME OUT <squadra>" se manca o finisce in anticipo) e sfuma nell'ultimo secondo; il server chiude automaticamente il time out dopo 30s anche in caso di disconnessione del controller; video elencati da `/spot/list` e serviti da `/spot/<file>.mp4`
- Storico di cambi e time out registrato per ogni set nella `striscia` (popolato lazy, ordinato per punteggio, usato dal referto)
- Modalità **Amichevole**: i set si vincono normalmente ma la partita non termina mai automaticamente, per giocare set illimitati
- Dialog dedicato a fine partita che blocca ulteriori punti e apre le azioni post-match (referto, chiusura)
- Prototipo di generazione referto di gara stampabile (`src/referto.js`): formazioni di partenza, griglia punti, cambi e time out per set
- Layout esteso "da regia" per il controller in orientamento landscape (tablet), con pannelli squadra affiancati
- Indicatore ON/OFF sul pulsante Striscia del controller; config si riapre automaticamente dopo un reset
- Supporto WSL2 in sviluppo: `getNetworkIPs()` interroga PowerShell per ottenere gli IP reali della rete Windows
- Flusso di sviluppo containerizzato con hot reload: `docker-compose.dev.yml` e nuovo target `dev` nel Dockerfile multi-stage
- Suite di test estesa a oltre il 99% di copertura statement, con nuovi test e2e (time out, referto, accessibilità, visual regression)
### Modificato
- `punteggio`/`setVinti`/`servizio` derivati sempre dalla `striscia` invece di essere memorizzati nello stato
- Struttura dati `striscia` compattata da array a stringa
- Il controller rispetta `state.order` (scambio lato squadre) come il display
- Logica di connessione WebSocket lato client estratta in `src/wsMixin.js` riusabile
- Dev server esposto su tutte le interfacce di rete (`vite --host`)
- Persistenza dati passata da named volume a bind mount (`./.segnapunti`) per accesso diretto ai file dall'host
### Rimosso
- Sezione shortcut da tastiera dal README (funzionalità non più implementata)
- Emoji da codice, UI e commenti (ora vietate salvo richiesta esplicita)
### Corretto
- Immagine Docker di produzione: mancava la copia di `src/spot-utils.js`, causando errori nel serving degli spot video
---
## [2.0.0] - 2026-05-12
### Aggiunto
+61 -32
View File
@@ -1,55 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guida per Claude Code su questo repository.
## 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.
**Segnapunti Anto**: PWA segnapunti pallavolo in tempo reale. Server Express/WebSocket gestisce lo stato di gioco; due interfacce Vue — **display** (tabellone pubblico) e **controller** (pannello operatore) — sincronizzate via WebSocket.
## 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 dev # Vite dev — display: :5173/display, controller: :5173/controller
npm run serve # Build + avvio produzione — display: :3000/display, controller: :3000/controller
npm run test # Vitest 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
npm run test # Vitest watch
npm run test:all # Tutte le suite in una sola esecuzione
npm run test:unit # Solo unit + integration
npm run test:component # Componenti Vue (happy-dom)
npm run test:stress # Load test (50+ client concorrenti)
npm run test:e2e # Playwright E2E (richiede `npm run serve` attivo)
npm run test:e2e:ui # Playwright con UI interattiva
```
Per eseguire un singolo file di test: `npx vitest run tests/unit/gameState.test.js`
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
│ │
│ └── persist.js ── .segnapunti/state.json
```
**Tutta la logica di gioco** è in `src/gameState.js` come tre funzioni pure esportate:
- `createInitialState()` restituisce lo stato iniziale
**`src/gameState.js`** — logica di gioco, funzioni pure:
- `createInitialState()` — 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)
- `checkVittoria(state)` — vittoria set: 25 punti/vantaggio 2 (15 nel set decisivo)
- `punteggio` / `setVinti` / `servizio` — derivati dalla `striscia`, NON memorizzati direttamente nello stato
`src/websocket-handler.js` 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.
Punteggio, set e servizio si ricavano sempre dalla `striscia`. Al primo punto di ogni set, `applyAction('incPunt')` salva in `formInizio` uno snapshot della formazione di partenza (usato dal referto); `decPunt` lo rimuove se il set torna a 0-0.
`server.js` (Express) serve entrambe le interfacce sulla porta 3000: `/display``dist/index.html`, `/controller``dist/controller.html`. Singolo endpoint WebSocket su `/ws`.
Ogni voce della `striscia` accumula anche lo storico di cambi/time out del set, popolato lazy (nessun campo se il set non ha eventi), senza timestamp — il "quando" è il punteggio al momento dell'evento, non l'orologio:
- `set.timeouts`: `{ team, punteggio: { home, guest } }`, appeso da `startTimeout` (solo se non già attivo, per non duplicare l'evento su un restart).
- `set.cambi`: `{ team, in, out, punteggio }`, uno per sostituzione applicata da `confermaCambi` (nulla se la validazione fallisce).
In sviluppo, `vite-plugin-websocket.js` incorpora il server WebSocket dentro il dev server Vite con middleware di URL rewrite per `/display` e `/controller`.
**`src/websocket-handler.js`** — riceve i messaggi, valida che il mittente sia `controller` (non `display`), chiama `applyAction`, fa broadcast del nuovo stato, poi invoca `onStateChange` per persistere.
**`src/wsMixin.js`** — mixin Vue lato client: connessione/riconnessione WebSocket (backoff esponenziale), espone i computed `punt`/`servHome`/`set`.
**`src/referto.js`** — genera il referto di gara: `buildRefertoHtml(state, now)` è pura (testabile, ritorna HTML); `generaReferto(state)` apre la finestra (`window.open`) e avvia la stampa. Per ogni set mostra formazione di partenza, griglia punti e (se presenti) `set.timeouts`/`set.cambi` ordinati per punteggio crescente.
**`src/persist.js`** — carica/salva lo stato su `.segnapunti/state.json`.
**`src/server-utils.js`** — `getNetworkIPs` (sorgente iniettabile), stampa gli URL all'avvio.
**`src/spot-utils.js`** — `listSpotVideos(spotDir)`: elenca i `.mp4` (ordinati) di `spot/`; `[]` se la cartella manca.
**Time out / spot video**`startTimeout` (`team`, `video` opzionale) e `stopTimeout` impostano/azzerano `state.timeout`, `timeoutTeam`, `timeoutVideo`, `timeoutStartedAt`. `startedAt` è aggiunto da `websocket-handler.js` (non dal client), così `gameState.js` resta puro/deterministico; lo stesso handler arma un timer server-side di 30s che invia automaticamente `stopTimeout` (sopravvive a refresh/disconnessione del controller). Sul controller, il pulsante Time Out apre una modal per scegliere squadra + un `.mp4` da `spot/` (cartella in root, non versionata) o nessun video; durante il time out mostra il countdown, ripremerlo chiude in anticipo. Sul display, un overlay fullscreen con countdown copre il tabellone: riproduce il video (riallineandosi al punto giusto su riconnessione a metà time out) o mostra "TIME OUT <squadra>" se manca il video o finisce prima dei 30s, con dissolvenza nell'ultimo secondo. Lista esposta da `/spot/list` (JSON), file serviti da `/spot/<file>.mp4` sia in produzione (`server.js`) che in dev (`vite-plugin-websocket.js`). I video partono con audio; se l'autoplay con suono è bloccato si ripiega su muto — per audio reale il browser del display va avviato in kiosk con autoplay consentito (es. Chrome `--autoplay-policy=no-user-gesture-required`).
**`server.js`** — Express: espone `createApp(distDir)` (solo routing, testabile) e `startServer(port)` (HTTP + WebSocket). Serve `/display``dist/index.html`, `/controller``dist/controller.html` sulla porta 3000; endpoint WebSocket unico `/ws`. Si auto-avvia solo se eseguito come entrypoint.
**`vite-plugin-websocket.js`** — in dev, incorpora il server WebSocket nel dev server Vite con rewrite URL per `/display` e `/controller`.
## Vincoli di design
- **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.
- **Logica solo server-side** — i client sono pura UI; il server è l'unica source of truth.
- **WebSocket role-based** — i client si registrano come `display` o `controller`; solo i controller inviano azioni.
- **Stato immutabile** — `applyAction` non muta mai lo stato, restituisce sempre un nuovo oggetto.
- **Un solo controller** — 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.
- **Un solo controller/display attivi** — nessuna conflict resolution per controller simultanei.
- **Stato persistente** — `src/persist.js` salva in `.segnapunti/state.json` dopo ogni azione e ricarica all'avvio.
## Layout dei test
@@ -61,22 +78,34 @@ In sviluppo, `vite-plugin-websocket.js` incorpora il server WebSocket dentro il
| 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`.
E2E gira in serie (`workers: 1`) per evitare race condition sullo stato WebSocket; richiede `npm run serve` attivo prima di `npm run test:e2e`.
Copertura statement/linee Vitest oltre il 99%. Per testare `startServer()` (`server.js`) o i rami platform-dependent di `getNetworkIPs()` (`server-utils.js`) senza effetti collaterali reali (scrittura su `.segnapunti/state.json`, dipendenza dalla piattaforma), mockare il modulo con `vi.mock`/`vi.doMock` (vedi `tests/integration/startServer.test.js`, `tests/unit/server-utils-platform.test.js`) invece di eseguire il codice reale.
## Deploy
Il progetto si distribuisce tramite Docker. L'immagine è pubblicata sul registry Gitea self-hosted (`santantonio.sytes.net`):
`Dockerfile` è multi-stage con 4 target: `deps` (dipendenze condivise), `dev` (hot reload Vite), `builder` (build frontend), `runtime` (immagine di produzione, target di default).
**Produzione**`docker-compose.yml` usa l'immagine già pubblicata sul registry Gitea self-hosted (`santantonio.sytes.net`), niente build locale:
```bash
docker compose up -d # Avvia con immagine dal registry privato (porta 3000)
```
Il volume `./.segnapunti` persiste lo stato tra i riavvii del container.
**Sviluppo**`docker-compose.dev.yml` builda il target `dev` in locale e monta il codice sorgente per l'hot reload (Vite su porta 5173); `node_modules` resta un volume anonimo del container per non farsi sovrascrivere dal bind-mount (evita anche problemi di binari nativi arch-specific tra host e container):
```bash
docker compose -f docker-compose.dev.yml up --build
```
Per buildare in locale l'immagine di produzione (es. per testarla prima del push sul registry): `docker build -t segnapunti:local .` (usa il target `runtime` di default).
Volume `./.segnapunti` persiste lo stato tra i riavvii del container.
## Istruzioni operative
- 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`).
- Nuova funzionalità: valutare come si inserisce nel flusso `action → applyAction → broadcast`, poi decidere se serve un nuovo action type o estendere uno esistente.
- Regole di gioco → solo `src/gameState.js`. Protocollo WebSocket → solo `src/websocket-handler.js`.
- Nuovo action type o regola di gioco → aggiungere test in `tests/unit/gameState.test.js`.
- Commenti e nomi di dominio in italiano (es. `servHome`, `striscia`, `nomi`); nomi tecnici standard in inglese (`state`, `action`, `handler`).
- Niente emoji in codice, UI (label, testi bottoni, banner) o commenti, salvo richiesta esplicita dell'utente.
+14 -5
View File
@@ -1,13 +1,22 @@
# Stage 1: build frontend
FROM node:20-alpine AS builder
# Stage 1: dipendenze condivise (build + dev)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: dev — hot reload via Vite, sorgente montato da bind-mount
FROM deps AS dev
ENV NODE_ENV=development
EXPOSE 5173
CMD ["npm", "run", "dev"]
# Stage 3: build frontend
FROM deps AS builder
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:20-alpine
# Stage 4: runtime di produzione
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=3000
@@ -15,7 +24,7 @@ COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY src/gameState.js src/websocket-handler.js src/server-utils.js src/persist.js ./src/
COPY src/gameState.js src/websocket-handler.js src/server-utils.js src/persist.js src/spot-utils.js ./src/
COPY --from=builder /app/dist ./dist
EXPOSE 3000
+119 -20
View File
@@ -1,6 +1,6 @@
# Segnapunti
![Version](https://img.shields.io/badge/versione-2.0.0-blue)
![Version](https://img.shields.io/badge/versione-2.1.0-blue)
![Node](https://img.shields.io/badge/node-%3E%3D18-green)
![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)
![License](https://img.shields.io/badge/licenza-privata-lightgrey)
@@ -16,6 +16,7 @@ Segnapunti digitale in tempo reale per partite di pallavolo. Un server centrale
- [Funzionalità](#funzionalità)
- [Deploy con Docker](#deploy-con-docker)
- [Sviluppo](#sviluppo)
- [Sviluppo con Docker](#sviluppo-con-docker)
- [Test](#test)
---
@@ -81,33 +82,88 @@ http://<IP-del-server>:3000/controller
## Funzionalità
### Display
Il sistema è composto da due interfacce complementari: il **display** è in sola lettura e mostra al pubblico lo stato della partita; il **controller** è il pannello operatore da cui parte ogni azione. Tutto ciò che l'operatore tocca sul controller si riflette istantaneamente sul display via WebSocket.
- Nomi squadre con indicatore di servizio
- Punteggio del set corrente (grande, leggibile da lontano)
- Contatore set vinti
- Striscia storica punti del set in corso, scorrevole verso destra
- Modalità formazioni: posizioni dei 6 giocatori in campo
- Indicatore connessione WebSocket (scompare quando connesso, rosso lampeggiante se disconnesso)
### Display (tabellone pubblico)
### Controller
Pagina di sola visualizzazione, pensata per uno schermo grande o un proiettore. Mostra:
- **Punti** — `+1` per casa e ospite, con annullamento dell'ultimo punto
- **Dialog set vinto** — appare automaticamente al raggiungimento dei 25 punti (o 15 nel tie-break); permette di confermare il set o annullare l'ultimo punto
- **Formazioni** — configura i numeri di maglia; la rotazione avviene automaticamente al cambio palla
- **Cambi** — dialog `IN → OUT` con validazione
- **Servizio** — cambio manuale (disponibile solo a 0-0)
- **Visualizzazione** — alterna tra punteggio grande e formazioni in campo
- **Striscia** — mostra/nasconde lo storico punti sul display
- **Reset** — azzera la partita (richiede conferma)
- **Nomi squadre** con **indicatore di servizio** (l'icona della palla compare accanto alla squadra che sta servendo).
- **Punteggio del set corrente** in caratteri molto grandi, leggibili da lontano.
- **Contatore dei set vinti** per ciascuna squadra.
- **Striscia storica dei punti** del set in corso: una sequenza scorrevole che ricostruisce l'andamento punto su punto.
- **Modalità formazioni**: in alternativa al punteggio, mostra le posizioni dei 6 giocatori in campo (numeri di maglia disposti come a referto).
- **Ordine di visualizzazione invertibile**: le due squadre possono essere scambiate di lato per rispecchiare l'orientamento reale del campo.
- **Overlay time out / spot**: durante un time out copre il tabellone con un countdown e il video scelto dall'operatore, o con la scritta *TIME OUT* (vedi sotto).
- **Annuncio vocale del punteggio** (sintesi vocale del browser), comandato dal controller.
- **Indicatore di connessione**: scompare quando tutto è connesso, diventa un punto rosso lampeggiante con scritta *Disconnesso* in caso di problemi.
- **Fullscreen automatico** all'apertura sui dispositivi mobili.
### Controller (pannello operatore)
Il controller adatta automaticamente il proprio layout all'orientamento del dispositivo:
- **Verticale (smartphone)** — layout compatto: anteprima punteggio toccabile in alto e griglia di pulsanti sotto.
- **Orizzontale (tablet)** — layout esteso "da regia": due grandi pannelli squadra affiancati con punteggio, campo e tasto set, più una barra di azioni in basso.
#### Gestione del punteggio
- **Punto** — tocca il punteggio/la card di una squadra per assegnarle `+1`. Il sistema gestisce automaticamente il **cambio palla** e la **rotazione** della squadra che riconquista il servizio.
- **Annulla punto** — rimuove l'ultimo punto assegnato, ripristinando anche eventuale rotazione e servizio.
- **Tasti SET** — incrementano manualmente il contatore dei set vinti di una squadra (utile per correzioni); il contatore cicla da 0.
- **Dialog automatico SET VINTO / PARTITA FINITA** — compare da solo al raggiungimento delle condizioni di vittoria (25 punti, o 15 nel set decisivo, con 2 di scarto). Da qui puoi:
- **VAI AL SET SUCCESSIVO** — chiude il set, lo registra come vinto e prepara il set successivo aprendo la configurazione delle formazioni;
- **INDIETRO** — annulla l'ultimo punto se il dialog è comparso per errore;
- a partita conclusa, **REFERTO** per generare il referto e **CHIUDI** per tornare al tabellone.
#### Configurazione partita
Dal pulsante **Config** si apre una finestra che permette di impostare:
- **Nomi delle squadre** (Home e Guest);
- **Modalità partita**: `2/3` (al meglio dei 3 set), `3/5` (al meglio dei 5 set) o `Amichevole` (senza vittoria automatica della partita);
- **Formazioni iniziali**: i numeri di maglia dei 6 giocatori in campo per ciascuna squadra, disposti per zona.
#### Cambi giocatore
Il pulsante **Cambi** apre, dopo aver scelto la squadra, un dialog con righe `IN → OUT`. Le sostituzioni vengono **validate** prima di essere applicate: i numeri devono essere cifre, il giocatore entrante non può essere già in campo, quello uscente deve essere effettivamente in formazione, e nessuno può sostituire sé stesso. Eventuali errori sono segnalati in chiaro.
#### Servizio e visualizzazione
- **Cambio Palla** — assegna manualmente il servizio; disponibile **solo a 0-0** (a inizio set).
- **Inverti** — scambia il lato delle due squadre sul display.
- **Formazioni / Punteggio** — alterna sul display la vista formazioni in campo e la vista punteggio.
- **Striscia ON/OFF** — mostra o nasconde lo storico punti del set sul display.
- **Voce** — fa annunciare al display il punteggio corrente con la sintesi vocale (es. *"15 a 12"*, *"15 pari"*, *"zero a zero"*).
#### Time Out e spot pubblicitari
Il pulsante **Time Out** apre una finestra in cui l'operatore sceglie **quale squadra** ha chiamato il time out (obbligatorio) e **quale video** proiettare sul display, scegliendo tra i file della cartella `spot/` oppure *nessun video* (overlay con la scritta *TIME OUT*). Alla conferma:
- il **display** copre il tabellone con un overlay a tutto schermo: countdown in sovraimpressione e video scelto; se non c'è video, o se il video finisce prima del tempo, compare la scritta *TIME OUT* con il nome della squadra. Nell'ultimo secondo il video sfuma in dissolvenza.
- il **controller** resta pienamente operativo: compare un banner *TIME OUT <squadra>* con il conto alla rovescia e il pulsante Time Out mostra i secondi rimanenti. Ripremendolo si chiude il time out in anticipo.
- il **server** chiude automaticamente il time out dopo **30 secondi**, anche se il controller si disconnette o va in standby.
I video da proiettare vanno messi in una cartella **`spot/`** nella root del progetto (non versionata): vengono elencati nella finestra di scelta in ordine alfabetico (solo file `.mp4`). Se un display si riconnette a metà time out, il video riparte dal punto corretto.
> **Audio:** i video partono con audio, ma il display è in genere uno schermo senza tastiera/mouse e i browser bloccano l'autoplay con suono senza un'interazione dell'utente. In assenza di configurazione il video parte automaticamente **muto** (il video si vede comunque). Per avere l'audio, avvia il browser del display in modalità kiosk consentendo l'autoplay con suono (es. Chrome con `--autoplay-policy=no-user-gesture-required`, oppure impostando un'eccezione del sito per audio/video).
#### Reset e referto
- **Reset** — azzera la partita (punteggi, set e formazioni) previa conferma, e riapre subito la configurazione per impostare la partita successiva.
- **Referto** — a fine partita, dal dialog *PARTITA FINITA*, genera un referto di gara stampabile/PDF con i punteggi per set, le formazioni di partenza e l'andamento punto-punto. *Funzione prototipale.*
### Regole pallavolo integrate
Le condizioni di vittoria del set sono applicate automaticamente dal server (è quello che fa comparire il dialog *SET VINTO*):
| Set | Condizione di vittoria |
|---|---|
| Set 14 (modalità 3/5) o 12 (modalità 2/3) | Primo a **25** con almeno 2 punti di scarto |
| Set decisivo (tie-break) | Primo a **15** con almeno 2 punti di scarto |
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*.
---
@@ -119,7 +175,9 @@ http://<IP-del-server>:3000/controller
docker compose up -d
```
Lo stato viene salvato nel volume Docker `segnapunti-state` e sopravvive ai riavvii del container.
Lo stato viene salvato nella cartella `./.segnapunti` montata come volume nel container e sopravvive ai riavvii.
> **Video spot in Docker:** la cartella `spot/` non è inclusa nell'immagine. Per usare i video del time out aggiungi un volume in `docker-compose.yml`: `./spot:/app/spot`.
### Aggiornamento a nuova versione
@@ -129,15 +187,24 @@ docker compose pull && docker compose up -d
### Build e pubblicazione immagine
Il `Dockerfile` è multi-stage (`deps``dev` / `builder``runtime`); senza specificare `--target`, il build produce l'ultimo stage (`runtime`), l'immagine di produzione:
```bash
docker build \
-t santantonio.sytes.net/attilio/segnapunti:2.0.0 \
-t santantonio.sytes.net/attilio/segnapunti:2.1.0 \
-t santantonio.sytes.net/attilio/segnapunti:latest .
docker push santantonio.sytes.net/attilio/segnapunti:2.0.0
docker push santantonio.sytes.net/attilio/segnapunti:2.1.0
docker push santantonio.sytes.net/attilio/segnapunti:latest
```
Per testare in locale l'immagine di produzione senza pubblicarla:
```bash
docker build -t segnapunti:local .
docker run --rm -p 3000:3000 -v ./.segnapunti:/app/.segnapunti segnapunti:local
```
---
## Sviluppo
@@ -173,6 +240,29 @@ Lo stato viene salvato in `.segnapunti/state.json` anche in modalità dev.
---
## Sviluppo con Docker
In alternativa a Node.js installato in locale, `docker-compose.dev.yml` builda il target `dev` del `Dockerfile` e monta il codice sorgente nel container, con hot reload identico a `npm run dev`:
```bash
docker compose -f docker-compose.dev.yml up --build
```
| URL | Interfaccia |
|---|---|
| `http://localhost:5173/display` | Display |
| `http://localhost:5173/controller` | Controller |
Le modifiche ai file sorgente sull'host si riflettono subito nel container (bind-mount); `node_modules` resta invece un volume anonimo del container, per non farsi sovrascrivere dal bind-mount e per evitare incompatibilità tra i binari nativi installati sull'host e quelli richiesti dal container (es. host ARM / container `node:20-alpine` su x86).
Per fermare e rimuovere il container:
```bash
docker compose -f docker-compose.dev.yml down
```
---
## Test
| Comando | Descrizione |
@@ -181,8 +271,17 @@ Lo stato viene salvato in `.segnapunti/state.json` anche in modalità dev.
| `npm run test:component` | Componenti Vue (Happy-DOM) |
| `npm run test:stress` | Load test WebSocket (50+ client) |
| `npm run test:all` | Tutti i test tranne E2E |
| `npm run test:ui` | Vitest con interfaccia grafica |
| `npm run test:e2e` | Playwright — Chromium, Firefox, Mobile Chrome |
Le suite Vitest coprono la logica di gioco (`gameState`, inclusa `formInizio`),
il protocollo e il routing del server (incluso l'avvio effettivo di
`startServer`), la persistenza, il rilevamento di rete/piattaforma
(`server-utils`, inclusi i rami WSL/PowerShell), il client WebSocket
(`wsMixin`, connessione/riconnessione/cleanup), i componenti Vue e la
generazione del referto (inclusi i suoi side-effect di stampa). La copertura
statement complessiva è oltre il 99%.
> I test E2E richiedono il server in esecuzione (`npm run serve`) e i browser Playwright installati:
> ```bash
> npx playwright install chromium firefox
-1
View File
@@ -1 +0,0 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
-92
View File
@@ -1,92 +0,0 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5357ef54'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"revision": null,
"url": "index.html"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
services:
segnapunti:
build:
context: .
target: dev
container_name: segnapunti-dev
ports:
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
+1 -1
View File
@@ -1,6 +1,6 @@
services:
segnapunti:
image: santantonio.sytes.net/attilio/segnapunti:2.0.0
image: santantonio.sytes.net/attilio/segnapunti:2.1.0
container_name: segnapunti
ports:
- "3000:3000"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "segnapuntianto",
"version": "0.0.0",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "segnapuntianto",
"version": "0.0.0",
"version": "2.1.0",
"dependencies": {
"express": "^5.2.1",
"vue": "^3.2.47",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "segnapuntianto",
"private": true,
"version": "2.0.0",
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
+9 -1
View File
@@ -6,19 +6,27 @@ 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) {
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'))
})
+174 -1
View File
@@ -6,6 +6,11 @@
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
</div>
<!-- Banner modalità time out attiva -->
<div class="timeout-banner" v-if="state.timeout">
TIME OUT {{ state.sp.nomi[state.timeoutTeam] }} {{ timeoutCountdown }}
</div>
<!-- MODALITÀ MOBILE -->
<template v-if="!isEstesa">
<!-- Anteprima punteggio -->
@@ -64,6 +69,10 @@
<button class="btn btn-ctrl" @click="openCambiTeam()">
Cambi
</button>
<button class="btn btn-ctrl btn-timeout" :class="{ active: state.timeout }"
@click="state.timeout ? terminaTimeout() : openTimeout()">
{{ state.timeout ? timeoutCountdown : 'Time Out' }}
</button>
<button class="btn btn-danger" @click="confirmReset = true">
Reset
</button>
@@ -135,13 +144,15 @@
<!-- Barra azioni secondarie -->
<div class="e-actions">
<button class="btn e-act e-act--undo" @click="sendAction({ type: 'decPunt' })"> Annulla</button>
<button class="btn e-act e-act--undo" @click="sendAction({ type: 'decPunt' })">Annulla</button>
<button class="btn e-act" :disabled="!isPunteggioZeroZero" @click="sendAction({ type: 'cambiaPalla' })">Cambio Palla</button>
<button class="btn e-act" @click="openCambiTeam()">Cambi</button>
<button class="btn e-act" @click="sendAction({ type: 'toggleOrder' })">Inverti</button>
<button class="btn e-act" @click="sendAction({ type: 'toggleStriscia' })">Striscia {{ state.visuStriscia ? 'ON' : 'OFF' }}</button>
<button class="btn e-act" @click="speak()">Voce</button>
<button class="btn e-act" @click="openConfig()">Config</button>
<button class="btn e-act btn-timeout" :class="{ active: state.timeout }"
@click="state.timeout ? terminaTimeout() : openTimeout()">{{ state.timeout ? timeoutCountdown : 'Time Out' }}</button>
<button class="btn e-act e-act--danger" @click="confirmReset = true">Reset</button>
</div>
</div>
@@ -270,6 +281,43 @@
</div>
</div>
</div>
<!-- Finestra avvio time out: squadra + scelta video -->
<div class="overlay" v-if="showTimeoutModal" @click.self="showTimeoutModal = false">
<div class="dialog dialog-timeout">
<div class="dialog-title">Time Out</div>
<div class="form-group">
<label>Squadra che lo ha richiesto</label>
<div class="dialog-buttons">
<button class="btn btn-set home-bg" :class="{ 'team-sel-active': timeoutTeamSel === 'home' }"
@click="timeoutTeamSel = 'home'">{{ state.sp.nomi.home }}</button>
<button class="btn btn-set guest-bg" :class="{ 'team-sel-active': timeoutTeamSel === 'guest' }"
@click="timeoutTeamSel = 'guest'">{{ state.sp.nomi.guest }}</button>
</div>
</div>
<div class="form-group">
<label>Video (30s nominali)</label>
<div class="timeout-video-list">
<label class="timeout-video-option" v-for="file in spotList" :key="file">
<input type="radio" name="timeoutVideo" :value="file" v-model="timeoutVideoSel" />
{{ file }}
<span class="video-duration">{{ formatDurata(spotDurations[file]) }}</span>
</label>
<label class="timeout-video-option">
<input type="radio" name="timeoutVideo" :value="null" v-model="timeoutVideoSel" />
Nessun video (overlay "TIME OUT")
</label>
</div>
</div>
<div class="dialog-buttons">
<button class="btn btn-cancel" @click="showTimeoutModal = false">Annulla</button>
<button class="btn btn-confirm" :disabled="!timeoutTeamSel" @click="avviaTimeout()">Avvia</button>
</div>
</div>
</div>
</section>
</template>
@@ -297,6 +345,13 @@ export default {
formGuest: ["1", "2", "3", "4", "5", "6"],
},
isLandscape: window.innerWidth > window.innerHeight,
showTimeoutModal: false,
timeoutTeamSel: null,
timeoutVideoSel: null,
spotList: [],
spotDurations: {},
nowTick: Date.now(),
timeoutTicker: null,
}
},
computed: {
@@ -331,6 +386,17 @@ export default {
}
return hasComplete
},
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}`
},
},
watch: {
squadraVincente(val) {
@@ -339,6 +405,12 @@ export default {
this.showSetVinto = true
}
},
'state.timeout': {
immediate: true,
handler(attivo) {
attivo ? this.avviaTimeoutTicker() : this.fermaTimeoutTicker()
},
},
},
mounted() {
this._resizeHandler = () => { this.isLandscape = window.innerWidth > window.innerHeight }
@@ -348,6 +420,7 @@ export default {
beforeUnmount() {
window.removeEventListener('resize', this._resizeHandler)
window.removeEventListener('orientationchange', this._resizeHandler)
this.fermaTimeoutTicker()
},
methods: {
generaReferto,
@@ -451,6 +524,47 @@ export default {
: this.servHome ? `${home} a ${guest}` : `${guest} a ${home}`
this.sendWs({ type: 'speak', text })
},
avviaTimeoutTicker() {
this.fermaTimeoutTicker()
this.nowTick = Date.now()
this.timeoutTicker = setInterval(() => { this.nowTick = Date.now() }, 250)
},
fermaTimeoutTicker() {
if (this.timeoutTicker) { clearInterval(this.timeoutTicker); this.timeoutTicker = null }
},
async openTimeout() {
this.timeoutTeamSel = null
this.timeoutVideoSel = null
this.spotList = []
this.spotDurations = {}
this.showTimeoutModal = true
try {
const res = await fetch('/spot/list')
this.spotList = await res.json()
} catch {
this.spotList = []
}
this.spotList.forEach(file => this.probeDurata(file))
},
probeDurata(file) {
const video = document.createElement('video')
video.preload = 'metadata'
video.onloadedmetadata = () => {
this.spotDurations = { ...this.spotDurations, [file]: video.duration }
}
video.src = '/spot/' + encodeURIComponent(file)
},
formatDurata(sec) {
return typeof sec === 'number' && isFinite(sec) ? `${Math.round(sec)}s` : '…'
},
avviaTimeout() {
if (!this.timeoutTeamSel) return
this.sendAction({ type: 'startTimeout', team: this.timeoutTeamSel, video: this.timeoutVideoSel })
this.showTimeoutModal = false
},
terminaTimeout() {
this.sendAction({ type: 'stopTimeout' })
},
},
}
</script>
@@ -489,6 +603,32 @@ export default {
.conn-bar.connected {
background: #2e7d32;
}
/* Banner modalità time out */
.timeout-banner {
background: #e65100;
color: #fff;
text-align: center;
font-weight: 800;
letter-spacing: 0.04em;
padding: 10px;
border-radius: 10px;
margin-bottom: 8px;
animation: timeout-pulse 1.2s ease-in-out infinite;
}
/* Pulsante time out evidenziato quando attivo */
.btn-timeout.active {
background: #e65100;
color: #fff;
box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.6);
animation: timeout-pulse 1.2s ease-in-out infinite;
}
@keyframes timeout-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.dot {
width: 8px;
height: 8px;
@@ -1037,4 +1177,37 @@ export default {
padding: 8px 0;
font-weight: 600;
}
/* Modal avvio time out */
.dialog-timeout {
max-height: 85vh;
overflow-y: auto;
}
.btn-set.team-sel-active {
box-shadow: 0 0 0 3px rgba(255,255,255,0.6) inset;
}
.timeout-video-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 40vh;
overflow-y: auto;
}
.timeout-video-option {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
cursor: pointer;
}
.video-duration {
margin-left: auto;
color: #aaa;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
</style>
+150 -4
View File
@@ -112,6 +112,16 @@
{{ 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>
@@ -121,12 +131,40 @@ 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: [] }
@@ -149,8 +187,60 @@ export default {
})
}
},
'state.timeout': {
handler(attivo) {
attivo ? this.avviaTimeout() : this.fermaTimeout()
},
},
},
methods: {
avviaTimeoutTicker() {
this.fermaTimeoutTicker()
this.nowTick = Date.now()
this.timeoutTicker = setInterval(() => { this.nowTick = Date.now() }, 200)
},
fermaTimeoutTicker() {
if (this.timeoutTicker) { clearInterval(this.timeoutTicker); this.timeoutTicker = null }
},
async avviaTimeout() {
this.mostraPlaceholder = !this.state.timeoutVideo
this.spotVideoDuration = null
this.avviaTimeoutTicker()
if (!this.state.timeoutVideo) return
await this.$nextTick()
this.caricaSpotCorrente()
},
fermaTimeout() {
this.fermaTimeoutTicker()
const video = this.$refs.spotVideo
if (video) { video.pause(); video.removeAttribute('src'); video.load() }
this.mostraPlaceholder = false
this.spotVideoDuration = null
},
caricaSpotCorrente() {
const video = this.$refs.spotVideo
if (!video || !this.state.timeoutVideo) return
video.src = '/spot/' + encodeURIComponent(this.state.timeoutVideo)
video.onloadedmetadata = () => {
this.spotVideoDuration = video.duration
// Riconnessione a metà time out: riallinea il video al punto corretto.
const trascorsi = this.state.timeoutStartedAt ? (Date.now() - this.state.timeoutStartedAt) / 1000 : 0
if (trascorsi > 0.5 && trascorsi < video.duration) video.currentTime = trascorsi
}
// Video più corto dei 30s: a fine riproduzione mostra l'immagine "TIME OUT" fino allo stop del server.
video.onended = () => { this.mostraPlaceholder = true }
this.playSpot()
},
playSpot() {
const video = this.$refs.spotVideo
if (!video) return
// Prova con audio; se il browser blocca l'autoplay con suono, ripiega su muto.
video.muted = false
const p = video.play()
if (p && typeof p.catch === 'function') {
p.catch(() => { video.muted = true; video.play().catch(() => {}) })
}
},
onWsMessage(msg) {
if (msg.type === 'speak') this.speakOnDisplay(msg.text)
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
@@ -177,7 +267,8 @@ export default {
.display-page {
width: 100%;
height: 100vh;
background: #000;
background: #111;
font-family: 'Inter', system-ui, sans-serif;
overflow: hidden;
}
@@ -200,7 +291,7 @@ export default {
}
.connection-status.disconnected {
background: rgba(255, 50, 50, 0.8);
background: #c62828;
color: white;
opacity: 1;
}
@@ -213,11 +304,11 @@ export default {
}
.connected .dot {
background: #4caf50;
background: #2e7d32;
}
.disconnected .dot {
background: #f44336;
background: #ff6b6b;
animation: blink 1s infinite;
}
@@ -237,6 +328,8 @@ export default {
.punt {
font-size: 60vh;
font-weight: 900;
font-variant-numeric: tabular-nums;
flex: 1;
display: flex;
justify-content: center;
@@ -247,4 +340,57 @@ export default {
overflow: hidden;
box-sizing: border-box;
}
.timeout-overlay {
position: fixed;
inset: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
.timeout-video {
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.8s ease-out;
}
.timeout-video--fading {
opacity: 0;
}
.timeout-countdown {
position: absolute;
top: 24px;
left: 50%;
transform: translateX(-50%);
color: #fff;
background: rgba(0, 0, 0, 0.5);
font-size: 4vh;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
padding: 6px 18px;
border-radius: 12px;
z-index: 501;
}
.timeout-placeholder {
color: #ff9800;
font-size: 14vh;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
text-align: center;
line-height: 1.3;
animation: timeout-pulse 1.2s ease-in-out infinite;
}
@keyframes timeout-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
</style>
+38
View File
@@ -21,6 +21,10 @@ export function createInitialState() {
order: true,
visuForm: false,
visuStriscia: true,
timeout: false,
timeoutTeam: null,
timeoutVideo: null,
timeoutStartedAt: null,
modalitaPartita: "3/5",
sp: {
striscia: [{ serv: 'h', ris: '', vinc: null }],
@@ -164,6 +168,31 @@ export function applyAction(state, action) {
break
}
case "startTimeout": {
const team = action.team
if (team !== 'home' && team !== 'guest') break
if (!s.timeout) {
const setCorrente = s.sp.striscia.at(-1)
;(setCorrente.timeouts ??= []).push({
team,
punteggio: punteggio(s.sp.striscia),
})
}
s.timeout = true
s.timeoutTeam = team
s.timeoutVideo = action.video ?? null
s.timeoutStartedAt = action.startedAt ?? null
break
}
case "stopTimeout": {
s.timeout = false
s.timeoutTeam = null
s.timeoutVideo = null
s.timeoutStartedAt = null
break
}
case "setNomi": {
if (action.home !== undefined) s.sp.nomi.home = action.home
if (action.guest !== undefined) s.sp.nomi.guest = action.guest
@@ -187,6 +216,7 @@ export function applyAction(state, action) {
const cambi = action.cambi || []
const form = s.sp.form[team].map((val) => String(val).trim())
const formAggiornata = [...form]
const cambiApplicati = []
let valid = true
for (const cambio of cambi) {
@@ -201,11 +231,19 @@ export function applyAction(state, action) {
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
if (idx !== -1) {
formAggiornata.splice(idx, 1, cin)
cambiApplicati.push({ in: cin, out: cout })
}
}
if (valid) {
s.sp.form[team] = formAggiornata
if (cambiApplicati.length > 0) {
const setCorrente = s.sp.striscia.at(-1)
const puntAttuale = punteggio(s.sp.striscia)
;(setCorrente.cambi ??= []).push(
...cambiApplicati.map(c => ({ team, ...c, punteggio: { ...puntAttuale } }))
)
}
}
break
}
+119 -37
View File
@@ -44,6 +44,31 @@ export function buildRefertoHtml(state, now = new Date()) {
</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 = []
@@ -62,17 +87,31 @@ export function buildRefertoHtml(state, now = new Date()) {
return `
<div class="set-section">
<div class="set-header">
Set ${i + 1} &nbsp;—&nbsp; ${nomi.home} <strong>${h}</strong> · <strong>${g}</strong> ${nomi.guest}${etichettaVinc}
<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 class="form-inizio">
<div class="form-inizio-label">Formazione di partenza</div>
${formazioneHtml(s.formInizio)}
</div>
<div class="punti-grid">${puntiHtml || '<em style="color:#999;font-size:11px">Nessun punto registrato</em>'}</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>
@@ -80,50 +119,93 @@ export function buildRefertoHtml(state, now = new Date()) {
<title>Referto — ${nomi.home} vs ${nomi.guest}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; background: #fff; padding: 28px; font-size: 14px; }
@media print { body { padding: 12px; } }
: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; } }
.header { text-align: center; border-bottom: 2px solid #111; padding-bottom: 12px; margin-bottom: 20px; }
.titolo { font-size: 18px; font-weight: bold; letter-spacing: 3px; text-transform: uppercase; }
.meta { font-size: 12px; color: #666; margin-top: 5px; }
.foglio { border: 3px solid #111; padding: 14px; }
.squadre-row { display: flex; justify-content: space-around; align-items: center; margin: 18px 0 6px; }
.nome-sq { font-size: 22px; font-weight: bold; text-align: center; max-width: 40%; }
.vs { font-size: 16px; color: #999; }
.risultato { text-align: center; font-size: 36px; font-weight: bold; letter-spacing: 6px; margin-bottom: 4px; }
.modalita-label { text-align: center; font-size: 12px; color: #888; margin-bottom: 22px; }
.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; }
.set-section { margin-bottom: 12px; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
.set-header { background: #f5f5f5; padding: 7px 12px; font-size: 13px; border-bottom: 1px solid #ddd; }
.punti-grid { display: flex; flex-wrap: wrap; gap: 3px; padding: 8px 10px; }
.punto { display: inline-block; padding: 2px 5px; border-radius: 3px; font-size: 11px; font-family: 'Courier New', monospace; white-space: nowrap; }
.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; }
.form-inizio { padding: 8px 10px; border-bottom: 1px solid #eee; background: #fafafa; }
.form-inizio-label { font-size: 11px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #888; margin-bottom: 7px; }
.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: 13px; margin-bottom: 6px; }
.form-team-name { font-weight: bold; font-size: 12px; margin-bottom: 5px; }
.giocatori { display: flex; gap: 5px; flex-wrap: wrap; }
.giocatore { background: #f0f0f0; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; border: 1px solid #ccc; }
.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="header">
<div class="titolo">Referto di Gara</div>
<div class="meta">${dataOra} &nbsp;·&nbsp; Modalità: ${modalitaPartita}</div>
</div>
<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>
<div class="squadre-row">
<div class="nome-sq">${nomi.home}</div>
<div class="vs">vs</div>
<div class="nome-sq">${nomi.guest}</div>
</div>
<div class="risultato">${setVinti.home} ${setVinti.guest}</div>
<div class="modalita-label">set vinti</div>
${setsHtml}
${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>`
+16
View File
@@ -0,0 +1,16 @@
import { readdirSync } from 'fs'
/**
* Elenca i video .mp4 presenti nella cartella spot.
* Ritorna i filename ordinati alfabeticamente (sequenza prevedibile),
* oppure un array vuoto se la cartella non esiste o non è leggibile.
*/
export function listSpotVideos(spotDir) {
try {
return readdirSync(spotDir)
.filter(name => name.toLowerCase().endsWith('.mp4'))
.sort((a, b) => a.localeCompare(b))
} catch {
return []
}
}
+78 -10
View File
@@ -46,11 +46,31 @@ button:focus-visible {
width: 100%;
display: table;
color: #fff;
position: relative;
}
/* Linea centrale che separa i due campi, come un tabellone reale */
.campo::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
background: rgba(255, 255, 255, 0.12);
pointer-events: none;
z-index: 1;
}
.hea {
float: left;
width: 50%;
font-size: xx-large;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 18px 4px 12px;
box-sizing: border-box;
border-bottom: 3px solid currentColor;
opacity: 0.96;
}
.hea span {
/* Bordo di debug: border: 1px solid #f3fb00; */
@@ -70,6 +90,27 @@ button:focus-visible {
align-items: center;
justify-content: center;
vertical-align: middle;
border-radius: 50%;
background: rgba(255, 255, 255, 0.07);
}
.mr3, .ml3 {
display: inline-flex;
align-items: center;
font-size: 3vh;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 4px 16px;
margin-top: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.mr3 {
margin-right: 14px;
}
.ml3 {
margin-left: 14px;
}
.tal {
text-align: left;
@@ -110,10 +151,11 @@ button:focus-visible {
max-width: 50vw;
overflow: hidden;
box-sizing: border-box;
text-shadow: 0 0 40px currentColor;
}
.form {
font-size: 5vh;
border-top: #fff dashed 25px;
border-top: 2px solid rgba(255, 255, 255, 0.18);
padding-top: 50px;
}
.formtit {
@@ -125,14 +167,23 @@ button:focus-visible {
font-size: 20vh;
float: left;
width: 32%;
box-sizing: border-box;
margin: 3px 0;
color: #ddd;
font-weight: 700;
background: rgba(255, 255, 255, 0.07);
border-radius: 10px;
}
.text-bold {
font-weight: 800;
}
.home {
background-color: black;
color: yellow;
background-color: transparent;
color: #f5c518;
}
.guest {
background-color: blue;
color: white
background-color: transparent;
color: #2196f3;
}
.striscia {
position: fixed;
@@ -141,17 +192,28 @@ button:focus-visible {
right: 10px;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 2px;
row-gap: 6px;
align-items: center;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
backdrop-filter: blur(6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 2;
}
.striscia-nome {
white-space: nowrap;
padding-right: 6px;
padding-right: 10px;
font-size: 2.2vh;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.striscia-items {
display: flex;
flex-wrap: nowrap;
overflow: hidden;
gap: 2px;
}
.striscia .item {
width: 25px;
@@ -159,11 +221,17 @@ button:focus-visible {
text-align: center;
font-weight: bold;
flex-shrink: 0;
border-radius: 5px;
border-radius: 6px;
}
.striscia .item:not(.item-vuoto) {
background-color: rgb(206, 247, 3);
color: blue;
background-color: #f5c518;
color: #111;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.striscia-items.guest-striscia .item:not(.item-vuoto) {
background-color: #2196f3;
color: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.campo-config {
+34 -1
View File
@@ -5,6 +5,8 @@ import { createInitialState, applyAction } from './gameState.js'
* @param {WebSocketServer} wss - Istanza del server WebSocket.
* @returns {Object} Oggetto con metodi di gestione dello stato.
*/
const DURATA_TIMEOUT_MS = 30000
export function setupWebSocketHandler(wss, options = {}) {
// Stato globale della partita.
let gameState = options.initialState ?? createInitialState()
@@ -12,6 +14,17 @@ export function setupWebSocketHandler(wss, options = {}) {
// Mappa dei ruoli associati ai client connessi.
const clients = new Map() // ws -> { role: 'display' | 'controller' }
// Timer che chiude automaticamente il time out dopo 30s, indipendentemente
// dal controller (sopravvive a refresh/disconnessione/standby del tablet).
let timeoutTimer = null
function annullaTimerTimeout() {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
}
/**
* Gestisce i messaggi in arrivo dal client
*/
@@ -87,10 +100,16 @@ export function setupWebSocketHandler(wss, options = {}) {
return
}
// Il momento di inizio del time out lo decide il server (non il client),
// cosi' gameState.js resta puro/deterministico e testabile.
const action = msg.action.type === 'startTimeout'
? { ...msg.action, startedAt: Date.now() }
: msg.action
// Applica l'azione allo stato della partita.
const previousState = gameState
try {
gameState = applyAction(gameState, msg.action)
gameState = applyAction(gameState, action)
} catch (err) {
console.error('Error applying action:', err)
sendError(ws, 'Failed to apply action')
@@ -98,6 +117,20 @@ export function setupWebSocketHandler(wss, options = {}) {
return
}
// Qualsiasi azione che tocca il time out invalida il timer di auto-chiusura
// pendente; se il time out e' (ri)partito, ne arma uno nuovo.
if (action.type === 'startTimeout' || action.type === 'stopTimeout') {
annullaTimerTimeout()
}
if (action.type === 'startTimeout' && gameState.timeout) {
timeoutTimer = setTimeout(() => {
timeoutTimer = null
gameState = applyAction(gameState, { type: 'stopTimeout' })
broadcastState()
options.onStateChange?.(gameState)
}, DURATA_TIMEOUT_MS)
}
// Propaga il nuovo stato a tutti i client connessi.
broadcastState()
options.onStateChange?.(gameState)
-108
View File
@@ -1,108 +0,0 @@
# TODO — completamento suite di test
Checkpoint: branch `test-suite-repair`, commit `bf9a6f8`.
## Contesto
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**, **tutti gli e2e rotti**.
Nel commit `bf9a6f8`:
- Vitest riportato a **212/212 verde** (verificato con `npx vitest run`).
- e2e migrati **parzialmente** (porte + viewport + reset→config), **NON verificati verdi**.
---
## ✅ Fatto (verificato)
- `tests/unit/gameState.test.js` — riscritto con helper che derivano
punteggio/set/servizio dalla striscia; aggiunto blocco `formInizio`.
- `src/server-utils.js``getNetworkIPs(nets)` con interfacce iniettabili +
`printServerInfo(port, ips)` con IP iniettabili (deterministico anche su WSL);
filtro LAN unificato (esclude `127.` / `169.254.` / `172.`). Test riscritto.
- `tests/integration/websocket.test.js` + `tests/stress/websocket-load.test.js`
— punteggio letto via `punteggio(striscia)`.
- `tests/component/ControllerPage.test.js` + `DisplayPage.test.js` — forzato
layout mobile (viewport portrait), punteggi via striscia; aggiunto test REFERTO.
- Nuovi unit/integration: `referto.test.js`, `persist.test.js` (mock `fs`),
`wsMixin.test.js`, `integration/server.test.js` (routing).
- `src/referto.js` — estratta `buildRefertoHtml(state, now)` pura; `generaReferto`
resta wrapper con `window.open`/`print`.
- `server.js` — estratti `createApp()` / `startServer()`; avvio solo se entrypoint.
---
## ⚠️ Da finire / verificare — e2e Playwright
Gli e2e NON sono stati eseguiti fino a verde (run lenti). Punto di ripartenza.
### Fix già applicati nei 6 spec
1. `:3001``:3000/controller`.
2. `setViewportSize({ width: 390, height: 844 })` prima di ogni `goto` controller
(su desktop renderizza la dashboard landscape `.e-dash`, ma i test cercano il
markup mobile `.team-score` / `.team-pts` / `.btn-set`).
3. Il reset ora chiude il dialog di configurazione che `doReset()` apre in automatico.
4. `game-simulation`: a 25 gestito il modal automatico "SET VINTO" con
"VAI AL SET SUCCESSIVO".
### Da verificare / probabili fix residui
- [ ] **`full-match.spec.cjs`** — RISCHIO ALTO. I test arrivano a 25/15 → scatta
il modal automatico (`squadraVincente`). Le asserzioni che cliccano
`.btn-set` o `.team-score` DOPO i 25 punti vengono bloccate dall'overlay del
modal. Da rivedere il flusso (usare i bottoni del modal).
- [ ] **`game-simulation.spec.cjs`** — verificare il nuovo finale col modal.
- [ ] **`game-operations.spec.cjs`** — verificare flusso cambi/toggle/config dopo
`resetGame` (config ora chiuso).
- [ ] **`basic-flow.spec.cjs`** — probabile OK, confermare.
- [ ] **`accessibility.spec.cjs`** — rieseguire axe sul markup attuale.
- [ ] **`visual-regression.spec.cjs`** — FALLIRÀ: snapshot baseline della vecchia
UI. Rigenerare con `npm run test:e2e -- --update-snapshots` DOPO aver
sistemato il resto.
### Note importanti
- Stato e2e **condiviso e persistente** (`.segnapunti/state.json`): i test
dipendono dall'ordine e dal reset. Partire da stato pulito.
- I 3 progetti Playwright (chromium, firefox, Mobile Chrome) girano in serie
(`workers: 1`).
---
## ⬜ Non ancora iniziato
- [ ] **`tests/e2e/referto.spec.cjs`** (nuovo) — portare una partita 2/3 a fine
match, intercettare il popup con `page.waitForEvent('popup')` dopo il click
su REFERTO, verificare nomi squadre + punteggi. Stubbare `window.print`
(via `addInitScript`) perché può bloccare.
- [ ] **Documentazione**:
- `README.md` — sezione Controller: aggiungere il referto (PARTITA FINITA →
referto stampabile/PDF, prototipo). Sezione Test: aggiungere `test:ui`.
- `CLAUDE.md` — aggiungere `src/referto.js` (`buildRefertoHtml` + `generaReferto`),
menzionare `wsMixin.js` / `persist.js` / `server-utils.js`; nota su `formInizio`.
- `tests/README.md` — correggere i conteggi obsoleti ("159 passed (159)",
"6 files"); aggiungere referto / persist / wsMixin / routing server / spec
referto.
---
## Comandi utili
```bash
# Vitest (deve restare verde: 212/212)
npx vitest run
# e2e — partire da stato pulito + server attivo
rm -f .segnapunti/state.json
npm run serve # in un terminale a parte
# e2e mirati (chromium, dai più rischiosi)
npx playwright test --config=playwright.config.cjs --project=chromium tests/e2e/full-match.spec.cjs
npx playwright test --config=playwright.config.cjs --project=chromium tests/e2e/game-simulation.spec.cjs
# rigenerare gli snapshot visual DOPO aver sistemato la UI dei test
npm run test:e2e -- --update-snapshots
# suite e2e completa (3 browser, lenta)
npm run test:e2e
```
+47 -5
View File
@@ -90,10 +90,22 @@ Esempi reali nel progetto:
- cambio palla
- vittoria set con regola dei 2 punti di scarto
- reset stato
- `formInizio` (snapshot formazione di partenza del set)
- time out (`startTimeout`/`stopTimeout`: squadra, video, timestamp)
- generazione del referto (`buildRefertoHtml`)
- persistenza stato su disco (`persist.js`, con `fs` mockato)
- utility di rete (`server-utils.js`), incluso il rilevamento piattaforma
(WSL/PowerShell vs. `os.networkInterfaces()`, con `fs`/`child_process`/`os`
mockati per non dipendere dalla macchina che esegue i test)
- elenco video spot (`spot-utils.js`, su cartella temporanea reale)
Quando falliscono:
- quasi sempre c'e un problema nella logica core
> Nota: punteggio, set e servizio NON sono campi dello stato: si ricavano dalla
> `striscia` con `punteggio` / `setVinti` / `servizio`. Nei test si impostano gli
> scenari costruendo la `striscia`, non scrivendo `sp.punt`/`sp.set`.
### 5.2 Integration (`tests/integration`)
Cosa sono:
@@ -107,6 +119,10 @@ Esempi reali nel progetto:
- broadcast stato ai client
- validazione input malformati
- autorizzazioni (solo controller puo inviare certe azioni)
- timer server-side del time out (auto-`stopTimeout` dopo 30s, `startedAt` impostato dal server)
- routing del server Express (`createApp`: rotte display/controller/asset, `/spot/list` e file statici `/spot/`)
- rami di errore del WebSocket handler (`ws.send`/`ws.terminate` che lanciano, `applyAction` che fallisce a metà azione: lo stato precedente va ripristinato senza propagare l'eccezione)
- avvio effettivo di `startServer` (`server.js`): ascolto HTTP, upgrade su `/ws`, rifiuto su altri percorsi, persistenza dopo un'azione — con `persist.js` mockato per non toccare mai `.segnapunti/state.json` reale
Quando falliscono:
- spesso c'e problema nel protocollo messaggi o nei controlli di ruolo
@@ -124,6 +140,11 @@ Esempi reali nel progetto:
- stato connessione
- click bottoni controller
- dialog reset/config/cambi
- modal time out del controller (scelta squadra/video, banner, countdown)
- overlay time out del display (video, placeholder, countdown, dissolvenza)
- bottone REFERTO nel dialog PARTITA FINITA e generazione del referto stampabile (`generaReferto`: `window.open`, scrittura HTML, stampa)
- client WebSocket (`wsMixin`: connessione, riconnessione con backoff, cleanup allo smontaggio, `sendWs`, errori)
- layout esteso/landscape del controller (pannelli squadra, popup automatico SET VINTO con soglia decisiva)
Quando falliscono:
- spesso hai rotto template, computed, metodi o binding
@@ -152,15 +173,20 @@ A cosa servono:
- verificano che Controller e Display funzionino davvero insieme
File principali:
- `helpers.cjs`: helper condivisi (`openController`/`openDisplay`/`resetGame`/`addPoints`)
- `basic-flow.spec.cjs`: flusso base Controller <-> Display
- `game-operations.spec.cjs`: reset, config, toggle, cambi
- `game-simulation.spec.cjs`: simulazione partita
- `full-match.spec.cjs`: scenari partita completi
- `timeout.spec.cjs`: time out end-to-end (modal, overlay sul display, chiusura anticipata)
- `referto.spec.cjs`: generazione referto a PARTITA FINITA
- `accessibility.spec.cjs`: controlli accessibilita con axe
- `visual-regression.spec.cjs`: confronto screenshot con baseline
Nota importante:
Note importanti:
- gli E2E sono configurati in seriale (`workers: 1`) per evitare interferenze sullo stato partita condiviso.
- il controller viene aperto in viewport portrait (layout "mobile") tramite `openController`: in landscape userebbe la dashboard estesa con selettori diversi.
- lo stato è UNICO e persistente sul server: ogni test inizia con `resetGame`, che azzera e reimposta i nomi di default per garantire determinismo.
## 6) Come leggere i risultati
@@ -169,8 +195,8 @@ Nota importante:
Caso OK (verde):
```text
Test Files 6 passed (6)
Tests 159 passed (159)
Test Files 15 passed (15)
Tests 362 passed (362)
```
Significa:
@@ -187,7 +213,7 @@ Caso KO (rosso):
Caso OK:
```text
72 passed
81 passed
```
Caso KO:
@@ -258,7 +284,23 @@ npm run test:e2e -- --update-snapshots
npm run test:e2e
```
## 10) Come aggiungere un nuovo test (consigli pratici)
## 10) Copertura del codice
Non c'è uno script `npm` dedicato, ma si può controllare la copertura in
qualsiasi momento con:
```bash
npx vitest run tests/unit tests/integration tests/component --coverage
```
Il report a schermo mostra percentuale e righe scoperte per file. La suite
attuale copre oltre il 99% di statement/linee; le poche righe rimaste
scoperte sono documentate nei commit dei rispettivi test come codice morto,
difensivo, oppure legato a HMR (`import.meta.hot`) o all'avvio diretto
(`node server.js`) — casi non testabili in modo significativo senza side
effect reali sull'ambiente.
## 11) Come aggiungere un nuovo test (consigli pratici)
- metti i test nel posto giusto:
- `tests/unit` per logica pura
+687 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, flushPromises } from '@vue/test-utils'
import ControllerPage from '../../src/components/ControllerPage.vue'
import { generaReferto } from '../../src/referto.js'
@@ -29,6 +29,10 @@ class MockWebSocket {
vi.stubGlobal('WebSocket', MockWebSocket)
// Mock di fetch per /spot/list (usato dalla modal time out).
const fetchMock = vi.fn().mockResolvedValue({ json: async () => [] })
vi.stubGlobal('fetch', fetchMock)
// Forza l'orientamento portrait → il controller usa il layout "mobile"
// (con .team-pts, .btn-ctrl, ecc.) su cui questi test fanno asserzioni.
Object.defineProperty(window, 'innerWidth', { value: 400, writable: true, configurable: true })
@@ -129,6 +133,14 @@ describe('ControllerPage.vue', () => {
const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla'))
expect(btn.attributes('disabled')).toBeDefined()
})
it('il click quando abilitato invia cambiaPalla', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla'))
await btn.trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'cambiaPalla' })
})
})
// =============================================
@@ -287,6 +299,680 @@ describe('ControllerPage.vue', () => {
})
})
// =============================================
// TIME OUT
// =============================================
describe('Time Out', () => {
it('il click su Time Out apre la modal con l\'elenco dei video', async () => {
fetchMock.mockResolvedValueOnce({ json: async () => ['a.mp4', 'b.mp4'] })
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
expect(wrapper.vm.showTimeoutModal).toBe(true)
expect(wrapper.find('.dialog-timeout').exists()).toBe(true)
expect(wrapper.vm.spotList).toEqual(['a.mp4', 'b.mp4'])
// le opzioni sono i video più "Nessun video"
expect(wrapper.findAll('.timeout-video-option')).toHaveLength(3)
})
it('se /spot/list fallisce la modal si apre comunque senza video', async () => {
fetchMock.mockRejectedValueOnce(new Error('rete assente'))
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
expect(wrapper.vm.showTimeoutModal).toBe(true)
expect(wrapper.vm.spotList).toEqual([])
})
it('Avvia è disabilitato finché non si sceglie la squadra', async () => {
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
const avvia = wrapper.find('.dialog-timeout .btn-confirm')
expect(avvia.attributes('disabled')).toBeDefined()
await wrapper.find('.dialog-timeout .btn-set.home-bg').trigger('click')
expect(avvia.attributes('disabled')).toBeUndefined()
})
it('Avvia invia startTimeout con squadra e video e chiude la modal', async () => {
fetchMock.mockResolvedValueOnce({ json: async () => ['a.mp4'] })
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
await wrapper.find('.dialog-timeout .btn-set.guest-bg').trigger('click')
wrapper.vm.timeoutVideoSel = 'a.mp4'
await wrapper.find('.dialog-timeout .btn-confirm').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'startTimeout', team: 'guest', video: 'a.mp4' })
expect(wrapper.vm.showTimeoutModal).toBe(false)
})
it('durante il time out il bottone invia stopTimeout', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
wrapper.vm.state.timeout = true
wrapper.vm.state.timeoutTeam = 'home'
await wrapper.vm.$nextTick()
await wrapper.find('.btn-timeout').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'stopTimeout' })
})
it('avviaTimeout non invia nulla se nessuna squadra è selezionata', () => {
const wrapper = mountController()
wrapper.vm.timeoutTeamSel = null
const spy = vi.spyOn(wrapper.vm, 'sendAction')
wrapper.vm.avviaTimeout()
expect(spy).not.toHaveBeenCalled()
})
it('il ticker del time out aggiorna nowTick periodicamente', async () => {
const wrapper = mountController()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = Date.now()
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
const before = wrapper.vm.nowTick
vi.advanceTimersByTime(500)
expect(wrapper.vm.nowTick).toBeGreaterThan(before)
})
it('durante il time out mostra banner con squadra e countdown', async () => {
const wrapper = mountController()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = Date.now() - 10000
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
const banner = wrapper.find('.timeout-banner')
expect(banner.exists()).toBe(true)
expect(banner.text()).toContain('TIME OUT')
expect(banner.text()).toContain('Antoniana')
expect(banner.text()).toContain('00:20')
// anche il bottone mostra il countdown
expect(wrapper.find('.btn-timeout').text()).toBe('00:20')
})
})
// =============================================
// MODALITÀ ESTESA (landscape)
// =============================================
describe('Modalità estesa', () => {
it('con isLandscape=true mostra il layout esteso con i pannelli squadra', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
expect(wrapper.find('.e-dash').exists()).toBe(true)
expect(wrapper.findAll('.e-panel')).toHaveLength(2)
// in modalità estesa non compare il layout mobile
expect(wrapper.find('.score-preview').exists()).toBe(false)
})
it('il resize della finestra aggiorna isLandscape', async () => {
const wrapper = mountController()
expect(wrapper.vm.isLandscape).toBe(false)
Object.defineProperty(window, 'innerWidth', { value: 800, writable: true, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 400, writable: true, configurable: true })
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
expect(wrapper.vm.isLandscape).toBe(true)
// ripristina le dimensioni portrait usate dagli altri test
Object.defineProperty(window, 'innerWidth', { value: 400, writable: true, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, writable: true, configurable: true })
})
it('il click sul punteggio in modalità estesa invia incPunt', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.findAll('.e-panel__score')[0].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'home' })
})
it('smontare il componente rimuove i listener di resize e ferma il ticker del time out', async () => {
const wrapper = mountController()
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
expect(vi.getTimerCount()).toBeGreaterThan(0)
const removeSpy = vi.spyOn(window, 'removeEventListener')
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
expect(removeSpy).toHaveBeenCalledWith('orientationchange', expect.any(Function))
})
})
// =============================================
// SET VINTO AUTOMATICO (watch squadraVincente)
// =============================================
describe('Set vinto automatico', () => {
it('apre la modal SET VINTO quando il punteggio raggiunge la soglia', async () => {
const wrapper = mountController()
setScore(wrapper, 25, 10)
await wrapper.vm.$nextTick()
expect(wrapper.vm.showSetVinto).toBe(true)
expect(wrapper.vm.setVintoTeam).toBe('home')
expect(wrapper.find('.dialog-title').text()).toBe('SET VINTO')
})
it('non riapre la modal se è già aperta', async () => {
const wrapper = mountController()
setScore(wrapper, 25, 10)
await wrapper.vm.$nextTick()
wrapper.vm.setVintoTeam = 'guest'
setScore(wrapper, 25, 24)
await wrapper.vm.$nextTick()
// il watch non deve sovrascrivere setVintoTeam perché showSetVinto è già true
expect(wrapper.vm.setVintoTeam).toBe('guest')
})
it('INDIETRO annulla l\'ultimo punto e chiude la modal', async () => {
const wrapper = mountController()
setScore(wrapper, 25, 10)
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.btn-cancel').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'decPunt' })
expect(wrapper.vm.showSetVinto).toBe(false)
})
it('VAI AL SET SUCCESSIVO invia nuovoSet e apre la configurazione', async () => {
const wrapper = mountController()
setScore(wrapper, 25, 10)
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const btn = wrapper.findAll('.btn-confirm').find(b => b.text().includes('SET SUCCESSIVO'))
await btn.trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'nuovoSet', team: 'home' })
expect(wrapper.vm.showSetVinto).toBe(false)
expect(wrapper.vm.showConfig).toBe(true)
})
})
// =============================================
// CONFIGURAZIONE
// =============================================
describe('Configurazione', () => {
it('apre la modal di configurazione precompilata con lo stato corrente', async () => {
const wrapper = mountController()
await wrapper.find('.btn-ctrl:not(.btn-timeout)').exists()
const btn = wrapper.findAll('.btn-ctrl').find(b => b.text() === 'Config')
await btn.trigger('click')
expect(wrapper.vm.showConfig).toBe(true)
expect(wrapper.vm.configData.nomeHome).toBe('Antoniana')
expect(wrapper.vm.configData.nomeGuest).toBe('Guest')
expect(wrapper.vm.configData.modalita).toBe('3/5')
})
it('OK invia le action di configurazione e chiude la modal', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
wrapper.vm.configData = {
nomeHome: 'Casa', nomeGuest: 'Ospiti', modalita: '2/3',
formHome: ['1', '2', '3', '4', '5', '6'],
formGuest: ['1', '2', '3', '4', '5', '6'],
}
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.dialog-config .btn-confirm').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'setNomi', home: 'Casa', guest: 'Ospiti' })
expect(spy).toHaveBeenCalledWith({ type: 'setModalita', modalita: '2/3' })
expect(spy).toHaveBeenCalledWith({ type: 'setFormazione', team: 'home', form: ['1', '2', '3', '4', '5', '6'] })
expect(spy).toHaveBeenCalledWith({ type: 'setFormazione', team: 'guest', form: ['1', '2', '3', '4', '5', '6'] })
expect(wrapper.vm.showConfig).toBe(false)
})
it('Annulla chiude la modal di configurazione senza inviare nulla', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.dialog-config .btn-cancel').trigger('click')
expect(spy).not.toHaveBeenCalled()
expect(wrapper.vm.showConfig).toBe(false)
})
it('scegliendo la modalità 2/3 il bottone diventa attivo', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const btn23 = wrapper.findAll('.btn-mode').find(b => b.text() === '2/3')
await btn23.trigger('click')
expect(wrapper.vm.configData.modalita).toBe('2/3')
expect(btn23.classes()).toContain('active')
})
})
// =============================================
// GESTIONE CAMBI GIOCATORE
// =============================================
describe('Gestione cambi', () => {
it('il click su Cambi apre la selezione squadra, poi la modal cambio per la squadra scelta', async () => {
const wrapper = mountController()
const btnCambi = wrapper.findAll('.btn-ctrl').find(b => b.text() === 'Cambi')
await btnCambi.trigger('click')
expect(wrapper.vm.showCambiTeam).toBe(true)
await wrapper.find('.dialog-title').exists()
const btnHome = wrapper.find('.overlay .btn-set.home-bg')
await btnHome.trigger('click')
expect(wrapper.vm.showCambiTeam).toBe(false)
expect(wrapper.vm.showCambi).toBe(true)
expect(wrapper.vm.cambiTeam).toBe('home')
})
it('Annulla chiude la modal cambi e azzera i campi', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: '10', out: '1' }, { in: '', out: '' }]
await wrapper.vm.$nextTick()
await wrapper.find('.dialog .btn-cancel').trigger('click')
expect(wrapper.vm.showCambi).toBe(false)
expect(wrapper.vm.cambiData).toEqual([{ in: '', out: '' }, { in: '', out: '' }])
})
it('CONFERMA con un cambio valido invia confermaCambi e chiude la modal', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: '10', out: '1' }, { in: '', out: '' }]
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.dialog .btn-confirm').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'confermaCambi', team: 'home', cambi: [{ in: '10', out: '1' }] })
expect(wrapper.vm.showCambi).toBe(false)
})
it('segnala errore se il numero del giocatore non è formato da sole cifre', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: 'ab', out: '1' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(wrapper.vm.cambiError).toBe('I numeri dei giocatori devono essere cifre')
})
it('segnala errore se il giocatore entrante coincide con l\'uscente', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: '1', out: '1' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(wrapper.vm.cambiError).toBe('Il giocatore 1 non può sostituire sé stesso')
})
it('segnala errore se il giocatore entrante è già in formazione', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
// "2" è già presente nella formazione di default ["1".."6"]
wrapper.vm.cambiData = [{ in: '2', out: '1' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(wrapper.vm.cambiError).toBe('Il giocatore 2 è già in formazione')
})
it('segnala errore se il giocatore uscente non è in formazione', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
wrapper.vm.cambiData = [{ in: '10', out: '9' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(wrapper.vm.cambiError).toBe('Il giocatore 9 non è in formazione')
})
it('confermaCambi non fa nulla se cambiValid è false', () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
const spy = vi.spyOn(wrapper.vm, 'sendAction')
wrapper.vm.cambiData = [{ in: '', out: '' }, { in: '', out: '' }]
wrapper.vm.confermaCambi()
expect(spy).not.toHaveBeenCalled()
})
})
// =============================================
// GESTIONE ERRORI WEBSOCKET
// =============================================
describe('Gestione errori WebSocket', () => {
it('onWsMessage con type error logga il messaggio', () => {
const wrapper = mountController()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper.vm.onWsMessage({ type: 'error', message: 'boom' })
expect(spy).toHaveBeenCalledWith('[Controller] Error:', 'boom')
spy.mockRestore()
})
it('sendAction senza type non invia nulla', () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendWs')
wrapper.vm.sendAction({})
expect(spy).not.toHaveBeenCalled()
})
it('sendAction mostra un feedback di errore se non connesso', () => {
const wrapper = mountController()
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper.vm.wsConnected = false
wrapper.vm.sendAction({ type: 'incPunt', team: 'home' })
expect(errSpy).toHaveBeenCalledWith('[Controller] Error:', 'Non connesso al server')
errSpy.mockRestore()
})
})
// =============================================
// DURATA VIDEO SPOT
// =============================================
describe('Durata video spot', () => {
it('formatDurata mostra i secondi arrotondati o un placeholder', () => {
const wrapper = mountController()
expect(wrapper.vm.formatDurata(12.4)).toBe('12s')
expect(wrapper.vm.formatDurata(undefined)).toBe('…')
expect(wrapper.vm.formatDurata(NaN)).toBe('…')
})
it('probeDurata popola spotDurations quando i metadata del video sono pronti', () => {
const wrapper = mountController()
const videoEls = []
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
const el = originalCreateElement(tag)
if (tag === 'video') videoEls.push(el)
return el
})
wrapper.vm.probeDurata('a.mp4')
const videoEl = videoEls[0]
Object.defineProperty(videoEl, 'duration', { value: 42, configurable: true })
videoEl.onloadedmetadata()
expect(wrapper.vm.spotDurations['a.mp4']).toBe(42)
document.createElement.mockRestore()
})
})
// =============================================
// PULSANTI MOBILE (click reali sui bottoni)
// =============================================
describe('Pulsanti layout mobile', () => {
it('ANNULLA PUNTO invia decPunt', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.btn-undo').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'decPunt' })
})
it('i due bottoni SET inviano incSet per la rispettiva squadra', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const setBtns = wrapper.findAll('.action-row .btn-set')
await setBtns[0].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'home' })
await setBtns[1].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'guest' })
})
it('Formazioni/Striscia/Inverti/Voce inviano le rispettive action', async () => {
const wrapper = mountController()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const byLabel = (label) => wrapper.findAll('.btn-ctrl').find(b => b.text().includes(label))
await byLabel('Formazioni').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleFormazione' })
await byLabel('Striscia').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleStriscia' })
await byLabel('Inverti').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleOrder' })
const wsSendSpy = vi.spyOn(wrapper.vm, 'sendWs')
wrapper.vm.wsConnected = true
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
await byLabel('Voce').trigger('click')
expect(wsSendSpy).toHaveBeenCalled()
})
it('con order=false lo score-preview mostra prima guest e poi home', async () => {
const wrapper = mountController()
wrapper.vm.state.order = false
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const scores = wrapper.findAll('.team-score')
expect(scores[0].classes()).toContain('guest-bg')
expect(scores[1].classes()).toContain('home-bg')
await scores[0].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'guest' })
})
})
// =============================================
// MODALITÀ ESTESA: interazioni aggiuntive
// =============================================
describe('Modalità estesa: interazioni', () => {
it('il click sul punteggio e sul bottone SET della seconda squadra funzionano', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.findAll('.e-panel__score')[1].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'guest' })
await wrapper.findAll('.e-panel__setbtn')[0].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'home' })
await wrapper.findAll('.e-panel__setbtn')[1].trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'guest' })
})
it('la barra e-actions invia le action corrispondenti ai bottoni', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const byLabel = (label) => wrapper.findAll('.e-act').find(b => b.text().includes(label))
await byLabel('Annulla').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'decPunt' })
await byLabel('Cambio Palla').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'cambiaPalla' })
await byLabel('Inverti').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleOrder' })
await byLabel('Striscia').trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'toggleStriscia' })
expect(wrapper.vm.showCambiTeam).toBe(false)
const btnCambi = wrapper.findAll('.e-act').find(b => b.text() === 'Cambi')
await btnCambi.trigger('click')
expect(wrapper.vm.showCambiTeam).toBe(true)
wrapper.vm.showCambiTeam = false
await byLabel('Config').trigger('click')
expect(wrapper.vm.showConfig).toBe(true)
wrapper.vm.showConfig = false
await wrapper.find('.e-act--danger').trigger('click')
expect(wrapper.vm.confirmReset).toBe(true)
})
it('il bottone Voce e il bottone Time Out della barra estesa funzionano', async () => {
const wrapper = mountController()
wrapper.vm.isLandscape = true
await wrapper.vm.$nextTick()
wrapper.vm.wsConnected = true
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
const wsSendSpy = vi.spyOn(wrapper.vm, 'sendWs')
const btnVoce = wrapper.findAll('.e-act').find(b => b.text() === 'Voce')
await btnVoce.trigger('click')
expect(wsSendSpy).toHaveBeenCalled()
const btnTimeout = wrapper.find('.e-act.btn-timeout')
await btnTimeout.trigger('click')
expect(wrapper.vm.showTimeoutModal).toBe(true)
wrapper.vm.showTimeoutModal = false
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await btnTimeout.trigger('click')
expect(spy).toHaveBeenCalledWith({ type: 'stopTimeout' })
})
})
// =============================================
// CHIUSURA DIALOG CLICCANDO FUORI (overlay self)
// =============================================
describe('Chiusura dialog cliccando fuori', () => {
it('conferma reset si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
wrapper.vm.confirmReset = true
await wrapper.vm.$nextTick()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.confirmReset).toBe(false)
})
it('configurazione si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.showConfig).toBe(false)
})
it('scelta squadra cambi si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
wrapper.vm.showCambiTeam = true
await wrapper.vm.$nextTick()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.showCambiTeam).toBe(false)
})
it('la modal cambi si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
await wrapper.vm.$nextTick()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.showCambi).toBe(false)
})
it('la modal time out si chiude cliccando fuori dal dialog', async () => {
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
await wrapper.find('.overlay').trigger('click')
expect(wrapper.vm.showTimeoutModal).toBe(false)
})
})
// =============================================
// DIALOG PARTITA FINITA / CONFIGURAZIONE: dettagli
// =============================================
describe('Dettagli dialog', () => {
it('CHIUDI chiude il dialog partita finita senza inviare action', async () => {
const wrapper = mountController()
wrapper.vm.state.modalitaPartita = '2/3'
wrapper.vm.state.sp.striscia = [
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'h', ris: '', vinc: null },
]
wrapper.vm.setVintoTeam = 'home'
wrapper.vm.showSetVinto = true
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
const chiudi = wrapper.findAll('.btn-confirm').find(b => b.text() === 'CHIUDI')
await chiudi.trigger('click')
expect(spy).not.toHaveBeenCalled()
expect(wrapper.vm.showSetVinto).toBe(false)
})
it('isPartitaFinita è false in modalità amichevole anche a set vinto', () => {
const wrapper = mountController()
wrapper.vm.state.modalitaPartita = 'amichevole'
wrapper.vm.setVintoTeam = 'home'
expect(wrapper.vm.isPartitaFinita).toBe(false)
})
it('la vittoria della squadra guest apre comunque la modal SET VINTO', async () => {
const wrapper = mountController()
setScore(wrapper, 10, 25, 'g')
await wrapper.vm.$nextTick()
expect(wrapper.vm.setVintoTeam).toBe('guest')
})
it('nel set decisivo la soglia di vittoria scende a 15 punti', async () => {
const wrapper = mountController()
// 4 set già assegnati (2 a testa): il quinto è il set decisivo (3/5)
wrapper.vm.state.sp.striscia = [
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'h', ris: '', vinc: 'g' },
{ serv: 'h', ris: '', vinc: 'h' },
{ serv: 'h', ris: '', vinc: 'g' },
{ serv: 'h', ris: '', vinc: null },
]
setScore(wrapper, 15, 10)
await wrapper.vm.$nextTick()
expect(wrapper.vm.setVintoTeam).toBe('home')
})
it('i bottoni modalità 3/5 e amichevole aggiornano configData', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const btn35 = wrapper.findAll('.btn-mode').find(b => b.text() === '3/5')
await btn35.trigger('click')
expect(wrapper.vm.configData.modalita).toBe('3/5')
const btnAmichevole = wrapper.findAll('.btn-mode').find(b => b.text() === 'Amichevole')
await btnAmichevole.trigger('click')
expect(wrapper.vm.configData.modalita).toBe('amichevole')
expect(btnAmichevole.classes()).toContain('active')
})
it('i campi di testo di configurazione aggiornano configData tramite v-model', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const inputs = wrapper.findAll('.dialog-config .input-field')
await inputs[0].setValue('Casa Nuova')
await inputs[1].setValue('Ospiti Nuovi')
expect(wrapper.vm.configData.nomeHome).toBe('Casa Nuova')
expect(wrapper.vm.configData.nomeGuest).toBe('Ospiti Nuovi')
})
it('i campi numerici della formazione aggiornano configData tramite v-model', async () => {
const wrapper = mountController()
wrapper.vm.showConfig = true
await wrapper.vm.$nextTick()
const numInputs = wrapper.findAll('.dialog-config .input-num')
// 6 campi per home + 6 per guest: li valorizziamo tutti per coprire ogni binding v-model
for (let i = 0; i < numInputs.length; i++) {
await numInputs[i].setValue(String(i))
}
// il DOM ordina le celle come 3,2,1 / 4,5,0: verifichiamo l'insieme dei valori digitati
expect(new Set(wrapper.vm.configData.formHome)).toEqual(new Set(['0', '1', '2', '3', '4', '5']))
expect(new Set(wrapper.vm.configData.formGuest)).toEqual(new Set(['6', '7', '8', '9', '10', '11']))
})
it('la selezione squadra guest per i cambi apre la modal con cambiTeam=guest', async () => {
const wrapper = mountController()
wrapper.vm.showCambiTeam = true
await wrapper.vm.$nextTick()
await wrapper.find('.overlay .btn-set.guest-bg').trigger('click')
expect(wrapper.vm.cambiTeam).toBe('guest')
expect(wrapper.vm.showCambi).toBe(true)
})
it('i campi IN/OUT della modal cambi aggiornano cambiData tramite v-model', async () => {
const wrapper = mountController()
wrapper.vm.openCambi('home')
await wrapper.vm.$nextTick()
const inField = wrapper.find('.cambi-in-field')
const outField = wrapper.find('.cambi-out-field')
await inField.setValue('10')
await outField.setValue('1')
expect(wrapper.vm.cambiData[0]).toEqual({ in: '10', out: '1' })
})
it('nella modal time out si può selezionare un video dall\'elenco tramite radio', async () => {
fetchMock.mockResolvedValueOnce({ json: async () => ['a.mp4'] })
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
const radios = wrapper.findAll('input[type=radio]')
await radios[0].setValue(true)
expect(wrapper.vm.timeoutVideoSel).toBe('a.mp4')
await radios[1].setValue(true)
expect(wrapper.vm.timeoutVideoSel).toBe(null)
})
it('Annulla nella modal time out la chiude senza avviarlo', async () => {
const wrapper = mountController()
await wrapper.find('.btn-timeout').trigger('click')
await flushPromises()
const spy = vi.spyOn(wrapper.vm, 'sendAction')
await wrapper.find('.dialog-timeout .btn-cancel').trigger('click')
expect(spy).not.toHaveBeenCalled()
expect(wrapper.vm.showTimeoutModal).toBe(false)
})
})
// =============================================
// BARRA CONNESSIONE
// =============================================
+341
View File
@@ -31,6 +31,21 @@ vi.stubGlobal('speechSynthesis', {
getVoices: () => []
})
// happy-dom non implementa la Web Speech API: stub minimo dell'utterance.
vi.stubGlobal('SpeechSynthesisUtterance', class {
constructor(text) {
this.text = text
this.voice = null
this.lang = ''
}
})
// happy-dom non implementa la riproduzione media: stub di play/pause/load
// così l'overlay time out può montare il <video> senza errori.
Object.defineProperty(HTMLMediaElement.prototype, 'play', { value: vi.fn().mockResolvedValue(undefined), writable: true })
Object.defineProperty(HTMLMediaElement.prototype, 'pause', { value: vi.fn(), writable: true })
Object.defineProperty(HTMLMediaElement.prototype, 'load', { value: vi.fn(), writable: true })
function mountDisplay() {
return mount(DisplayPage, {
global: {
@@ -175,6 +190,332 @@ describe('DisplayPage.vue', () => {
})
})
// =============================================
// OVERLAY TIME OUT
// =============================================
describe('Overlay time out', () => {
// Attiva il time out impostando lo stato come farebbe il server.
async function attivaTimeout(wrapper, { team = 'home', video = null, startedAt = Date.now() } = {}) {
wrapper.vm.state.timeoutTeam = team
wrapper.vm.state.timeoutVideo = video
wrapper.vm.state.timeoutStartedAt = startedAt
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
}
it('non mostra l\'overlay quando il time out non è attivo', () => {
const wrapper = mountDisplay()
expect(wrapper.find('.timeout-overlay').exists()).toBe(false)
})
it('senza video mostra il placeholder con il nome della squadra', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { team: 'home', video: null })
expect(wrapper.find('.timeout-overlay').exists()).toBe(true)
const placeholder = wrapper.find('.timeout-placeholder')
expect(placeholder.text()).toContain('TIME OUT')
expect(placeholder.text()).toContain('Antoniana')
expect(wrapper.find('.timeout-video').exists()).toBe(false)
})
it('con un video lo riproduce nell\'overlay', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { team: 'guest', video: 'a.mp4' })
const video = wrapper.find('.timeout-video')
expect(video.exists()).toBe(true)
expect(video.element.src).toContain('/spot/a.mp4')
expect(wrapper.find('.timeout-placeholder').exists()).toBe(false)
})
it('a fine video mostra il placeholder fino allo stop del server', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4' })
const video = wrapper.find('.timeout-video')
video.element.onended()
await wrapper.vm.$nextTick()
expect(wrapper.find('.timeout-video').exists()).toBe(false)
expect(wrapper.find('.timeout-placeholder').exists()).toBe(true)
})
it('il countdown parte da timeoutStartedAt', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { startedAt: Date.now() - 5000 })
expect(wrapper.vm.timeoutRemaining).toBe(25)
expect(wrapper.find('.timeout-countdown').text()).toBe('00:25')
})
it('timeoutFading è true solo con video oltre i 30s negli ultimi istanti', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4', startedAt: Date.now() - 29500 })
// video corto: mai dissolvenza
wrapper.vm.spotVideoDuration = 20
expect(wrapper.vm.timeoutFading).toBe(false)
// video più lungo dei 30s: dissolvenza nell'ultimo secondo
wrapper.vm.spotVideoDuration = 45
expect(wrapper.vm.timeoutRemaining).toBe(1)
expect(wrapper.vm.timeoutFading).toBe(true)
})
it('alla fine del time out l\'overlay scompare', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper)
wrapper.vm.state.timeout = false
await wrapper.vm.$nextTick()
expect(wrapper.find('.timeout-overlay').exists()).toBe(false)
})
})
// =============================================
// ORDINE GUEST/HOME CON FORMAZIONE
// =============================================
describe('Ordine guest/home con formazione visibile', () => {
it('order=false e visuForm=true mostra punteggio inline e formazioni nell\'ordine guest poi home', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.order = false
wrapper.vm.state.visuForm = true
wrapper.vm.state.sp.striscia.at(-1).ris = 'h'.repeat(3) + 'g'.repeat(2)
await wrapper.vm.$nextTick()
const scores = wrapper.findAll('.score-inline')
expect(scores[0].text()).toBe('2') // guest per primo
expect(scores[1].text()).toBe('3') // home per secondo
const formDivs = wrapper.findAll('.formdiv')
expect(formDivs).toHaveLength(12)
})
})
// =============================================
// MONTAGGIO: FULLSCREEN E TIME OUT GIÀ ATTIVO
// =============================================
describe('Comportamento al mount', () => {
it('su dispositivo mobile richiede la fullscreen', () => {
const uaSpy = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)')
const fsSpy = vi.fn()
document.documentElement.requestFullscreen = fsSpy
const wrapper = mountDisplay()
expect(fsSpy).toHaveBeenCalled()
wrapper.unmount()
uaSpy.mockRestore()
})
it('non richiede la fullscreen su desktop', () => {
const fsSpy = vi.fn()
document.documentElement.requestFullscreen = fsSpy
const wrapper = mountDisplay()
expect(fsSpy).not.toHaveBeenCalled()
wrapper.unmount()
})
it('ignora l\'errore se requestFullscreen lancia un\'eccezione', () => {
const uaSpy = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Linux; Android 10)')
document.documentElement.requestFullscreen = () => { throw new Error('non consentito') }
expect(() => mountDisplay()).not.toThrow()
uaSpy.mockRestore()
})
it('se il time out è già attivo al mount avvia subito la modalità time out', async () => {
const wrapper = mount(DisplayPage, {
global: { stubs: { 'w-app': true } },
data() {
const state = {
order: true, visuForm: false, visuStriscia: true,
timeout: true, timeoutTeam: 'home', timeoutVideo: null,
timeoutStartedAt: Date.now(), modalitaPartita: '3/5',
sp: {
striscia: [{ serv: 'h', ris: '', vinc: null }],
nomi: { home: 'Antoniana', guest: 'Guest' },
form: { home: ['1', '2', '3', '4', '5', '6'], guest: ['1', '2', '3', '4', '5', '6'] },
},
}
return { state }
}
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.mostraPlaceholder).toBe(true)
expect(wrapper.find('.timeout-overlay').exists()).toBe(true)
})
})
// =============================================
// TICKER COUNTDOWN E CASI LIMITE
// =============================================
describe('Ticker del countdown', () => {
it('il ticker aggiorna nowTick periodicamente durante il time out', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = Date.now()
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
expect(wrapper.vm.timeoutRemaining).toBe(30)
vi.advanceTimersByTime(1000)
await wrapper.vm.$nextTick()
expect(wrapper.vm.timeoutRemaining).toBeLessThan(30)
})
it('timeoutRemaining resta 30 se manca timeoutStartedAt', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = null
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
expect(wrapper.vm.timeoutRemaining).toBe(30)
})
it('smontare il componente durante il time out ferma il ticker senza errori', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.timeoutTeam = 'home'
wrapper.vm.state.timeoutStartedAt = Date.now()
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
expect(() => wrapper.unmount()).not.toThrow()
})
it('stricciaStrip non fallisce se la striscia del set corrente è assente', async () => {
const wrapper = mountDisplay()
wrapper.vm.state.sp.striscia = []
expect(wrapper.vm.stricciaStrip).toEqual({ home: [], guest: [] })
// ripristina uno stato valido prima che Vue ricalcoli gli altri computed (punt/servHome)
wrapper.vm.state.sp.striscia = [{ serv: 'h', ris: '', vinc: null }]
await wrapper.vm.$nextTick()
})
})
// =============================================
// FINE TIME OUT: STOP VIDEO E FALLBACK MUTO
// =============================================
describe('Stop del time out e riproduzione video', () => {
async function attivaTimeout(wrapper, { team = 'home', video = null, startedAt = Date.now() } = {}) {
wrapper.vm.state.timeoutTeam = team
wrapper.vm.state.timeoutVideo = video
wrapper.vm.state.timeoutStartedAt = startedAt
wrapper.vm.state.timeout = true
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
}
it('allo stop del time out con video in corso mette in pausa e resetta il video', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4' })
const video = wrapper.find('.timeout-video').element
const pauseSpy = vi.spyOn(video, 'pause')
wrapper.vm.state.timeout = false
await wrapper.vm.$nextTick()
expect(pauseSpy).toHaveBeenCalled()
})
it('a metà riproduzione si riallinea al punto corretto (riconnessione)', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4', startedAt: Date.now() - 10000 })
const video = wrapper.find('.timeout-video').element
Object.defineProperty(video, 'duration', { value: 60, configurable: true })
video.onloadedmetadata()
expect(video.currentTime).toBeCloseTo(10, 0)
})
it('senza timeoutStartedAt non tenta alcun riallineamento (trascorsi=0)', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4', startedAt: null })
const video = wrapper.find('.timeout-video').element
Object.defineProperty(video, 'duration', { value: 60, configurable: true })
video.currentTime = 0
video.onloadedmetadata()
expect(video.currentTime).toBe(0)
})
it('non si riallinea se il tempo trascorso supera la durata del video', async () => {
const wrapper = mountDisplay()
await attivaTimeout(wrapper, { video: 'a.mp4', startedAt: Date.now() - 10000 })
const video = wrapper.find('.timeout-video').element
Object.defineProperty(video, 'duration', { value: 5, configurable: true })
video.currentTime = 0
video.onloadedmetadata()
expect(video.currentTime).toBe(0)
})
it('caricaSpotCorrente non fa nulla se manca il ref del video o il video del time out', () => {
const wrapper = mountDisplay()
// nessun overlay attivo: $refs.spotVideo non esiste
expect(() => wrapper.vm.caricaSpotCorrente()).not.toThrow()
expect(wrapper.vm.spotVideoDuration).toBe(null)
})
it('playSpot non fa nulla se manca il ref del video', () => {
const wrapper = mountDisplay()
expect(() => wrapper.vm.playSpot()).not.toThrow()
})
it('se l\'autoplay con audio viene bloccato ripiega sul muto', async () => {
const wrapper = mountDisplay()
HTMLMediaElement.prototype.play = vi.fn()
.mockRejectedValueOnce(new Error('autoplay bloccato'))
.mockResolvedValueOnce(undefined)
await attivaTimeout(wrapper, { video: 'a.mp4' })
const video = wrapper.find('.timeout-video').element
await Promise.resolve()
await Promise.resolve()
expect(video.muted).toBe(true)
})
})
// =============================================
// MESSAGGI WEBSOCKET E SINTESI VOCALE
// =============================================
describe('onWsMessage e sintesi vocale', () => {
it('un messaggio "speak" avvia la sintesi vocale con il testo ricevuto', () => {
const wrapper = mountDisplay()
wrapper.vm.onWsMessage({ type: 'speak', text: '15 a 10' })
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
const utterance = window.speechSynthesis.speak.mock.calls.at(-1)[0]
expect(utterance.text).toBe('15 a 10')
expect(utterance.lang).toBe('it-IT')
})
it('un messaggio "error" viene loggato in console', () => {
const wrapper = mountDisplay()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper.vm.onWsMessage({ type: 'error', message: 'boom' })
expect(spy).toHaveBeenCalledWith('[Display] Server error:', 'boom')
spy.mockRestore()
})
it('ignora testo vuoto o non stringa nella sintesi vocale', () => {
const wrapper = mountDisplay()
window.speechSynthesis.speak.mockClear()
wrapper.vm.speakOnDisplay(' ')
wrapper.vm.speakOnDisplay(42)
expect(window.speechSynthesis.speak).not.toHaveBeenCalled()
})
it('sceglie la voce "Google italiano" quando disponibile', () => {
const wrapper = mountDisplay()
const vIt = { name: 'voce it generica', lang: 'it-IT' }
const vGoogle = { name: 'Google italiano', lang: 'it-IT' }
window.speechSynthesis.getVoices = () => [vIt, vGoogle]
wrapper.vm.speakOnDisplay('prova')
const utterance = window.speechSynthesis.speak.mock.calls.at(-1)[0]
expect(utterance.voice).toBe(vGoogle)
})
it('ripiega su una voce con lingua "it" se manca "Google italiano"', () => {
const wrapper = mountDisplay()
const vIt = { name: 'voce italiana', lang: 'it-IT' }
const vEn = { name: 'english voice', lang: 'en-US' }
window.speechSynthesis.getVoices = () => [vEn, vIt]
wrapper.vm.speakOnDisplay('prova')
const utterance = window.speechSynthesis.speak.mock.calls.at(-1)[0]
expect(utterance.voice).toBe(vIt)
})
it('usa voice=null se nessuna voce italiana è disponibile', () => {
const wrapper = mountDisplay()
window.speechSynthesis.getVoices = () => [{ name: 'english voice', lang: 'en-US' }]
wrapper.vm.speakOnDisplay('prova')
const utterance = window.speechSynthesis.speak.mock.calls.at(-1)[0]
expect(utterance.voice).toBe(null)
})
})
// =============================================
// ICONA SERVIZIO
// =============================================
@@ -0,0 +1,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)
})
})
+151
View File
@@ -142,6 +142,49 @@ describe('createWsMixin (wsMixin.js)', () => {
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', () => {
@@ -161,6 +204,17 @@ describe('createWsMixin (wsMixin.js)', () => {
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', () => {
@@ -170,5 +224,102 @@ describe('createWsMixin (wsMixin.js)', () => {
wrapper.unmount()
expect(ws.close).toHaveBeenCalled()
})
it('beforeUnmount con connessione OPEN chiude con codice 1000', () => {
const wrapper = mountWith()
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
wrapper.unmount()
expect(ws.close).toHaveBeenCalledWith(1000, 'Component unmounting')
})
it('beforeUnmount non lancia se ws.close lancia', () => {
const wrapper = mountWith()
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
ws.close.mockImplementation(() => { throw new Error('già chiusa') })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => wrapper.unmount()).not.toThrow()
expect(consoleSpy).toHaveBeenCalled()
})
it('beforeUnmount annulla un reconnectTimeout pendente', () => {
vi.useFakeTimers()
try {
const wrapper = mountWith()
wrapper.vm.isConnecting = false
wrapper.vm.scheduleReconnect()
expect(wrapper.vm.reconnectTimeout).not.toBe(null)
wrapper.unmount()
// se il timer non fosse stato annullato, allo scoccare del delay
// verrebbe creata una nuova connessione anche a componente smontato
const primaDelTimeout = MockWebSocket.instances.length
vi.advanceTimersByTime(30000)
expect(MockWebSocket.instances.length).toBe(primaDelTimeout)
} finally {
vi.useRealTimers()
}
})
})
describe('rami aggiuntivi', () => {
it('connectWebSocket non fa nulla se è già in corso un tentativo di connessione', () => {
const wrapper = mountWith()
const primaDelTimeout = MockWebSocket.instances.length
wrapper.vm.isConnecting = true
wrapper.vm.connectWebSocket()
expect(MockWebSocket.instances.length).toBe(primaDelTimeout)
})
it('riconnettendosi con la WebSocket precedente OPEN, la chiude con codice 1000', () => {
const wrapper = mountWith()
const prima = ultimaWs()
prima.readyState = MockWebSocket.OPEN
wrapper.vm.isConnecting = false
wrapper.vm.connectWebSocket()
expect(prima.close).toHaveBeenCalledWith(1000, 'Reconnecting')
})
it('se la chiusura della WebSocket precedente lancia, logga e continua a riconnettersi', () => {
const wrapper = mountWith()
const prima = ultimaWs()
prima.readyState = MockWebSocket.OPEN
prima.close.mockImplementation(() => { throw new Error('già chiusa') })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper.vm.isConnecting = false
expect(() => wrapper.vm.connectWebSocket()).not.toThrow()
expect(consoleSpy).toHaveBeenCalled()
expect(ultimaWs()).not.toBe(prima)
})
it('onopen non lancia se il registro fallisce (send che lancia)', () => {
const wrapper = mountWith()
const ws = ultimaWs()
ws.readyState = MockWebSocket.OPEN
ws.send.mockImplementation(() => { throw new Error('boom') })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => ws.onopen()).not.toThrow()
expect(wrapper.vm.wsConnected).toBe(true)
expect(consoleSpy).toHaveBeenCalled()
})
it('onmessage non lancia su JSON non valido', () => {
mountWith()
const ws = ultimaWs()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => ws.onmessage({ data: 'non è JSON {{{' })).not.toThrow()
expect(consoleSpy).toHaveBeenCalled()
})
it('scheduleReconnect non arma un secondo timer se uno è già pendente', () => {
const wrapper = mountWith()
const spy = vi.spyOn(globalThis, 'setTimeout')
wrapper.vm.reconnectTimeout = 123 // timer già pendente (valore fittizio)
wrapper.vm.scheduleReconnect()
expect(spy).not.toHaveBeenCalled()
})
})
})
+15 -14
View File
@@ -1,11 +1,12 @@
const { test, expect } = require('@playwright/test');
const AxeBuilderImport = require('@axe-core/playwright');
const AxeBuilder = AxeBuilderImport.default || AxeBuilderImport;
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
test.describe('Accessibility (a11y)', () => {
test('Display: non dovrebbe avere violazioni critiche a11y', async ({ page }) => {
await page.goto('http://localhost:3000');
test('Display: non dovrebbe avere violazioni critiche a11y', async ({ context }) => {
const page = await openDisplay(context);
await page.waitForTimeout(500);
const results = await new AxeBuilder({ page })
@@ -16,13 +17,15 @@ test.describe('Accessibility (a11y)', () => {
expect(results.violations).toEqual([]);
});
test('Controller: non dovrebbe avere violazioni critiche a11y', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('http://localhost:3000/controller');
await page.waitForTimeout(500);
test('Controller: non dovrebbe avere violazioni critiche a11y', async ({ context }) => {
const page = await openController(context);
await resetGame(page);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
// I colori squadra (giallo/blu) sono colori brand: il contrasto è
// valutato separatamente, come per il display.
.disableRules(['color-contrast'])
.analyze();
// Mostra i dettagli delle violazioni se ci sono
@@ -42,10 +45,9 @@ test.describe('Accessibility (a11y)', () => {
expect(serious).toEqual([]);
});
test('Controller: i touch target dovrebbero avere dimensione minima', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('http://localhost:3000/controller');
await page.waitForSelector('.conn-bar.connected');
test('Controller: i touch target dovrebbero avere dimensione minima', async ({ context }) => {
const page = await openController(context);
await resetGame(page);
// Controlla che i bottoni principali abbiano dimensione minima 44x44px
const buttons = page.locator('.btn-ctrl');
@@ -58,10 +60,9 @@ test.describe('Accessibility (a11y)', () => {
}
});
test('Controller: i bottoni punteggio dovrebbero avere dimensione adeguata', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('http://localhost:3000/controller');
await page.waitForSelector('.conn-bar.connected');
test('Controller: i bottoni punteggio dovrebbero avere dimensione adeguata', async ({ context }) => {
const page = await openController(context);
await resetGame(page);
const scoreButtons = page.locator('.team-score');
const count = await scoreButtons.count();
+14 -73
View File
@@ -1,26 +1,19 @@
const { test, expect } = require('@playwright/test');
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
test.describe('Basic Flow: Controller ↔ Display', () => {
test('dovrebbe caricare Display e Controller con i titoli corretti', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await expect(displayPage).toHaveTitle(/Segnapunti/);
await expect(controllerPage).toHaveTitle(/Controller/);
});
test('il punteggio iniziale dovrebbe essere 0-0', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
// Attende la connessione WebSocket
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
const homeScore = controllerPage.locator('.team-score.home-bg .team-pts');
const guestScore = controllerPage.locator('.team-score.guest-bg .team-pts');
@@ -29,28 +22,10 @@ test.describe('Basic Flow: Controller ↔ Display', () => {
});
test('click +1 Home sul Controller dovrebbe aggiornare il Display', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
// Attende la connessione WebSocket del controller
await controllerPage.waitForSelector('.conn-bar.connected');
// Reset per stato pulito
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
// doReset apre automaticamente il dialog di configurazione: chiudilo
const cfgCancel = controllerPage.locator('.dialog-config .btn-cancel');
if (await cfgCancel.isVisible()) {
await cfgCancel.click();
}
await controllerPage.waitForTimeout(200);
await resetGame(controllerPage);
// Click +1 Home
await controllerPage.locator('.team-score.home-bg').click();
@@ -64,27 +39,10 @@ test.describe('Basic Flow: Controller ↔ Display', () => {
});
test('click +1 Guest sul Controller dovrebbe aggiornare il Display', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
// Reset
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
// doReset apre automaticamente il dialog di configurazione: chiudilo
const cfgCancel = controllerPage.locator('.dialog-config .btn-cancel');
if (await cfgCancel.isVisible()) {
await cfgCancel.click();
}
await controllerPage.waitForTimeout(200);
await resetGame(controllerPage);
// Click +1 Guest
await controllerPage.locator('.team-score.guest-bg').click();
@@ -98,27 +56,10 @@ test.describe('Basic Flow: Controller ↔ Display', () => {
});
test('la sincronizzazione dovrebbe funzionare con punti alternati', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
// Reset
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
// doReset apre automaticamente il dialog di configurazione: chiudilo
const cfgCancel = controllerPage.locator('.dialog-config .btn-cancel');
if (await cfgCancel.isVisible()) {
await cfgCancel.click();
}
await controllerPage.waitForTimeout(200);
await resetGame(controllerPage);
// Home +1, Guest +1, Home +1
await controllerPage.locator('.team-score.home-bg').click();
+15 -62
View File
@@ -1,49 +1,10 @@
const { test, expect } = require('@playwright/test');
// Helper: reset dal controller
async function resetGame(controllerPage) {
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
// doReset apre automaticamente il dialog di configurazione: chiudilo
const cfgCancel = controllerPage.locator('.dialog-config .btn-cancel');
if (await cfgCancel.isVisible()) {
await cfgCancel.click();
}
await controllerPage.waitForTimeout(300);
}
// Helper: incrementa punti per una squadra N volte
async function addPoints(controllerPage, team, count) {
const selector = team === 'home' ? '.team-score.home-bg' : '.team-score.guest-bg';
for (let i = 0; i < count; i++) {
await controllerPage.locator(selector).click();
await controllerPage.waitForTimeout(30);
}
await controllerPage.waitForTimeout(100);
}
// Helper: assegna un set a una squadra (25 punti + click SET)
async function winSet(controllerPage, team) {
await addPoints(controllerPage, team, 25);
// Clicca bottone SET
const setSelector = team === 'home' ? '.btn-set.home-bg' : '.btn-set.guest-bg';
await controllerPage.locator(setSelector).click();
await controllerPage.waitForTimeout(100);
// Reset punti per il prossimo set
// (in questo gioco i punti non si resettano automaticamente, serve reset manuale
// o il controller gestisce il prossimo set manualmente)
}
const { openController, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Full Match Simulation', () => {
test('Partita 2/3: Home vince 2 set a 0', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
test('Partita 2/3: Home vince il primo set', async ({ context }) => {
const controllerPage = await openController(context);
await resetGame(controllerPage);
@@ -60,19 +21,19 @@ test.describe('Full Match Simulation', () => {
// Verifica punteggio 25
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('25');
// Incrementa set Home
await controllerPage.locator('.btn-set.home-bg').click();
await controllerPage.waitForTimeout(100);
// A 25-0 compare automaticamente il dialog "SET VINTO": vai al set successivo
await controllerPage.waitForSelector('.dialog-winner');
await controllerPage.getByText('VAI AL SET SUCCESSIVO').click();
// doNuovoSet riapre il dialog di configurazione: chiudilo
await controllerPage.locator('.dialog-config .btn-cancel').click();
await controllerPage.waitForTimeout(200);
// Verifica set 1 per Home
await expect(controllerPage.locator('.team-score.home-bg .team-set')).toContainText('SET 1');
});
test('Set decisivo 2/3: vittoria a 15 punti', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
@@ -99,18 +60,12 @@ test.describe('Full Match Simulation', () => {
// Verifica punteggio 15 (e il set è decisivo: dopo 15 punti il gioco è vinto)
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('15');
// Verifica che non si possono aggiungere altri punti (vittoria)
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(100);
// Dovrebbe restare 15 (checkVittoria blocca incPunt)
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('15');
// A 15 nel set decisivo scatta la vittoria: compare il dialog (PARTITA FINITA)
await expect(controllerPage.locator('.dialog-winner')).toBeVisible();
});
test('Set normale: punti oltre 25 fino ai vantaggi', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
@@ -131,9 +86,7 @@ test.describe('Full Match Simulation', () => {
await controllerPage.waitForTimeout(100);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('26');
// 26-24 è vittoria → non si possono più aggiungere punti
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(100);
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('26');
// 26-24 è vittoria → compare automaticamente il dialog SET VINTO
await expect(controllerPage.locator('.dialog-winner')).toBeVisible();
});
});
+16 -56
View File
@@ -1,28 +1,10 @@
const { test, expect } = require('@playwright/test');
// Helper: reset dal controller
async function resetGame(controllerPage) {
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
// doReset apre automaticamente il dialog di configurazione: chiudilo
const cfgCancel = controllerPage.locator('.dialog-config .btn-cancel');
if (await cfgCancel.isVisible()) {
await cfgCancel.click();
}
await controllerPage.waitForTimeout(300);
}
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
test.describe('Game Operations', () => {
test('Undo: dovrebbe annullare l\'ultimo punto', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Incrementa Home a 1
@@ -37,10 +19,8 @@ test.describe('Game Operations', () => {
});
test('Reset: dovrebbe azzerare tutto dopo conferma', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Imposta qualche punto
for (let i = 0; i < 5; i++) {
@@ -57,13 +37,9 @@ test.describe('Game Operations', () => {
});
test('Config: dovrebbe cambiare i nomi dei team', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri config
await controllerPage.getByText('Config').click();
@@ -88,13 +64,9 @@ test.describe('Game Operations', () => {
});
test('Toggle Formazione: dovrebbe mostrare la formazione sul display', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Inizialmente mostra punteggio, non formazione
await expect(displayPage.locator('.punteggio-container')).toBeVisible();
@@ -108,13 +80,9 @@ test.describe('Game Operations', () => {
});
test('Toggle Striscia: dovrebbe nascondere/mostrare la striscia', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Inizialmente la striscia è visibile
await expect(displayPage.locator('.striscia')).toBeVisible();
@@ -131,13 +99,8 @@ test.describe('Game Operations', () => {
});
test('Cambi: dovrebbe effettuare una sostituzione giocatore', async ({ context }) => {
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
@@ -167,10 +130,7 @@ test.describe('Game Operations', () => {
});
test('Cambi: dovrebbe mostrare errore per giocatore non in formazione', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
+9 -57
View File
@@ -1,66 +1,16 @@
const { test, expect } = require('@playwright/test');
const { openController, openDisplay, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Game Simulation', () => {
test('Simulazione Partita: Controller aggiunge punti finché non cambia il set', async ({ context }) => {
// 1. Setup Pagine
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await displayPage.goto('http://localhost:3000');
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await resetGame(controllerPage);
// Selettori (basati su ID ipotetici o classi, adattali al tuo HTML reale)
// Assumo che nel DOM ci siano elementi con ID o classi riconoscibili
// E che i punteggi siano visibili.
// Pulisco lo stato iniziale (reset)
const btnReset = controllerPage.getByText(/Reset/i).first();
if (await btnReset.isVisible()) {
await btnReset.click();
// La modale di conferma ha un bottone "SI" con classe .btn-confirm
const btnConfirmReset = controllerPage.locator('.dialog .btn-confirm').getByText('SI');
if (await btnConfirmReset.isVisible()) {
await btnConfirmReset.click();
}
}
// doReset apre automaticamente il dialog di configurazione: chiudilo
const cfgCancel = controllerPage.locator('.dialog-config .btn-cancel');
if (await cfgCancel.isVisible()) {
await cfgCancel.click();
}
await controllerPage.waitForTimeout(200);
// 2. Loop per vincere il primo set (25 punti)
// In ControllerPage.vue, il click su .team-score.home-bg incrementa i punti home
const btnHomeScore = controllerPage.locator('.team-score.home-bg');
for (let i = 0; i < 25; i++) {
await btnHomeScore.click();
// Piccola pausa per lasciare tempo al server di processare e broadcastare
//await displayPage.waitForTimeout(10);
}
// 3. Verifica Vittoria Set
// I punti dovrebbero essere tornati a 0 (o mostrare 25 prima del reset manuale?)
// Il codice gameState dice: checkVittoria -> resetta solo se qualcuno chiama resetta?
// No, checkVittoria è boolean. applyAction('incPunt') incrementa.
// Se vince, il set incrementa? 'incPunt' non incrementa i set in automatico nel codice gameState checkato prima!
// Controllo applyAction:
// "s.sp.punt[team]++" ... POI "checkVittoria(s)" all'inizio del prossimo incPunt?
// NO: "if (checkVittoria(s)) break" all'inizio di incPunt impedisce di andare oltre 25 se già vinto.
// MA 'incSet' è un'azione separata!
// Aspetta, la logica standard è: arrivo a 25 -> vinco set?
// In questo codice `gameState.js` NON c'è automatismo "arrivo a 25 -> set++ e palla al centro".
// L'utente deve cliccare "SET Antoniana" manualmente?
// Guardiamo ControllerPage.vue:
// C'è un bottone "SET {{ state.sp.nomi.home }}" che manda { type: 'incSet', team: 'home' }
// QUINDI: Il test deve:
// 1. Arrivare a 25 pt.
// 2. Cliccare "SET HOME".
// 3. Verificare che Set Home = 1.
// 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');
@@ -68,6 +18,8 @@ test.describe('Game Simulation', () => {
// 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
+81
View File
@@ -0,0 +1,81 @@
// Helper condivisi per gli E2E.
// Nota: lo stato di gioco è UNICO e persistente sul server, quindi ogni test
// deve riportarlo a zero (resetGame) e gestire eventuali dialog rimasti aperti
// dal test precedente.
const CONTROLLER_URL = 'http://localhost:3000/controller';
const DISPLAY_URL = 'http://localhost:3000';
// Apre il controller in viewport portrait (layout "mobile", su cui si basano i
// selettori dei test) e attende la connessione WebSocket.
async function openController(context) {
const page = await context.newPage();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(CONTROLLER_URL);
await page.waitForSelector('.conn-bar.connected');
return page;
}
// Apre il display.
async function openDisplay(context) {
const page = await context.newPage();
await page.goto(DISPLAY_URL);
return page;
}
// Chiude l'eventuale dialog di fine set / fine partita (può essersi aperto in
// automatico al caricamento se lo stato persistito era già "vinto").
async function chiudiDialogVittoria(page) {
if (await page.locator('.dialog-winner').isVisible().catch(() => false)) {
const chiudi = page.getByRole('button', { name: 'CHIUDI' });
if (await chiudi.isVisible().catch(() => false)) {
await chiudi.click();
} else {
await page.getByText('INDIETRO').click();
}
await page.waitForTimeout(150);
}
}
// Reset completo dello stato dal controller:
// 1) chiude un eventuale dialog di vittoria, 2) azzera, 3) nel dialog di
// configurazione che doReset riapre, reimposta nomi di default e conferma.
// Reimpostare i nomi rende lo stato deterministico (lo stato è condiviso e
// persistente: senza questo i nomi di un test precedente "sporcano" gli altri,
// in particolare gli screenshot di visual-regression).
async function resetGame(page) {
await chiudiDialogVittoria(page);
await page.getByText(/Reset/i).first().click();
const btnConfirm = page.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible().catch(() => false)) {
await btnConfirm.click();
}
const cfg = page.locator('.dialog-config');
if (await cfg.isVisible().catch(() => false)) {
const inputs = page.locator('.dialog-config .input-field');
await inputs.first().fill('Antoniana');
await inputs.nth(1).fill('Guest');
await page.locator('.dialog-config .btn-confirm').click();
}
await page.waitForTimeout(300);
}
// Incrementa N punti per una squadra cliccando il punteggio.
async function addPoints(page, team, count) {
const selector = team === 'home' ? '.team-score.home-bg' : '.team-score.guest-bg';
for (let i = 0; i < count; i++) {
await page.locator(selector).click();
await page.waitForTimeout(30);
}
await page.waitForTimeout(100);
}
module.exports = {
CONTROLLER_URL,
DISPLAY_URL,
openController,
openDisplay,
chiudiDialogVittoria,
resetGame,
addPoints,
};
+47
View File
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
const { openController, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Referto di fine partita', () => {
test('a PARTITA FINITA il bottone REFERTO apre il referto stampabile', async ({ context }) => {
// window.print() bloccherebbe il test: lo neutralizziamo (anche nel popup).
await context.addInitScript(() => { window.print = () => {}; });
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Modalità 2/3 (bastano 2 set per chiudere la partita)
await controllerPage.getByText('Config').click();
await controllerPage.waitForSelector('.dialog-config');
await controllerPage.locator('.btn-mode').getByText('2/3').click();
await controllerPage.locator('.dialog-config .btn-confirm').click();
await controllerPage.waitForTimeout(200);
// SET 1: Home a 25 → dialog SET VINTO → vai al set successivo
await addPoints(controllerPage, 'home', 25);
await controllerPage.waitForSelector('.dialog-winner');
await controllerPage.getByText('VAI AL SET SUCCESSIVO').click();
await controllerPage.locator('.dialog-config .btn-cancel').click();
await controllerPage.waitForTimeout(200);
// SET 2: Home a 25 → PARTITA FINITA
await addPoints(controllerPage, 'home', 25);
await controllerPage.waitForSelector('.dialog-winner');
const referto = controllerPage.getByRole('button', { name: 'REFERTO' });
await expect(referto).toBeVisible();
// Il click apre il referto in un nuovo popup
const [popup] = await Promise.all([
context.waitForEvent('page'),
referto.click(),
]);
// Verifica il contenuto del referto
const body = popup.locator('body');
await expect(body).toContainText('Referto di Gara');
await expect(popup.locator('.set-numero')).toHaveText(['1', '2']);
// Home ha vinto 2 set a 0
await expect(body).toContainText('2 0');
});
});
+56
View File
@@ -0,0 +1,56 @@
const { test, expect } = require('@playwright/test');
const { openController, openDisplay, resetGame } = require('./helpers.cjs');
// Il flusso usa sempre l'opzione "Nessun video": non dipende dal contenuto
// della cartella spot/ e resta deterministico su qualsiasi macchina.
test.describe('Time Out', () => {
test('flusso completo: avvio, overlay sul display, chiusura anticipata', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri la modal time out
await controllerPage.locator('.btn-timeout').click();
await expect(controllerPage.locator('.dialog-timeout')).toBeVisible();
// Avvia è disabilitato finché non si sceglie la squadra
const avvia = controllerPage.locator('.dialog-timeout .btn-confirm');
await expect(avvia).toBeDisabled();
// Scegli squadra Home e nessun video
await controllerPage.locator('.dialog-timeout .btn-set.home-bg').click();
await controllerPage.locator('.timeout-video-option').last().locator('input').check();
await expect(avvia).toBeEnabled();
await avvia.click();
// Display: overlay a tutto schermo con countdown e squadra
await expect(displayPage.locator('.timeout-overlay')).toBeVisible();
await expect(displayPage.locator('.timeout-placeholder')).toContainText('TIME OUT');
await expect(displayPage.locator('.timeout-placeholder')).toContainText('Antoniana');
await expect(displayPage.locator('.timeout-countdown')).toBeVisible();
// Controller: banner attivo con nome squadra
await expect(controllerPage.locator('.timeout-banner')).toContainText('TIME OUT');
await expect(controllerPage.locator('.timeout-banner')).toContainText('Antoniana');
// Chiusura anticipata: il bottone (che mostra il countdown) invia stopTimeout
await controllerPage.locator('.btn-timeout').click();
await expect(displayPage.locator('.timeout-overlay')).not.toBeVisible();
await expect(controllerPage.locator('.timeout-banner')).not.toBeVisible();
});
test('Annulla chiude la modal senza avviare il time out', async ({ context }) => {
const displayPage = await openDisplay(context);
const controllerPage = await openController(context);
await resetGame(controllerPage);
await controllerPage.locator('.btn-timeout').click();
await expect(controllerPage.locator('.dialog-timeout')).toBeVisible();
await controllerPage.locator('.dialog-timeout .btn-cancel').click();
await expect(controllerPage.locator('.dialog-timeout')).not.toBeVisible();
await expect(controllerPage.locator('.timeout-banner')).not.toBeVisible();
await expect(displayPage.locator('.timeout-overlay')).not.toBeVisible();
});
});
+14 -47
View File
@@ -1,32 +1,12 @@
const { test, expect } = require('@playwright/test');
// Helper: reset dal controller
async function resetGame(controllerPage) {
await controllerPage.getByText(/Reset/i).first().click();
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
if (await btnConfirm.isVisible()) {
await btnConfirm.click();
}
// doReset apre automaticamente il dialog di configurazione: chiudilo
const cfgCancel = controllerPage.locator('.dialog-config .btn-cancel');
if (await cfgCancel.isVisible()) {
await cfgCancel.click();
}
await controllerPage.waitForTimeout(300);
}
const { openController, openDisplay, resetGame, addPoints } = require('./helpers.cjs');
test.describe('Visual Regression', () => {
test('Display: screenshot a 0-0', async ({ context }) => {
const controllerPage = await context.newPage();
const displayPage = await context.newPage();
const controllerPage = await openController(context);
const displayPage = await openDisplay(context);
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await displayPage.goto('http://localhost:3000');
await controllerPage.waitForSelector('.conn-bar.connected');
// Reset per stato pulito
await resetGame(controllerPage);
// Attende che il display riceva lo stato
@@ -34,42 +14,31 @@ test.describe('Visual Regression', () => {
await expect(displayPage).toHaveScreenshot('display-0-0.png', {
maxDiffPixelRatio: 0.05,
animations: 'disabled',
mask: [displayPage.locator('.connection-status')],
});
});
test('Display: screenshot durante partita (15-12)', async ({ context }) => {
const controllerPage = await context.newPage();
const displayPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await displayPage.goto('http://localhost:3000');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
const displayPage = await openDisplay(context);
await resetGame(controllerPage);
// Porta il punteggio a 15-12
for (let i = 0; i < 15; i++) {
await controllerPage.locator('.team-score.home-bg').click();
await controllerPage.waitForTimeout(20);
}
for (let i = 0; i < 12; i++) {
await controllerPage.locator('.team-score.guest-bg').click();
await controllerPage.waitForTimeout(20);
}
await addPoints(controllerPage, 'home', 15);
await addPoints(controllerPage, 'guest', 12);
await displayPage.waitForTimeout(500);
await expect(displayPage).toHaveScreenshot('display-15-12.png', {
maxDiffPixelRatio: 0.05,
animations: 'disabled',
mask: [displayPage.locator('.connection-status')],
});
});
test('Controller: screenshot stato iniziale', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
await expect(controllerPage).toHaveScreenshot('controller-initial.png', {
@@ -78,10 +47,8 @@ test.describe('Visual Regression', () => {
});
test('Controller: screenshot con modal config aperta', async ({ context }) => {
const controllerPage = await context.newPage();
await controllerPage.setViewportSize({ width: 390, height: 844 });
await controllerPage.goto('http://localhost:3000/controller');
await controllerPage.waitForSelector('.conn-bar.connected');
const controllerPage = await openController(context);
await resetGame(controllerPage);
// Apri config
await controllerPage.getByText('Config').click();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 197 KiB

+36 -3
View File
@@ -4,11 +4,12 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { createApp } from '../../server.js'
// Usiamo una dist temporanea con file marcatori, così il test non dipende da
// una build reale ed è deterministico.
// 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-'))
@@ -16,7 +17,12 @@ beforeAll(async () => {
writeFileSync(join(distDir, 'controller.html'), 'CONTROLLER_MARKER')
writeFileSync(join(distDir, 'app.js'), 'ASSET_MARKER')
const app = createApp(distDir)
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}`
@@ -28,6 +34,7 @@ beforeAll(async () => {
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) {
@@ -63,4 +70,30 @@ describe('Routing server (server.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')
})
})
})
+111
View File
@@ -164,6 +164,117 @@ describe('WebSocket Integration (websocket-handler.js)', () => {
})
})
// =============================================
// TIME OUT (timer server-side)
// =============================================
describe('Time out', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
function inviaStartTimeout(controller, overrides = {}) {
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'startTimeout', team: 'home', video: 'spot.mp4', ...overrides }
}))
}
it('dovrebbe iniettare startedAt lato server (ignorando quello del client)', () => {
vi.setSystemTime(50000)
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller, { startedAt: 12345 })
const state = handler.getState()
expect(state.timeout).toBe(true)
expect(state.timeoutStartedAt).toBe(50000)
})
it('dovrebbe chiudere automaticamente il time out dopo 30 secondi', () => {
const controller = connectAndRegister(wss, 'controller')
const display = connectAndRegister(wss, 'display')
inviaStartTimeout(controller)
expect(handler.getState().timeout).toBe(true)
vi.advanceTimersByTime(29999)
expect(handler.getState().timeout).toBe(true)
vi.advanceTimersByTime(1)
const state = handler.getState()
expect(state.timeout).toBe(false)
expect(state.timeoutTeam).toBe(null)
expect(state.timeoutVideo).toBe(null)
expect(state.timeoutStartedAt).toBe(null)
// La chiusura automatica viene broadcastata ai client
const msg = lastSent(display)
expect(msg.type).toBe('state')
expect(msg.state.timeout).toBe(false)
})
it('la chiusura automatica dovrebbe persistere lo stato (onStateChange)', () => {
const onStateChange = vi.fn()
wss = new MockWebSocketServer()
handler = setupWebSocketHandler(wss, { onStateChange })
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller)
onStateChange.mockClear()
vi.advanceTimersByTime(30000)
expect(onStateChange).toHaveBeenCalledTimes(1)
expect(onStateChange.mock.calls[0][0].timeout).toBe(false)
})
it('stopTimeout manuale dovrebbe annullare il timer pendente', () => {
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller)
controller.emit('message', JSON.stringify({
type: 'action',
action: { type: 'stopTimeout' }
}))
expect(handler.getState().timeout).toBe(false)
controller.send.mockClear()
// Allo scadere dei 30s non deve partire nessun broadcast fantasma
vi.advanceTimersByTime(30000)
expect(controller.send).not.toHaveBeenCalled()
})
it('un nuovo startTimeout dovrebbe riarmare il timer da capo', () => {
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller)
vi.advanceTimersByTime(20000)
// Riavvio: il timer riparte da zero
inviaStartTimeout(controller, { team: 'guest' })
vi.advanceTimersByTime(20000)
expect(handler.getState().timeout).toBe(true)
expect(handler.getState().timeoutTeam).toBe('guest')
vi.advanceTimersByTime(10000)
expect(handler.getState().timeout).toBe(false)
})
it('startTimeout con squadra non valida non dovrebbe armare il timer', () => {
const controller = connectAndRegister(wss, 'controller')
inviaStartTimeout(controller, { team: 'arbitro' })
expect(handler.getState().timeout).toBe(false)
controller.send.mockClear()
vi.advanceTimersByTime(30000)
expect(controller.send).not.toHaveBeenCalled()
})
})
// =============================================
// BROADCAST MULTI-CLIENT
// =============================================
+176
View File
@@ -79,6 +79,13 @@ describe('Game Logic (gameState.js)', () => {
expect(state.visuForm).toBe(false)
expect(state.visuStriscia).toBe(true)
})
it('dovrebbe avere timeout false e campi timeout nulli', () => {
expect(state.timeout).toBe(false)
expect(state.timeoutTeam).toBe(null)
expect(state.timeoutVideo).toBe(null)
expect(state.timeoutStartedAt).toBe(null)
})
})
// =============================================
@@ -358,6 +365,13 @@ describe('Game Logic (gameState.js)', () => {
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)
@@ -479,6 +493,98 @@ describe('Game Logic (gameState.js)', () => {
const s = applyAction(state, { type: 'toggleOrder' })
expect(s.order).toBe(false)
})
})
// =============================================
// TIME OUT (startTimeout, stopTimeout)
// =============================================
describe('Time out', () => {
it('startTimeout dovrebbe attivare il time out con squadra, video e inizio', () => {
const s = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4', startedAt: 1000 })
expect(s.timeout).toBe(true)
expect(s.timeoutTeam).toBe('home')
expect(s.timeoutVideo).toBe('spot.mp4')
expect(s.timeoutStartedAt).toBe(1000)
})
it('startTimeout senza video dovrebbe lasciare timeoutVideo null', () => {
const s = applyAction(state, { type: 'startTimeout', team: 'guest', startedAt: 1000 })
expect(s.timeout).toBe(true)
expect(s.timeoutTeam).toBe('guest')
expect(s.timeoutVideo).toBe(null)
})
it('startTimeout senza startedAt dovrebbe lasciare timeoutStartedAt null', () => {
const s = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4' })
expect(s.timeout).toBe(true)
expect(s.timeoutStartedAt).toBe(null)
})
it('startTimeout senza squadra valida dovrebbe essere ignorato', () => {
for (const team of [undefined, null, 'arbitro', '']) {
const s = applyAction(state, { type: 'startTimeout', team, video: 'spot.mp4', startedAt: 1000 })
expect(s.timeout).toBe(false)
expect(s.timeoutTeam).toBe(null)
expect(s.timeoutVideo).toBe(null)
expect(s.timeoutStartedAt).toBe(null)
}
})
it('stopTimeout dovrebbe azzerare tutti i campi del time out', () => {
const attivo = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4', startedAt: 1000 })
const s = applyAction(attivo, { type: 'stopTimeout' })
expect(s.timeout).toBe(false)
expect(s.timeoutTeam).toBe(null)
expect(s.timeoutVideo).toBe(null)
expect(s.timeoutStartedAt).toBe(null)
})
it('stopTimeout su time out già inattivo non dovrebbe avere effetti', () => {
const s = applyAction(state, { type: 'stopTimeout' })
expect(s.timeout).toBe(false)
expect(s.timeoutTeam).toBe(null)
})
it('startTimeout dovrebbe registrare l\'evento nel set corrente con il punteggio', () => {
let s = state
for (let i = 0; i < 3; i++) s = applyAction(s, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 1000 })
expect(s.sp.striscia.at(-1).timeouts).toEqual([
{ team: 'guest', punteggio: { home: 3, guest: 1 } },
])
})
it('startTimeout su time out già attivo non dovrebbe registrare un secondo evento', () => {
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
s = applyAction(s, { type: 'startTimeout', team: 'home', startedAt: 2000 })
expect(s.sp.striscia.at(-1).timeouts).toHaveLength(1)
})
it('time out successivi dovrebbero accumularsi nel set corrente', () => {
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
s = applyAction(s, { type: 'stopTimeout' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 2000 })
expect(s.sp.striscia.at(-1).timeouts).toEqual([
{ team: 'home', punteggio: { home: 0, guest: 0 } },
{ team: 'guest', punteggio: { home: 0, guest: 1 } },
])
})
it('un nuovo set non dovrebbe ereditare i time out del precedente', () => {
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
s = applyAction(s, { type: 'stopTimeout' })
s = applyAction(s, { type: 'nuovoSet', team: 'home' })
expect(s.sp.striscia.at(-1).timeouts).toBeUndefined()
expect(s.sp.striscia.at(-2).timeouts).toHaveLength(1)
})
it('startTimeout ignorato non dovrebbe registrare eventi', () => {
const s = applyAction(state, { type: 'startTimeout', team: 'arbitro', startedAt: 1000 })
expect(s.sp.striscia.at(-1).timeouts).toBeUndefined()
})
})
// =============================================
@@ -551,6 +657,12 @@ describe('Game Logic (gameState.js)', () => {
// CAMBI GIOCATORI (confermaCambi)
// =============================================
describe('confermaCambi', () => {
it('senza array cambi non dovrebbe modificare la formazione', () => {
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, { type: 'confermaCambi', team: 'home' })
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
})
it('dovrebbe effettuare una sostituzione valida', () => {
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, {
@@ -652,6 +764,64 @@ describe('Game Logic (gameState.js)', () => {
expect(s.sp.form.home).not.toContain("1")
expect(s.sp.form.home).not.toContain("10")
})
it('dovrebbe registrare il cambio nel set corrente con il punteggio', () => {
let s = state
for (let i = 0; i < 2; i++) s = applyAction(s, { type: 'incPunt', team: 'home' })
s = applyAction(s, { type: 'incPunt', team: 'guest' })
s = applyAction(s, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "10", out: "3" }]
})
expect(s.sp.striscia.at(-1).cambi).toEqual([
{ team: 'home', in: "10", out: "3", punteggio: { home: 2, guest: 1 } },
])
})
it('dovrebbe registrare ogni cambio di una doppia sostituzione', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'guest',
cambi: [
{ in: "10", out: "1" },
{ in: "11", out: "2" }
]
})
expect(s.sp.striscia.at(-1).cambi).toEqual([
{ team: 'guest', in: "10", out: "1", punteggio: { home: 0, guest: 0 } },
{ team: 'guest', in: "11", out: "2", punteggio: { home: 0, guest: 0 } },
])
})
it('non dovrebbe registrare nulla se la validazione fallisce', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "abc", out: "1" }]
})
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
})
it('non dovrebbe registrare nulla se tutti i cambi sono vuoti', () => {
const s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "", out: "" }]
})
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
})
it('un nuovo set non dovrebbe ereditare i cambi del precedente', () => {
let s = applyAction(state, {
type: 'confermaCambi',
team: 'home',
cambi: [{ in: "10", out: "3" }]
})
s = applyAction(s, { type: 'nuovoSet', team: 'home' })
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
expect(s.sp.striscia.at(-2).cambi).toHaveLength(1)
})
})
// =============================================
@@ -790,6 +960,12 @@ describe('Game Logic (gameState.js)', () => {
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' })
+45 -4
View File
@@ -21,10 +21,10 @@ describe('buildRefertoHtml (referto.js)', () => {
{ serv: 'h', ris: 'h'.repeat(25) + 'g'.repeat(18), vinc: 'h' },
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
// due set reali → "Set 1" e "Set 2", mai "Set 3"
expect(html).toContain('Set 1')
expect(html).toContain('Set 2')
expect(html).not.toContain('Set 3')
// 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', () => {
@@ -60,6 +60,15 @@ describe('buildRefertoHtml (referto.js)', () => {
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 },
@@ -97,6 +106,38 @@ describe('buildRefertoHtml (referto.js)', () => {
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)
+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([])
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { listSpotVideos } from '../../src/spot-utils.js'
describe('listSpotVideos (spot-utils.js)', () => {
let dir
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'spot-test-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('ritorna [] se la cartella non esiste', () => {
expect(listSpotVideos(join(dir, 'inesistente'))).toEqual([])
})
it('ritorna [] se la cartella è vuota', () => {
expect(listSpotVideos(dir)).toEqual([])
})
it('elenca solo i file .mp4, ordinati alfabeticamente', () => {
writeFileSync(join(dir, 'b.mp4'), '')
writeFileSync(join(dir, 'a.mp4'), '')
writeFileSync(join(dir, 'note.txt'), '')
writeFileSync(join(dir, 'foto.png'), '')
expect(listSpotVideos(dir)).toEqual(['a.mp4', 'b.mp4'])
})
it('riconosce l\'estensione .mp4 senza distinzione di maiuscole', () => {
writeFileSync(join(dir, 'SPOT.MP4'), '')
expect(listSpotVideos(dir)).toEqual(['SPOT.MP4'])
})
})
+16
View File
@@ -1,7 +1,13 @@
import { WebSocketServer } from 'ws'
import express from 'express'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js'
import { loadState, saveState } from './src/persist.js'
import { listSpotVideos } from './src/spot-utils.js'
const SPOT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'spot')
export default function websocketPlugin() {
return {
@@ -10,6 +16,16 @@ export default function websocketPlugin() {
const wss = new WebSocketServer({ noServer: true })
setupWebSocketHandler(wss, { initialState: loadState(), onStateChange: saveState })
// Video spot del time out: stesso comportamento di server.js (elenco + statici)
server.middlewares.use('/spot', (req, res, next) => {
if (req.url === '/list' || req.url === '/list/') {
res.setHeader('Content-Type', 'application/json')
return res.end(JSON.stringify(listSpotVideos(SPOT_DIR)))
}
next()
})
server.middlewares.use('/spot', express.static(SPOT_DIR))
// Rewrite /display → / (index.html) e /controller → /controller.html
server.middlewares.use((req, _res, next) => {
if (req.url === '/display' || req.url === '/display/') req.url = '/'