35 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
42 changed files with 2868 additions and 3628 deletions
+1
View File
@@ -15,6 +15,7 @@ dist
dist-ssr dist-ssr
dev-dist dev-dist
.segnapunti .segnapunti
spot
*.local *.local
# Editor directories and files # 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 ## [2.0.0] - 2026-05-12
### Aggiunto ### Aggiunto
+57 -37
View File
@@ -1,64 +1,72 @@
# CLAUDE.md # 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 ## 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 ## Comandi
```bash ```bash
npm run dev # Vite dev server — display: :5173/display, controller: :5173/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 serve # Build + avvio produzione — display: :3000/display, controller: :3000/controller
npm run test # Vitest in modalità watch npm run test # Vitest watch
npm run test:all # Tutte le suite Vitest in una sola esecuzione npm run test:all # Tutte le suite in una sola esecuzione
npm run test:unit # Solo unit + integration npm run test:unit # Solo unit + integration
npm run test:component # Test componenti Vue (happy-dom) npm run test:component # Componenti Vue (happy-dom)
npm run test:stress # Load test (50+ client concorrenti) npm run test:stress # Load test (50+ client concorrenti)
npm run test:e2e # Playwright E2E (richiede npm run serve attivo) npm run test:e2e # Playwright E2E (richiede `npm run serve` attivo)
npm run test:e2e:ui # Playwright con UI interattiva 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 ## Architettura
``` ```
Controller (Vue) ──WebSocket──┐ Controller (Vue) ──WebSocket──┐
Display (Vue) ──WebSocket──┤── websocket-handler.js ── gameState.js 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 funzioni pure esportate: **`src/gameState.js`** — logica di gioco, funzioni pure:
- `createInitialState()` restituisce lo stato iniziale - `createInitialState()` — stato iniziale
- `applyAction(state, action)` — reducer immutabile (deep-clone via `structuredClone`) - `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` — derivano punteggio corrente, set vinti e chi serve dalla `striscia` (lo stato NON memorizza questi valori direttamente) - `punteggio` / `setVinti` / `servizio` — derivati dalla `striscia`, NON memorizzati direttamente nello stato
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. 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.
`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. 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).
`src/wsMixin.js` è il mixin Vue lato client: gestisce connessione/riconnessione WebSocket (backoff esponenziale) ed espone i computed `punt`/`servHome`/`set`. **`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/referto.js` genera il referto di gara: `buildRefertoHtml(state, now)` è una funzione pura che restituisce l'HTML (testabile), mentre `generaReferto(state)` è il wrapper che apre la finestra con `window.open` e avvia la stampa. **`src/wsMixin.js`** — mixin Vue lato client: connessione/riconnessione WebSocket (backoff esponenziale), espone i computed `punt`/`servHome`/`set`.
`src/persist.js` carica/salva lo stato su `.segnapunti/state.json`. `src/server-utils.js` ricava gli IP di rete (`getNetworkIPs`, con sorgente iniettabile) e stampa gli URL all'avvio. **`src/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.
`server.js` (Express) espone `createApp(distDir)` (solo routing, testabile) e `startServer(port)` (HTTP + WebSocket); serve entrambe le interfacce sulla porta 3000: `/display``dist/index.html`, `/controller``dist/controller.html`. Singolo endpoint WebSocket su `/ws`. L'avvio automatico parte solo se il file è eseguito come entrypoint. **`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.
In sviluppo, `vite-plugin-websocket.js` incorpora il server WebSocket dentro il dev server Vite con middleware di URL rewrite per `/display` e `/controller`. **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 ## Vincoli di design
- **Tutta la logica sul server** — i client sono pura UI; il server è l'unica source of truth. - **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 possono inviare azioni. - **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. - **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. - **Un solo controller/display attivi** — nessuna 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. - **Stato persistente** — `src/persist.js` salva in `.segnapunti/state.json` dopo ogni azione e ricarica all'avvio.
## Layout dei test ## Layout dei test
@@ -70,22 +78,34 @@ In sviluppo, `vite-plugin-websocket.js` incorpora il server WebSocket dentro il
| Stress | `tests/stress/` | Vitest + Node | | Stress | `tests/stress/` | Vitest + Node |
| E2E | `tests/e2e/` | Playwright (Chromium, Firefox, Mobile Chrome) | | 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 ## 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 ```bash
docker compose up -d # Avvia con immagine dal registry privato (porta 3000) 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 ## 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. - Nuova funzionalità: valutare come si inserisce nel flusso `action → applyAction → broadcast`, poi decidere se serve un nuovo action type o estendere uno esistente.
- Qualsiasi modifica alle regole di gioco va fatta esclusivamente in `src/gameState.js`. - Regole di gioco → solo `src/gameState.js`. Protocollo WebSocket → solo `src/websocket-handler.js`.
- Qualsiasi modifica al protocollo WebSocket va fatta in `src/websocket-handler.js`. - Nuovo action type o regola di gioco → aggiungere test in `tests/unit/gameState.test.js`.
- Aggiungere test unit in `tests/unit/gameState.test.js` per ogni nuovo action type o regola di gioco. - Commenti e nomi di dominio in italiano (es. `servHome`, `striscia`, `nomi`); nomi tecnici standard in inglese (`state`, `action`, `handler`).
- 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`). - 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 # Stage 1: dipendenze condivise (build + dev)
FROM node:20-alpine AS builder FROM node:20-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci 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 . . COPY . .
RUN npm run build RUN npm run build
# Stage 2: runtime # Stage 4: runtime di produzione
FROM node:20-alpine FROM node:20-alpine AS runtime
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production PORT=3000 ENV NODE_ENV=production PORT=3000
@@ -15,7 +24,7 @@ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY server.js ./ 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 COPY --from=builder /app/dist ./dist
EXPOSE 3000 EXPOSE 3000
+116 -23
View File
@@ -1,6 +1,6 @@
# Segnapunti # 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) ![Node](https://img.shields.io/badge/node-%3E%3D18-green)
![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)
![License](https://img.shields.io/badge/licenza-privata-lightgrey) ![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à) - [Funzionalità](#funzionalità)
- [Deploy con Docker](#deploy-con-docker) - [Deploy con Docker](#deploy-con-docker)
- [Sviluppo](#sviluppo) - [Sviluppo](#sviluppo)
- [Sviluppo con Docker](#sviluppo-con-docker)
- [Test](#test) - [Test](#test)
--- ---
@@ -81,34 +82,88 @@ http://<IP-del-server>:3000/controller
## Funzionalità ## 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 ### Display (tabellone pubblico)
- 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)
### 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 - **Nomi squadre** con **indicatore di servizio** (l'icona della palla compare accanto alla squadra che sta servendo).
- **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 - **Punteggio del set corrente** in caratteri molto grandi, leggibili da lontano.
- **Formazioni** — configura i numeri di maglia; la rotazione avviene automaticamente al cambio palla - **Contatore dei set vinti** per ciascuna squadra.
- **Cambi** — dialog `IN → OUT` con validazione - **Striscia storica dei punti** del set in corso: una sequenza scorrevole che ricostruisce l'andamento punto su punto.
- **Servizio** — cambio manuale (disponibile solo a 0-0) - **Modalità formazioni**: in alternativa al punteggio, mostra le posizioni dei 6 giocatori in campo (numeri di maglia disposti come a referto).
- **Visualizzazione** — alterna tra punteggio grande e formazioni in campo - **Ordine di visualizzazione invertibile**: le due squadre possono essere scambiate di lato per rispecchiare l'orientamento reale del campo.
- **Striscia** — mostra/nasconde lo storico punti sul display - **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).
- **Reset** — azzera la partita (richiede conferma) - **Annuncio vocale del punteggio** (sintesi vocale del browser), comandato dal controller.
- **Referto** — a fine partita, dal dialog *PARTITA FINITA*, genera un referto di gara stampabile/PDF (punteggi per set, formazioni di partenza, andamento punto-punto). *Funzione prototipale.* - **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 ### 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 | Condizione di vittoria |
|---|---| |---|---|
| Set 14 (modalità 3/5) o 12 (modalità 2/3) | Primo a **25** con almeno 2 punti di scarto | | 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 | | 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*.
--- ---
@@ -120,7 +175,9 @@ http://<IP-del-server>:3000/controller
docker compose up -d 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 ### Aggiornamento a nuova versione
@@ -130,15 +187,24 @@ docker compose pull && docker compose up -d
### Build e pubblicazione immagine ### 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 ```bash
docker build \ 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 . -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 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 ## Sviluppo
@@ -174,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 ## Test
| Comando | Descrizione | | Comando | Descrizione |
@@ -186,8 +275,12 @@ Lo stato viene salvato in `.segnapunti/state.json` anche in modalità dev.
| `npm run test:e2e` | Playwright — Chromium, Firefox, Mobile Chrome | | `npm run test:e2e` | Playwright — Chromium, Firefox, Mobile Chrome |
Le suite Vitest coprono la logica di gioco (`gameState`, inclusa `formInizio`), Le suite Vitest coprono la logica di gioco (`gameState`, inclusa `formInizio`),
il protocollo e il routing del server, la persistenza, il client WebSocket il protocollo e il routing del server (incluso l'avvio effettivo di
(`wsMixin`), i componenti Vue e la generazione del referto. `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: > I test E2E richiedono il server in esecuzione (`npm run serve`) e i browser Playwright installati:
> ```bash > ```bash
-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: services:
segnapunti: segnapunti:
image: santantonio.sytes.net/attilio/segnapunti:2.0.0 image: santantonio.sytes.net/attilio/segnapunti:2.1.0
container_name: segnapunti container_name: segnapunti
ports: ports:
- "3000:3000" - "3000:3000"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "segnapuntianto", "name": "segnapuntianto",
"version": "0.0.0", "version": "2.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "segnapuntianto", "name": "segnapuntianto",
"version": "0.0.0", "version": "2.1.0",
"dependencies": { "dependencies": {
"express": "^5.2.1", "express": "^5.2.1",
"vue": "^3.2.47", "vue": "^3.2.47",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "segnapuntianto", "name": "segnapuntianto",
"private": true, "private": true,
"version": "2.0.0", "version": "2.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
+9 -1
View File
@@ -6,19 +6,27 @@ import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js' import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js' import { printServerInfo } from './src/server-utils.js'
import { loadState, saveState } from './src/persist.js' import { loadState, saveState } from './src/persist.js'
import { listSpotVideos } from './src/spot-utils.js'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename) const __dirname = dirname(__filename)
const DIST_DIR = join(__dirname, 'dist') const DIST_DIR = join(__dirname, 'dist')
const SPOT_DIR = join(__dirname, 'spot')
// Crea l'app Express (asset statici + route display/controller) senza avviare il // Crea l'app Express (asset statici + route display/controller) senza avviare il
// listen né il WebSocket: così il routing è testabile in isolamento. // 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() const app = express()
app.use(express.static(distDir, { index: false })) 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) => { app.get(['/', '/display', '/display/*splat'], (_req, res) => {
res.sendFile(join(distDir, 'index.html')) res.sendFile(join(distDir, 'index.html'))
}) })
+174 -1
View File
@@ -6,6 +6,11 @@
{{ wsConnected ? 'Connesso' : 'Connessione...' }} {{ wsConnected ? 'Connesso' : 'Connessione...' }}
</div> </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 --> <!-- MODALITÀ MOBILE -->
<template v-if="!isEstesa"> <template v-if="!isEstesa">
<!-- Anteprima punteggio --> <!-- Anteprima punteggio -->
@@ -64,6 +69,10 @@
<button class="btn btn-ctrl" @click="openCambiTeam()"> <button class="btn btn-ctrl" @click="openCambiTeam()">
Cambi Cambi
</button> </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"> <button class="btn btn-danger" @click="confirmReset = true">
Reset Reset
</button> </button>
@@ -135,13 +144,15 @@
<!-- Barra azioni secondarie --> <!-- Barra azioni secondarie -->
<div class="e-actions"> <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" :disabled="!isPunteggioZeroZero" @click="sendAction({ type: 'cambiaPalla' })">Cambio Palla</button>
<button class="btn e-act" @click="openCambiTeam()">Cambi</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: 'toggleOrder' })">Inverti</button>
<button class="btn e-act" @click="sendAction({ type: 'toggleStriscia' })">Striscia {{ state.visuStriscia ? 'ON' : 'OFF' }}</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="speak()">Voce</button>
<button class="btn e-act" @click="openConfig()">Config</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> <button class="btn e-act e-act--danger" @click="confirmReset = true">Reset</button>
</div> </div>
</div> </div>
@@ -270,6 +281,43 @@
</div> </div>
</div> </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> </section>
</template> </template>
@@ -297,6 +345,13 @@ export default {
formGuest: ["1", "2", "3", "4", "5", "6"], formGuest: ["1", "2", "3", "4", "5", "6"],
}, },
isLandscape: window.innerWidth > window.innerHeight, isLandscape: window.innerWidth > window.innerHeight,
showTimeoutModal: false,
timeoutTeamSel: null,
timeoutVideoSel: null,
spotList: [],
spotDurations: {},
nowTick: Date.now(),
timeoutTicker: null,
} }
}, },
computed: { computed: {
@@ -331,6 +386,17 @@ export default {
} }
return hasComplete 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: { watch: {
squadraVincente(val) { squadraVincente(val) {
@@ -339,6 +405,12 @@ export default {
this.showSetVinto = true this.showSetVinto = true
} }
}, },
'state.timeout': {
immediate: true,
handler(attivo) {
attivo ? this.avviaTimeoutTicker() : this.fermaTimeoutTicker()
},
},
}, },
mounted() { mounted() {
this._resizeHandler = () => { this.isLandscape = window.innerWidth > window.innerHeight } this._resizeHandler = () => { this.isLandscape = window.innerWidth > window.innerHeight }
@@ -348,6 +420,7 @@ export default {
beforeUnmount() { beforeUnmount() {
window.removeEventListener('resize', this._resizeHandler) window.removeEventListener('resize', this._resizeHandler)
window.removeEventListener('orientationchange', this._resizeHandler) window.removeEventListener('orientationchange', this._resizeHandler)
this.fermaTimeoutTicker()
}, },
methods: { methods: {
generaReferto, generaReferto,
@@ -451,6 +524,47 @@ export default {
: this.servHome ? `${home} a ${guest}` : `${guest} a ${home}` : this.servHome ? `${home} a ${guest}` : `${guest} a ${home}`
this.sendWs({ type: 'speak', text }) 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> </script>
@@ -489,6 +603,32 @@ export default {
.conn-bar.connected { .conn-bar.connected {
background: #2e7d32; 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 { .dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -1037,4 +1177,37 @@ export default {
padding: 8px 0; padding: 8px 0;
font-weight: 600; 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> </style>
+150 -4
View File
@@ -112,6 +112,16 @@
{{ wsConnected ? '' : 'Disconnesso' }} {{ wsConnected ? '' : 'Disconnesso' }}
</div> </div>
</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> </section>
</template> </template>
@@ -121,12 +131,40 @@ import { createWsMixin } from '../wsMixin.js'
export default { export default {
name: "DisplayPage", name: "DisplayPage",
mixins: [createWsMixin('display')], 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() { mounted() {
if (this.isMobile()) { if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {} try { document.documentElement.requestFullscreen() } catch (e) {}
} }
if (this.state.timeout) this.avviaTimeout()
},
beforeUnmount() {
this.fermaTimeoutTicker()
}, },
computed: { 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() { stricciaStrip() {
const currentSet = this.state.sp.striscia.at(-1) const currentSet = this.state.sp.striscia.at(-1)
if (!currentSet) return { home: [], guest: [] } if (!currentSet) return { home: [], guest: [] }
@@ -149,8 +187,60 @@ export default {
}) })
} }
}, },
'state.timeout': {
handler(attivo) {
attivo ? this.avviaTimeout() : this.fermaTimeout()
},
},
}, },
methods: { 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) { onWsMessage(msg) {
if (msg.type === 'speak') this.speakOnDisplay(msg.text) if (msg.type === 'speak') this.speakOnDisplay(msg.text)
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message) else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
@@ -177,7 +267,8 @@ export default {
.display-page { .display-page {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
background: #000; background: #111;
font-family: 'Inter', system-ui, sans-serif;
overflow: hidden; overflow: hidden;
} }
@@ -200,7 +291,7 @@ export default {
} }
.connection-status.disconnected { .connection-status.disconnected {
background: rgba(255, 50, 50, 0.8); background: #c62828;
color: white; color: white;
opacity: 1; opacity: 1;
} }
@@ -213,11 +304,11 @@ export default {
} }
.connected .dot { .connected .dot {
background: #4caf50; background: #2e7d32;
} }
.disconnected .dot { .disconnected .dot {
background: #f44336; background: #ff6b6b;
animation: blink 1s infinite; animation: blink 1s infinite;
} }
@@ -237,6 +328,8 @@ export default {
.punt { .punt {
font-size: 60vh; font-size: 60vh;
font-weight: 900;
font-variant-numeric: tabular-nums;
flex: 1; flex: 1;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -247,4 +340,57 @@ export default {
overflow: hidden; overflow: hidden;
box-sizing: border-box; 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> </style>
+38
View File
@@ -21,6 +21,10 @@ export function createInitialState() {
order: true, order: true,
visuForm: false, visuForm: false,
visuStriscia: true, visuStriscia: true,
timeout: false,
timeoutTeam: null,
timeoutVideo: null,
timeoutStartedAt: null,
modalitaPartita: "3/5", modalitaPartita: "3/5",
sp: { sp: {
striscia: [{ serv: 'h', ris: '', vinc: null }], striscia: [{ serv: 'h', ris: '', vinc: null }],
@@ -164,6 +168,31 @@ export function applyAction(state, action) {
break 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": { case "setNomi": {
if (action.home !== undefined) s.sp.nomi.home = action.home if (action.home !== undefined) s.sp.nomi.home = action.home
if (action.guest !== undefined) s.sp.nomi.guest = action.guest if (action.guest !== undefined) s.sp.nomi.guest = action.guest
@@ -187,6 +216,7 @@ export function applyAction(state, action) {
const cambi = action.cambi || [] const cambi = action.cambi || []
const form = s.sp.form[team].map((val) => String(val).trim()) const form = s.sp.form[team].map((val) => String(val).trim())
const formAggiornata = [...form] const formAggiornata = [...form]
const cambiApplicati = []
let valid = true let valid = true
for (const cambio of cambi) { for (const cambio of cambi) {
@@ -201,11 +231,19 @@ export function applyAction(state, action) {
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout) const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
if (idx !== -1) { if (idx !== -1) {
formAggiornata.splice(idx, 1, cin) formAggiornata.splice(idx, 1, cin)
cambiApplicati.push({ in: cin, out: cout })
} }
} }
if (valid) { if (valid) {
s.sp.form[team] = formAggiornata 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 break
} }
+119 -37
View File
@@ -44,6 +44,31 @@ export function buildRefertoHtml(state, now = new Date()) {
</div>` </div>`
} }
const nomeTeam = (team) => team === 'home' ? nomi.home : nomi.guest
const eventiHtml = (s) => {
const eventi = [
...(s.timeouts ?? []).map(t => ({ tipo: 'timeout', ...t })),
...(s.cambi ?? []).map(c => ({ tipo: 'cambio', ...c })),
].sort((a, b) => (a.punteggio.home + a.punteggio.guest) - (b.punteggio.home + b.punteggio.guest))
if (eventi.length === 0) return ''
const righe = eventi.map(e => {
const sul = `sul ${e.punteggio.home}-${e.punteggio.guest}`
const testo = e.tipo === 'timeout'
? `Time out <strong>${nomeTeam(e.team)}</strong> ${sul}`
: `Cambio <strong>${nomeTeam(e.team)}</strong>: entra ${e.in} per ${e.out} ${sul}`
return `<li class="evento evento-${e.tipo}">${testo}</li>`
}).join('')
return `
<div class="eventi-set">
<div class="eventi-label">Cambi e time out</div>
<ul class="eventi-lista">${righe}</ul>
</div>`
}
const setsHtml = setReali.map((s, i) => { const setsHtml = setReali.map((s, i) => {
let h = 0, g = 0 let h = 0, g = 0
const punti = [] const punti = []
@@ -62,17 +87,31 @@ export function buildRefertoHtml(state, now = new Date()) {
return ` return `
<div class="set-section"> <div class="set-section">
<div class="set-header"> <div class="set-barra"><span>SET</span><span class="set-numero">${i + 1}</span></div>
Set ${i + 1} &nbsp;—&nbsp; ${nomi.home} <strong>${h}</strong> · <strong>${g}</strong> ${nomi.guest}${etichettaVinc} <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>
<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>` </div>`
}).join('') }).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> const html = `<!DOCTYPE html>
<html lang="it"> <html lang="it">
<head> <head>
@@ -80,50 +119,93 @@ export function buildRefertoHtml(state, now = new Date()) {
<title>Referto — ${nomi.home} vs ${nomi.guest}</title> <title>Referto — ${nomi.home} vs ${nomi.guest}</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; background: #fff; padding: 28px; font-size: 14px; } :root { --teal: #23b8dc; --teal-dark: #0f8faf; }
@media print { body { padding: 12px; } } 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; } .foglio { border: 3px solid #111; padding: 14px; }
.titolo { font-size: 18px; font-weight: bold; letter-spacing: 3px; text-transform: uppercase; }
.meta { font-size: 12px; color: #666; margin-top: 5px; }
.squadre-row { display: flex; justify-content: space-around; align-items: center; margin: 18px 0 6px; } .header { display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 2px solid #111; padding: 10px 16px; margin-bottom: 14px; }
.nome-sq { font-size: 22px; font-weight: bold; text-align: center; max-width: 40%; } .titolo { font-size: 15px; font-weight: bold; letter-spacing: 2px; text-transform: uppercase; }
.vs { font-size: 16px; color: #999; } .meta { font-size: 11px; color: #333; margin-top: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.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; }
.set-section { margin-bottom: 12px; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; } .squadre-row { display: flex; align-items: center; gap: 14px; }
.set-header { background: #f5f5f5; padding: 7px 12px; font-size: 13px; border-bottom: 1px solid #ddd; } .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; }
.punti-grid { display: flex; flex-wrap: wrap; gap: 3px; padding: 8px 10px; } .nome-sq { font-size: 18px; font-weight: bold; }
.punto { display: inline-block; padding: 2px 5px; border-radius: 3px; font-size: 11px; font-family: 'Courier New', monospace; white-space: nowrap; } .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-h { background: #d0e8ff; color: #003a6e; }
.punto-g { background: #ffddc0; color: #6e2700; } .punto-g { background: #ffddc0; color: #6e2700; }
.form-inizio { padding: 8px 10px; border-bottom: 1px solid #eee; background: #fafafa; } .eventi-set { padding: 6px 12px; border-bottom: 1px solid #111; }
.form-inizio-label { font-size: 11px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #888; margin-bottom: 7px; } .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-row { display: flex; gap: 40px; }
.form-team { flex: 1; } .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; } .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> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="foglio">
<div class="titolo">Referto di Gara</div> <div class="header">
<div class="meta">${dataOra} &nbsp;·&nbsp; Modalità: ${modalitaPartita}</div> <div>
</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"> ${setsHtml}
<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} <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> </body>
</html>` </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%; width: 100%;
display: table; display: table;
color: #fff; 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 { .hea {
float: left; float: left;
width: 50%; width: 50%;
font-size: xx-large; 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 { .hea span {
/* Bordo di debug: border: 1px solid #f3fb00; */ /* Bordo di debug: border: 1px solid #f3fb00; */
@@ -70,6 +90,27 @@ button:focus-visible {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
vertical-align: middle; 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 { .tal {
text-align: left; text-align: left;
@@ -110,10 +151,11 @@ button:focus-visible {
max-width: 50vw; max-width: 50vw;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
text-shadow: 0 0 40px currentColor;
} }
.form { .form {
font-size: 5vh; font-size: 5vh;
border-top: #fff dashed 25px; border-top: 2px solid rgba(255, 255, 255, 0.18);
padding-top: 50px; padding-top: 50px;
} }
.formtit { .formtit {
@@ -125,14 +167,23 @@ button:focus-visible {
font-size: 20vh; font-size: 20vh;
float: left; float: left;
width: 32%; 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 { .home {
background-color: black; background-color: transparent;
color: yellow; color: #f5c518;
} }
.guest { .guest {
background-color: blue; background-color: transparent;
color: white color: #2196f3;
} }
.striscia { .striscia {
position: fixed; position: fixed;
@@ -141,17 +192,28 @@ button:focus-visible {
right: 10px; right: 10px;
display: grid; display: grid;
grid-template-columns: max-content 1fr; grid-template-columns: max-content 1fr;
row-gap: 2px; row-gap: 6px;
align-items: center; 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 { .striscia-nome {
white-space: nowrap; white-space: nowrap;
padding-right: 6px; padding-right: 10px;
font-size: 2.2vh;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.striscia-items { .striscia-items {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
overflow: hidden; overflow: hidden;
gap: 2px;
} }
.striscia .item { .striscia .item {
width: 25px; width: 25px;
@@ -159,11 +221,17 @@ button:focus-visible {
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
flex-shrink: 0; flex-shrink: 0;
border-radius: 5px; border-radius: 6px;
} }
.striscia .item:not(.item-vuoto) { .striscia .item:not(.item-vuoto) {
background-color: rgb(206, 247, 3); background-color: #f5c518;
color: blue; 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 { .campo-config {
+34 -1
View File
@@ -5,6 +5,8 @@ import { createInitialState, applyAction } from './gameState.js'
* @param {WebSocketServer} wss - Istanza del server WebSocket. * @param {WebSocketServer} wss - Istanza del server WebSocket.
* @returns {Object} Oggetto con metodi di gestione dello stato. * @returns {Object} Oggetto con metodi di gestione dello stato.
*/ */
const DURATA_TIMEOUT_MS = 30000
export function setupWebSocketHandler(wss, options = {}) { export function setupWebSocketHandler(wss, options = {}) {
// Stato globale della partita. // Stato globale della partita.
let gameState = options.initialState ?? createInitialState() let gameState = options.initialState ?? createInitialState()
@@ -12,6 +14,17 @@ export function setupWebSocketHandler(wss, options = {}) {
// Mappa dei ruoli associati ai client connessi. // Mappa dei ruoli associati ai client connessi.
const clients = new Map() // ws -> { role: 'display' | 'controller' } 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 * Gestisce i messaggi in arrivo dal client
*/ */
@@ -87,10 +100,16 @@ export function setupWebSocketHandler(wss, options = {}) {
return 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. // Applica l'azione allo stato della partita.
const previousState = gameState const previousState = gameState
try { try {
gameState = applyAction(gameState, msg.action) gameState = applyAction(gameState, action)
} catch (err) { } catch (err) {
console.error('Error applying action:', err) console.error('Error applying action:', err)
sendError(ws, 'Failed to apply action') sendError(ws, 'Failed to apply action')
@@ -98,6 +117,20 @@ export function setupWebSocketHandler(wss, options = {}) {
return 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. // Propaga il nuovo stato a tutti i client connessi.
broadcastState() broadcastState()
options.onStateChange?.(gameState) options.onStateChange?.(gameState)
+35 -8
View File
@@ -91,9 +91,13 @@ Esempi reali nel progetto:
- vittoria set con regola dei 2 punti di scarto - vittoria set con regola dei 2 punti di scarto
- reset stato - reset stato
- `formInizio` (snapshot formazione di partenza del set) - `formInizio` (snapshot formazione di partenza del set)
- time out (`startTimeout`/`stopTimeout`: squadra, video, timestamp)
- generazione del referto (`buildRefertoHtml`) - generazione del referto (`buildRefertoHtml`)
- persistenza stato su disco (`persist.js`, con `fs` mockato) - persistenza stato su disco (`persist.js`, con `fs` mockato)
- utility di rete (`server-utils.js`) - 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: Quando falliscono:
- quasi sempre c'e un problema nella logica core - quasi sempre c'e un problema nella logica core
@@ -115,7 +119,10 @@ Esempi reali nel progetto:
- broadcast stato ai client - broadcast stato ai client
- validazione input malformati - validazione input malformati
- autorizzazioni (solo controller puo inviare certe azioni) - autorizzazioni (solo controller puo inviare certe azioni)
- routing del server Express (`createApp`: rotte display/controller/asset) - 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: Quando falliscono:
- spesso c'e problema nel protocollo messaggi o nei controlli di ruolo - spesso c'e problema nel protocollo messaggi o nei controlli di ruolo
@@ -133,8 +140,11 @@ Esempi reali nel progetto:
- stato connessione - stato connessione
- click bottoni controller - click bottoni controller
- dialog reset/config/cambi - dialog reset/config/cambi
- bottone REFERTO nel dialog PARTITA FINITA - modal time out del controller (scelta squadra/video, banner, countdown)
- client WebSocket (`wsMixin`: connessione, riconnessione, `sendWs`) - 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: Quando falliscono:
- spesso hai rotto template, computed, metodi o binding - spesso hai rotto template, computed, metodi o binding
@@ -168,6 +178,7 @@ File principali:
- `game-operations.spec.cjs`: reset, config, toggle, cambi - `game-operations.spec.cjs`: reset, config, toggle, cambi
- `game-simulation.spec.cjs`: simulazione partita - `game-simulation.spec.cjs`: simulazione partita
- `full-match.spec.cjs`: scenari partita completi - `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 - `referto.spec.cjs`: generazione referto a PARTITA FINITA
- `accessibility.spec.cjs`: controlli accessibilita con axe - `accessibility.spec.cjs`: controlli accessibilita con axe
- `visual-regression.spec.cjs`: confronto screenshot con baseline - `visual-regression.spec.cjs`: confronto screenshot con baseline
@@ -184,8 +195,8 @@ Note importanti:
Caso OK (verde): Caso OK (verde):
```text ```text
Test Files 10 passed (10) Test Files 15 passed (15)
Tests 212 passed (212) Tests 362 passed (362)
``` ```
Significa: Significa:
@@ -202,7 +213,7 @@ Caso KO (rosso):
Caso OK: Caso OK:
```text ```text
75 passed 81 passed
``` ```
Caso KO: Caso KO:
@@ -273,7 +284,23 @@ npm run test:e2e -- --update-snapshots
npm run test:e2e 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: - metti i test nel posto giusto:
- `tests/unit` per logica pura - `tests/unit` per logica pura
+687 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 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 ControllerPage from '../../src/components/ControllerPage.vue'
import { generaReferto } from '../../src/referto.js' import { generaReferto } from '../../src/referto.js'
@@ -29,6 +29,10 @@ class MockWebSocket {
vi.stubGlobal('WebSocket', 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" // Forza l'orientamento portrait → il controller usa il layout "mobile"
// (con .team-pts, .btn-ctrl, ecc.) su cui questi test fanno asserzioni. // (con .team-pts, .btn-ctrl, ecc.) su cui questi test fanno asserzioni.
Object.defineProperty(window, 'innerWidth', { value: 400, writable: true, configurable: true }) Object.defineProperty(window, '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')) const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla'))
expect(btn.attributes('disabled')).toBeDefined() 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 // BARRA CONNESSIONE
// ============================================= // =============================================
+341
View File
@@ -31,6 +31,21 @@ vi.stubGlobal('speechSynthesis', {
getVoices: () => [] 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() { function mountDisplay() {
return mount(DisplayPage, { return mount(DisplayPage, {
global: { 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 // 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 }) ws.onclose({ code: 1006 })
expect(spy).toHaveBeenCalled() 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', () => { describe('sendWs', () => {
@@ -161,6 +204,17 @@ describe('createWsMixin (wsMixin.js)', () => {
const inviato = JSON.parse(ws.send.mock.calls.at(-1)[0]) const inviato = JSON.parse(ws.send.mock.calls.at(-1)[0])
expect(inviato.type).toBe('action') 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', () => { describe('cleanup', () => {
@@ -170,5 +224,102 @@ describe('createWsMixin (wsMixin.js)', () => {
wrapper.unmount() wrapper.unmount()
expect(ws.close).toHaveBeenCalled() 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()
})
}) })
}) })
+1 -2
View File
@@ -40,8 +40,7 @@ test.describe('Referto di fine partita', () => {
// Verifica il contenuto del referto // Verifica il contenuto del referto
const body = popup.locator('body'); const body = popup.locator('body');
await expect(body).toContainText('Referto di Gara'); await expect(body).toContainText('Referto di Gara');
await expect(body).toContainText('Set 1'); await expect(popup.locator('.set-numero')).toHaveText(['1', '2']);
await expect(body).toContainText('Set 2');
// Home ha vinto 2 set a 0 // Home ha vinto 2 set a 0
await expect(body).toContainText('2 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();
});
});
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: 19 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: 58 KiB

After

Width:  |  Height:  |  Size: 197 KiB

+36 -3
View File
@@ -4,11 +4,12 @@ import { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
import { createApp } from '../../server.js' import { createApp } from '../../server.js'
// Usiamo una dist temporanea con file marcatori, così il test non dipende da // Usiamo una dist e una cartella spot temporanee con file marcatori, così il
// una build reale ed è deterministico. // test non dipende da una build reale ed è deterministico.
let server let server
let baseUrl let baseUrl
let distDir let distDir
let spotDir
beforeAll(async () => { beforeAll(async () => {
distDir = mkdtempSync(join(tmpdir(), 'segnapunti-dist-')) distDir = mkdtempSync(join(tmpdir(), 'segnapunti-dist-'))
@@ -16,7 +17,12 @@ beforeAll(async () => {
writeFileSync(join(distDir, 'controller.html'), 'CONTROLLER_MARKER') writeFileSync(join(distDir, 'controller.html'), 'CONTROLLER_MARKER')
writeFileSync(join(distDir, 'app.js'), 'ASSET_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) => { await new Promise((resolve) => {
server = app.listen(0, () => { server = app.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}` baseUrl = `http://127.0.0.1:${server.address().port}`
@@ -28,6 +34,7 @@ beforeAll(async () => {
afterAll(async () => { afterAll(async () => {
await new Promise((resolve) => server.close(resolve)) await new Promise((resolve) => server.close(resolve))
rmSync(distDir, { recursive: true, force: true }) rmSync(distDir, { recursive: true, force: true })
rmSync(spotDir, { recursive: true, force: true })
}) })
async function get(path) { async function get(path) {
@@ -63,4 +70,30 @@ describe('Routing server (server.js)', () => {
expect(status).toBe(200) expect(status).toBe(200)
expect(body).toBe('ASSET_MARKER') 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 // BROADCAST MULTI-CLIENT
// ============================================= // =============================================
+176
View File
@@ -79,6 +79,13 @@ describe('Game Logic (gameState.js)', () => {
expect(state.visuForm).toBe(false) expect(state.visuForm).toBe(false)
expect(state.visuStriscia).toBe(true) 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) 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', () => { it('in 2/3 alla palla match registra il set vincente senza aprirne uno nuovo', () => {
state.modalitaPartita = '2/3' state.modalitaPartita = '2/3'
setSetVinti(state, 1, 0) setSetVinti(state, 1, 0)
@@ -479,6 +493,98 @@ describe('Game Logic (gameState.js)', () => {
const s = applyAction(state, { type: 'toggleOrder' }) const s = applyAction(state, { type: 'toggleOrder' })
expect(s.order).toBe(false) 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) // CAMBI GIOCATORI (confermaCambi)
// ============================================= // =============================================
describe('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', () => { it('dovrebbe effettuare una sostituzione valida', () => {
state.sp.form.home = ["1", "2", "3", "4", "5", "6"] state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
const s = applyAction(state, { 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("1")
expect(s.sp.form.home).not.toContain("10") 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('') 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', () => { it('dovrebbe impostare visuForm a false', () => {
state.visuForm = true state.visuForm = true
const s = applyAction(state, { type: 'resetta' }) 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' }, { serv: 'h', ris: 'h'.repeat(25) + 'g'.repeat(18), vinc: 'h' },
] ]
const html = buildRefertoHtml(statoConSet(striscia), NOW) const html = buildRefertoHtml(statoConSet(striscia), NOW)
// due set reali → "Set 1" e "Set 2", mai "Set 3" // due set reali → numeri "1" e "2" nella barra SET, mai "3"
expect(html).toContain('Set 1') expect(html).toContain('<span class="set-numero">1</span>')
expect(html).toContain('Set 2') expect(html).toContain('<span class="set-numero">2</span>')
expect(html).not.toContain('Set 3') expect(html).not.toContain('<span class="set-numero">3</span>')
}) })
it('calcola il punteggio finale di ogni set dalla ris', () => { it('calcola il punteggio finale di ogni set dalla ris', () => {
@@ -60,6 +60,15 @@ describe('buildRefertoHtml (referto.js)', () => {
expect(html).toContain('1 0') expect(html).toContain('1 0')
}) })
it('ricava il vincitore guest dal conteggio punti se vinc è nullo', () => {
const striscia = [
{ serv: 'h', ris: 'g'.repeat(25) + 'h'.repeat(20), vinc: null },
]
const html = buildRefertoHtml(statoConSet(striscia), NOW)
// conteggio punti guest > home, pur con vinc null → 0 1
expect(html).toContain('0 1')
})
it('include la progressione punto-punto con classi per squadra', () => { it('include la progressione punto-punto con classi per squadra', () => {
const striscia = [ const striscia = [
{ serv: 'h', ris: 'hhg', vinc: null }, { serv: 'h', ris: 'hhg', vinc: null },
@@ -97,6 +106,38 @@ describe('buildRefertoHtml (referto.js)', () => {
expect(html).toContain('Nessun punto registrato') 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', () => { it('header contiene nomi squadre, modalità e data iniettata', () => {
const striscia = [{ serv: 'h', ris: '', vinc: null }] const striscia = [{ serv: 'h', ris: '', vinc: null }]
const state = statoConSet(striscia) 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 { WebSocketServer } from 'ws'
import express from 'express'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js' import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js' import { printServerInfo } from './src/server-utils.js'
import { loadState, saveState } from './src/persist.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() { export default function websocketPlugin() {
return { return {
@@ -10,6 +16,16 @@ export default function websocketPlugin() {
const wss = new WebSocketServer({ noServer: true }) const wss = new WebSocketServer({ noServer: true })
setupWebSocketHandler(wss, { initialState: loadState(), onStateChange: saveState }) 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 // Rewrite /display → / (index.html) e /controller → /controller.html
server.middlewares.use((req, _res, next) => { server.middlewares.use((req, _res, next) => {
if (req.url === '/display' || req.url === '/display/') req.url = '/' if (req.url === '/display' || req.url === '/display/') req.url = '/'