Compare commits

8 Commits

Author SHA1 Message Date
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
15 changed files with 326 additions and 15 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ Punteggio, set e servizio si ricavano sempre dalla `striscia`: al primo punto di
`src/persist.js` carica/salva lo stato su `.segnapunti/state.json`. `src/server-utils.js` ricava gli IP di rete (`getNetworkIPs`, con sorgente iniettabile) e stampa gli URL all'avvio. `src/spot-utils.js` espone `listSpotVideos(spotDir)`, che elenca i `.mp4` (ordinati) della cartella `spot/`; ritorna `[]` se la cartella manca. `src/persist.js` carica/salva lo stato su `.segnapunti/state.json`. `src/server-utils.js` ricava gli IP di rete (`getNetworkIPs`, con sorgente iniettabile) e stampa gli URL all'avvio. `src/spot-utils.js` espone `listSpotVideos(spotDir)`, che elenca i `.mp4` (ordinati) della cartella `spot/`; ritorna `[]` se la cartella manca.
**Time out / spot video** — l'action `toggleTimeout` commuta il flag booleano `state.timeout`. Quando attivo, il display mostra a tutto schermo i video `.mp4` della cartella `spot/` nella root (non versionata): uno solo va in loop, più video girano in sequenza ripartendo dal primo; se la cartella è vuota mostra un overlay "TIME OUT". La lista è esposta dall'endpoint HTTP `/spot/list` (JSON) e i file sono serviti staticamente da `/spot/<file>.mp4`, sia in produzione (`server.js`) sia in sviluppo (`vite-plugin-websocket.js`). I video partono con audio; se il browser blocca l'autoplay con suono si ripiega automaticamente su muto — per avere l'audio reale il browser del display (schermo senza input) va avviato in kiosk con autoplay consentito (es. Chrome `--autoplay-policy=no-user-gesture-required`). **Time out / spot video** — le action `startTimeout` (richiede `team`, `video` opzionale) e `stopTimeout` impostano/azzerano `state.timeout`, `timeoutTeam`, `timeoutVideo` e `timeoutStartedAt`. Il timestamp `startedAt` lo aggiunge `websocket-handler.js` (non il client), così `gameState.js` resta puro e deterministico; lo stesso handler arma un timer server-side di 30 secondi che invia automaticamente `stopTimeout` (sopravvive a refresh o disconnessione del controller). Sul controller il pulsante Time Out apre una modal per scegliere la squadra e uno dei video `.mp4` della cartella `spot/` nella root (non versionata), oppure nessun video; durante il time out il pulsante mostra il countdown e ripremerlo chiude in anticipo. Il display copre il tabellone con un overlay a tutto schermo con countdown: riproduce il video scelto (riallineandosi al punto giusto in caso di riconnessione a metà time out) e ripiega sulla scritta "TIME OUT <squadra>" se non c'è video o se il video finisce prima dei 30 secondi, con dissolvenza nell'ultimo secondo. La lista è esposta dall'endpoint HTTP `/spot/list` (JSON) e i file sono serviti staticamente da `/spot/<file>.mp4`, sia in produzione (`server.js`) sia in sviluppo (`vite-plugin-websocket.js`). I video partono con audio; se il browser blocca l'autoplay con suono si ripiega automaticamente su muto — per avere l'audio reale il browser del display (schermo senza input) va avviato in kiosk con autoplay consentito (es. Chrome `--autoplay-policy=no-user-gesture-required`).
`server.js` (Express) espone `createApp(distDir)` (solo routing, testabile) e `startServer(port)` (HTTP + WebSocket); serve entrambe le interfacce sulla porta 3000: `/display``dist/index.html`, `/controller``dist/controller.html`. Singolo endpoint WebSocket su `/ws`. L'avvio automatico parte solo se il file è eseguito come entrypoint. `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.
+1 -1
View File
@@ -15,7 +15,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
+9 -7
View File
@@ -93,7 +93,7 @@ Pagina di sola visualizzazione, pensata per uno schermo grande o un proiettore.
- **Striscia storica dei punti** del set in corso: una sequenza scorrevole che ricostruisce l'andamento punto su punto. - **Striscia storica dei punti** del set in corso: una sequenza scorrevole che ricostruisce l'andamento punto su punto.
- **Modalità formazioni**: in alternativa al punteggio, mostra le posizioni dei 6 giocatori in campo (numeri di maglia disposti come a referto). - **Modalità formazioni**: in alternativa al punteggio, mostra le posizioni dei 6 giocatori in campo (numeri di maglia disposti come a referto).
- **Ordine di visualizzazione invertibile**: le due squadre possono essere scambiate di lato per rispecchiare l'orientamento reale del campo. - **Ordine di visualizzazione invertibile**: le due squadre possono essere scambiate di lato per rispecchiare l'orientamento reale del campo.
- **Overlay time out / spot**: durante un time out copre il tabellone con i video della cartella `spot/` (vedi sotto). - **Overlay time out / spot**: durante un time out copre il tabellone con un countdown e il video scelto dall'operatore, o con la scritta *TIME OUT* (vedi sotto).
- **Annuncio vocale del punteggio** (sintesi vocale del browser), comandato dal controller. - **Annuncio vocale del punteggio** (sintesi vocale del browser), comandato dal controller.
- **Indicatore di connessione**: scompare quando tutto è connesso, diventa un punto rosso lampeggiante con scritta *Disconnesso* in caso di problemi. - **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. - **Fullscreen automatico** all'apertura sui dispositivi mobili.
@@ -137,13 +137,13 @@ Il pulsante **Cambi** apre, dopo aver scelto la squadra, un dialog con righe `IN
#### Time Out e spot pubblicitari #### Time Out e spot pubblicitari
Il pulsante **Time Out** attiva/disattiva la proiezione degli spot sul display. Quando la modalità è attiva il controller resta **pienamente operativo** ma è chiaramente evidenziato: compare un banner *MODALITÀ TIME OUT ATTIVA* e il pulsante diventa arancione e pulsante. Ripremendolo si esce e il display torna al punteggio. 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:
I video da proiettare vanno messi in una cartella **`spot/`** nella root del progetto (non versionata). Comportamento: - 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.
- **un solo** video `.mp4` → riprodotto in loop continuo; 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.
- **più** video `.mp4` → riprodotti in sequenza in ordine alfabetico, poi si riparte dal primo;
- **cartella vuota o assente** → il display mostra un overlay nero con la scritta *TIME OUT*.
> **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). > **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).
@@ -174,7 +174,9 @@ La **partita** è vinta da chi raggiunge 3 set su 5 (modalità `3/5`) o 2 set su
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
+8 -2
View File
@@ -91,9 +91,11 @@ 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`)
- 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 +117,8 @@ 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/`)
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 +136,10 @@ Esempi reali nel progetto:
- stato connessione - stato connessione
- click bottoni controller - click bottoni controller
- dialog reset/config/cambi - dialog reset/config/cambi
- modal time out del controller (scelta squadra/video, banner, countdown)
- overlay time out del display (video, placeholder, countdown, dissolvenza)
- bottone REFERTO nel dialog PARTITA FINITA - bottone REFERTO nel dialog PARTITA FINITA
- client WebSocket (`wsMixin`: connessione, riconnessione, `sendWs`) - client WebSocket (`wsMixin`: connessione, riconnessione, `sendWs`, errori)
Quando falliscono: Quando falliscono:
- spesso hai rotto template, computed, metodi o binding - spesso hai rotto template, computed, metodi o binding
@@ -168,6 +173,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
+79 -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 })
@@ -287,6 +291,80 @@ 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('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')
})
})
// ============================================= // =============================================
// BARRA CONNESSIONE // BARRA CONNESSIONE
// ============================================= // =============================================
+82
View File
@@ -31,6 +31,12 @@ vi.stubGlobal('speechSynthesis', {
getVoices: () => [] getVoices: () => []
}) })
// 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 +181,82 @@ 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)
})
})
// ============================================= // =============================================
// ICONA SERVIZIO // ICONA SERVIZIO
// ============================================= // =============================================
+54
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', () => {
+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))
}
})
}) })