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.
This commit is contained in:
2026-07-03 10:27:44 +02:00
7 changed files with 436 additions and 36 deletions
+1
View File
@@ -91,3 +91,4 @@ Il volume `./.segnapunti` persiste lo stato tra i riavvii del container.
- Qualsiasi modifica al protocollo WebSocket va fatta in `src/websocket-handler.js`. - Qualsiasi modifica al protocollo WebSocket va fatta in `src/websocket-handler.js`.
- Aggiungere test unit in `tests/unit/gameState.test.js` per ogni nuovo action type o regola di gioco. - Aggiungere test unit in `tests/unit/gameState.test.js` per ogni nuovo action type o regola di gioco.
- Il codice deve essere scritto in italiano per commenti e nomi di variabili di dominio (es. `servHome`, `striscia`, `nomi`), ma in inglese per nomi tecnici standard (`state`, `action`, `handler`). - Il codice deve essere scritto in italiano per commenti e nomi di variabili di dominio (es. `servHome`, `striscia`, `nomi`), ma in inglese per nomi tecnici standard (`state`, `action`, `handler`).
- Non inserire emoji in codice, UI (label, testi dei bottoni, banner) o commenti, a meno che l'utente non le richieda esplicitamente.
+145 -5
View File
@@ -7,7 +7,9 @@
</div> </div>
<!-- Banner modalità time out attiva --> <!-- Banner modalità time out attiva -->
<div class="timeout-banner" v-if="state.timeout"> MODALITÀ TIME OUT ATTIVA</div> <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">
@@ -67,8 +69,9 @@
<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="sendAction({ type: 'toggleTimeout' })"> <button class="btn btn-ctrl btn-timeout" :class="{ active: state.timeout }"
Time Out @click="state.timeout ? terminaTimeout() : openTimeout()">
{{ state.timeout ? timeoutCountdown : 'Time Out' }}
</button> </button>
<button class="btn btn-danger" @click="confirmReset = true"> <button class="btn btn-danger" @click="confirmReset = true">
Reset Reset
@@ -141,14 +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="sendAction({ type: 'toggleTimeout' })"> Time Out</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>
@@ -277,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>
@@ -304,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: {
@@ -338,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) {
@@ -346,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 }
@@ -355,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,
@@ -458,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>
@@ -1070,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>
+79 -21
View File
@@ -113,10 +113,14 @@
</div> </div>
</div> </div>
<!-- Overlay time out: proietta i video spot a tutto schermo --> <!-- Overlay time out: proietta il video spot a tutto schermo -->
<div v-if="state.timeout" class="timeout-overlay"> <div v-if="state.timeout" class="timeout-overlay">
<video v-if="spotList.length" ref="spotVideo" class="timeout-video" playsinline></video> <div class="timeout-countdown">{{ timeoutCountdown }}</div>
<div v-else class="timeout-placeholder">TIME OUT</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> </div>
</section> </section>
</template> </template>
@@ -129,16 +133,38 @@ export default {
mixins: [createWsMixin('display')], mixins: [createWsMixin('display')],
data() { data() {
return { return {
spotList: [], // filename dei video spot da riprodurre mostraPlaceholder: false, // video terminato prima dei 30s: mostra l'immagine "TIME OUT"
spotIndex: 0, // video corrente nella sequenza 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: [] }
@@ -168,32 +194,41 @@ export default {
}, },
}, },
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() { async avviaTimeout() {
this.spotIndex = 0 this.mostraPlaceholder = !this.state.timeoutVideo
try { this.spotVideoDuration = null
const res = await fetch('/spot/list') this.avviaTimeoutTicker()
this.spotList = await res.json() if (!this.state.timeoutVideo) return
} catch {
this.spotList = []
}
if (!this.spotList.length) return
await this.$nextTick() await this.$nextTick()
this.caricaSpotCorrente() this.caricaSpotCorrente()
}, },
fermaTimeout() { fermaTimeout() {
this.fermaTimeoutTicker()
const video = this.$refs.spotVideo const video = this.$refs.spotVideo
if (video) { video.pause(); video.removeAttribute('src'); video.load() } if (video) { video.pause(); video.removeAttribute('src'); video.load() }
this.spotList = [] this.mostraPlaceholder = false
this.spotVideoDuration = null
}, },
caricaSpotCorrente() { caricaSpotCorrente() {
const video = this.$refs.spotVideo const video = this.$refs.spotVideo
if (!video || !this.spotList.length) return if (!video || !this.state.timeoutVideo) return
video.src = '/spot/' + encodeURIComponent(this.spotList[this.spotIndex]) video.src = '/spot/' + encodeURIComponent(this.state.timeoutVideo)
// Avanza al prossimo video alla fine (loop: con un solo video torna su sé stesso) video.onloadedmetadata = () => {
video.onended = () => { this.spotVideoDuration = video.duration
this.spotIndex = (this.spotIndex + 1) % this.spotList.length // Riconnessione a metà time out: riallinea il video al punto corretto.
this.caricaSpotCorrente() 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() this.playSpot()
}, },
playSpot() { playSpot() {
@@ -320,14 +355,37 @@ export default {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; 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 { .timeout-placeholder {
color: #ff9800; color: #ff9800;
font-size: 20vh; font-size: 14vh;
font-weight: 800; font-weight: 800;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
text-align: center;
line-height: 1.3;
animation: timeout-pulse 1.2s ease-in-out infinite; animation: timeout-pulse 1.2s ease-in-out infinite;
} }
+18 -2
View File
@@ -22,6 +22,9 @@ export function createInitialState() {
visuForm: false, visuForm: false,
visuStriscia: true, visuStriscia: true,
timeout: false, 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 }],
@@ -165,8 +168,21 @@ export function applyAction(state, action) {
break break
} }
case "toggleTimeout": { case "startTimeout": {
s.timeout = !s.timeout const team = action.team
if (team !== 'home' && team !== 'guest') break
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 break
} }
+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)
+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
// ============================================= // =============================================
+48 -7
View File
@@ -80,8 +80,11 @@ describe('Game Logic (gameState.js)', () => {
expect(state.visuStriscia).toBe(true) expect(state.visuStriscia).toBe(true)
}) })
it('dovrebbe avere timeout false', () => { it('dovrebbe avere timeout false e campi timeout nulli', () => {
expect(state.timeout).toBe(false) expect(state.timeout).toBe(false)
expect(state.timeoutTeam).toBe(null)
expect(state.timeoutVideo).toBe(null)
expect(state.timeoutStartedAt).toBe(null)
}) })
}) })
@@ -484,12 +487,50 @@ describe('Game Logic (gameState.js)', () => {
expect(s.order).toBe(false) expect(s.order).toBe(false)
}) })
it('toggleTimeout: false → true → false', () => { })
expect(state.timeout).toBe(false)
const s1 = applyAction(state, { type: 'toggleTimeout' }) // =============================================
expect(s1.timeout).toBe(true) // TIME OUT (startTimeout, stopTimeout)
const s2 = applyAction(s1, { type: 'toggleTimeout' }) // =============================================
expect(s2.timeout).toBe(false) 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 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)
}) })
}) })