Compare commits
8 Commits
8c52f1761e
...
7cc3b8bb5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cc3b8bb5e | |||
| b1c69f9517 | |||
| 8d53178c54 | |||
| da01149a2a | |||
| 3ee3578bb1 | |||
| c21e17c9d3 | |||
| 1bdb07aeef | |||
| f5459f0871 |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -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
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 197 KiB |
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||