feat(striscia): registra cambi e time out per set (#18)
La striscia salva ora storico cambi formazione e time out per ogni set, con riferimento al punteggio al momento dell'evento anziché a un timestamp: startTimeout accumula in set.timeouts, confermaCambi in set.cambi. Il referto elenca entrambi ordinati per punteggio.
This commit is contained in:
@@ -40,11 +40,15 @@ Display (Vue) ──WebSocket──┤── websocket-handler.js ── gam
|
|||||||
|
|
||||||
Punteggio, set e servizio si ricavano sempre dalla `striscia`: al primo punto di ogni set `applyAction('incPunt')` salva in `formInizio` uno snapshot della formazione di partenza (usato dal referto); `decPunt` lo rimuove se il set torna a 0-0.
|
Punteggio, set e servizio si ricavano sempre dalla `striscia`: al primo punto di ogni set `applyAction('incPunt')` salva in `formInizio` uno snapshot della formazione di partenza (usato dal referto); `decPunt` lo rimuove se il set torna a 0-0.
|
||||||
|
|
||||||
|
Ogni voce della `striscia` accumula anche lo storico di cambi e time out del set, popolato lazy (nessun campo se il set non ha eventi), senza timestamp — il "quando" è il punteggio al momento dell'evento, non l'orologio:
|
||||||
|
- `set.timeouts`: array di `{ team, punteggio: { home, guest } }`, appeso da `startTimeout` (solo se il time out non è già attivo, per non duplicare l'evento su un restart).
|
||||||
|
- `set.cambi`: array di `{ team, in, out, punteggio }`, uno per ogni sostituzione effettivamente applicata da `confermaCambi` (nulla viene registrato se la validazione del cambio fallisce).
|
||||||
|
|
||||||
`src/websocket-handler.js` riceve i messaggi WebSocket, valida che il mittente sia un `controller` (non un `display`), chiama `applyAction`, fa il broadcast del nuovo stato a tutti i client, poi invoca `onStateChange` per persistere su disco.
|
`src/websocket-handler.js` riceve i messaggi WebSocket, valida che il mittente sia un `controller` (non un `display`), chiama `applyAction`, fa il broadcast del nuovo stato a tutti i client, poi invoca `onStateChange` per persistere su disco.
|
||||||
|
|
||||||
`src/wsMixin.js` è il mixin Vue lato client: gestisce connessione/riconnessione WebSocket (backoff esponenziale) ed espone i computed `punt`/`servHome`/`set`.
|
`src/wsMixin.js` è il mixin Vue lato client: gestisce connessione/riconnessione WebSocket (backoff esponenziale) ed espone i computed `punt`/`servHome`/`set`.
|
||||||
|
|
||||||
`src/referto.js` genera il referto di gara: `buildRefertoHtml(state, now)` è una funzione pura che restituisce l'HTML (testabile), mentre `generaReferto(state)` è il wrapper che apre la finestra con `window.open` e avvia la stampa.
|
`src/referto.js` genera il referto di gara: `buildRefertoHtml(state, now)` è una funzione pura che restituisce l'HTML (testabile), mentre `generaReferto(state)` è il wrapper che apre la finestra con `window.open` e avvia la stampa. Per ogni set, oltre alla formazione di partenza e alla griglia punti, mostra (se presenti) l'elenco di `set.timeouts` e `set.cambi` ordinato per punteggio crescente.
|
||||||
|
|
||||||
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,13 @@ export function applyAction(state, action) {
|
|||||||
case "startTimeout": {
|
case "startTimeout": {
|
||||||
const team = action.team
|
const team = action.team
|
||||||
if (team !== 'home' && team !== 'guest') break
|
if (team !== 'home' && team !== 'guest') break
|
||||||
|
if (!s.timeout) {
|
||||||
|
const setCorrente = s.sp.striscia.at(-1)
|
||||||
|
;(setCorrente.timeouts ??= []).push({
|
||||||
|
team,
|
||||||
|
punteggio: punteggio(s.sp.striscia),
|
||||||
|
})
|
||||||
|
}
|
||||||
s.timeout = true
|
s.timeout = true
|
||||||
s.timeoutTeam = team
|
s.timeoutTeam = team
|
||||||
s.timeoutVideo = action.video ?? null
|
s.timeoutVideo = action.video ?? null
|
||||||
@@ -209,6 +216,7 @@ export function applyAction(state, action) {
|
|||||||
const cambi = action.cambi || []
|
const cambi = action.cambi || []
|
||||||
const form = s.sp.form[team].map((val) => String(val).trim())
|
const form = s.sp.form[team].map((val) => String(val).trim())
|
||||||
const formAggiornata = [...form]
|
const formAggiornata = [...form]
|
||||||
|
const cambiApplicati = []
|
||||||
|
|
||||||
let valid = true
|
let valid = true
|
||||||
for (const cambio of cambi) {
|
for (const cambio of cambi) {
|
||||||
@@ -223,11 +231,19 @@ export function applyAction(state, action) {
|
|||||||
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
|
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
formAggiornata.splice(idx, 1, cin)
|
formAggiornata.splice(idx, 1, cin)
|
||||||
|
cambiApplicati.push({ in: cin, out: cout })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valid) {
|
if (valid) {
|
||||||
s.sp.form[team] = formAggiornata
|
s.sp.form[team] = formAggiornata
|
||||||
|
if (cambiApplicati.length > 0) {
|
||||||
|
const setCorrente = s.sp.striscia.at(-1)
|
||||||
|
const puntAttuale = punteggio(s.sp.striscia)
|
||||||
|
;(setCorrente.cambi ??= []).push(
|
||||||
|
...cambiApplicati.map(c => ({ team, ...c, punteggio: { ...puntAttuale } }))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -44,6 +44,31 @@ export function buildRefertoHtml(state, now = new Date()) {
|
|||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nomeTeam = (team) => team === 'home' ? nomi.home : nomi.guest
|
||||||
|
|
||||||
|
const eventiHtml = (s) => {
|
||||||
|
const eventi = [
|
||||||
|
...(s.timeouts ?? []).map(t => ({ tipo: 'timeout', ...t })),
|
||||||
|
...(s.cambi ?? []).map(c => ({ tipo: 'cambio', ...c })),
|
||||||
|
].sort((a, b) => (a.punteggio.home + a.punteggio.guest) - (b.punteggio.home + b.punteggio.guest))
|
||||||
|
|
||||||
|
if (eventi.length === 0) return ''
|
||||||
|
|
||||||
|
const righe = eventi.map(e => {
|
||||||
|
const sul = `sul ${e.punteggio.home}-${e.punteggio.guest}`
|
||||||
|
const testo = e.tipo === 'timeout'
|
||||||
|
? `Time out <strong>${nomeTeam(e.team)}</strong> ${sul}`
|
||||||
|
: `Cambio <strong>${nomeTeam(e.team)}</strong>: entra ${e.in} per ${e.out} ${sul}`
|
||||||
|
return `<li class="evento evento-${e.tipo}">${testo}</li>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="eventi-set">
|
||||||
|
<div class="eventi-label">Cambi e time out</div>
|
||||||
|
<ul class="eventi-lista">${righe}</ul>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
const setsHtml = setReali.map((s, i) => {
|
const setsHtml = setReali.map((s, i) => {
|
||||||
let h = 0, g = 0
|
let h = 0, g = 0
|
||||||
const punti = []
|
const punti = []
|
||||||
@@ -68,7 +93,7 @@ export function buildRefertoHtml(state, now = new Date()) {
|
|||||||
<div class="form-inizio">
|
<div class="form-inizio">
|
||||||
<div class="form-inizio-label">Formazione di partenza</div>
|
<div class="form-inizio-label">Formazione di partenza</div>
|
||||||
${formazioneHtml(s.formInizio)}
|
${formazioneHtml(s.formInizio)}
|
||||||
</div>
|
</div>${eventiHtml(s)}
|
||||||
<div class="punti-grid">${puntiHtml || '<em style="color:#999;font-size:11px">Nessun punto registrato</em>'}</div>
|
<div class="punti-grid">${puntiHtml || '<em style="color:#999;font-size:11px">Nessun punto registrato</em>'}</div>
|
||||||
</div>`
|
</div>`
|
||||||
}).join('')
|
}).join('')
|
||||||
@@ -100,6 +125,11 @@ export function buildRefertoHtml(state, now = new Date()) {
|
|||||||
.punto-h { background: #d0e8ff; color: #003a6e; }
|
.punto-h { background: #d0e8ff; color: #003a6e; }
|
||||||
.punto-g { background: #ffddc0; color: #6e2700; }
|
.punto-g { background: #ffddc0; color: #6e2700; }
|
||||||
|
|
||||||
|
.eventi-set { padding: 8px 10px; border-bottom: 1px solid #eee; }
|
||||||
|
.eventi-label { font-size: 11px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #888; margin-bottom: 5px; }
|
||||||
|
.eventi-lista { list-style: none; }
|
||||||
|
.evento { font-size: 12px; padding: 2px 0; }
|
||||||
|
|
||||||
.form-inizio { padding: 8px 10px; border-bottom: 1px solid #eee; background: #fafafa; }
|
.form-inizio { padding: 8px 10px; border-bottom: 1px solid #eee; background: #fafafa; }
|
||||||
.form-inizio-label { font-size: 11px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #888; margin-bottom: 7px; }
|
.form-inizio-label { font-size: 11px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #888; margin-bottom: 7px; }
|
||||||
.form-row { display: flex; gap: 40px; }
|
.form-row { display: flex; gap: 40px; }
|
||||||
|
|||||||
@@ -532,6 +532,46 @@ describe('Game Logic (gameState.js)', () => {
|
|||||||
expect(s.timeout).toBe(false)
|
expect(s.timeout).toBe(false)
|
||||||
expect(s.timeoutTeam).toBe(null)
|
expect(s.timeoutTeam).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('startTimeout dovrebbe registrare l\'evento nel set corrente con il punteggio', () => {
|
||||||
|
let s = state
|
||||||
|
for (let i = 0; i < 3; i++) s = applyAction(s, { type: 'incPunt', team: 'home' })
|
||||||
|
s = applyAction(s, { type: 'incPunt', team: 'guest' })
|
||||||
|
s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 1000 })
|
||||||
|
expect(s.sp.striscia.at(-1).timeouts).toEqual([
|
||||||
|
{ team: 'guest', punteggio: { home: 3, guest: 1 } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startTimeout su time out già attivo non dovrebbe registrare un secondo evento', () => {
|
||||||
|
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
|
||||||
|
s = applyAction(s, { type: 'startTimeout', team: 'home', startedAt: 2000 })
|
||||||
|
expect(s.sp.striscia.at(-1).timeouts).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('time out successivi dovrebbero accumularsi nel set corrente', () => {
|
||||||
|
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
|
||||||
|
s = applyAction(s, { type: 'stopTimeout' })
|
||||||
|
s = applyAction(s, { type: 'incPunt', team: 'guest' })
|
||||||
|
s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 2000 })
|
||||||
|
expect(s.sp.striscia.at(-1).timeouts).toEqual([
|
||||||
|
{ team: 'home', punteggio: { home: 0, guest: 0 } },
|
||||||
|
{ team: 'guest', punteggio: { home: 0, guest: 1 } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un nuovo set non dovrebbe ereditare i time out del precedente', () => {
|
||||||
|
let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
|
||||||
|
s = applyAction(s, { type: 'stopTimeout' })
|
||||||
|
s = applyAction(s, { type: 'nuovoSet', team: 'home' })
|
||||||
|
expect(s.sp.striscia.at(-1).timeouts).toBeUndefined()
|
||||||
|
expect(s.sp.striscia.at(-2).timeouts).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startTimeout ignorato non dovrebbe registrare eventi', () => {
|
||||||
|
const s = applyAction(state, { type: 'startTimeout', team: 'arbitro', startedAt: 1000 })
|
||||||
|
expect(s.sp.striscia.at(-1).timeouts).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -705,6 +745,64 @@ describe('Game Logic (gameState.js)', () => {
|
|||||||
expect(s.sp.form.home).not.toContain("1")
|
expect(s.sp.form.home).not.toContain("1")
|
||||||
expect(s.sp.form.home).not.toContain("10")
|
expect(s.sp.form.home).not.toContain("10")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('dovrebbe registrare il cambio nel set corrente con il punteggio', () => {
|
||||||
|
let s = state
|
||||||
|
for (let i = 0; i < 2; i++) s = applyAction(s, { type: 'incPunt', team: 'home' })
|
||||||
|
s = applyAction(s, { type: 'incPunt', team: 'guest' })
|
||||||
|
s = applyAction(s, {
|
||||||
|
type: 'confermaCambi',
|
||||||
|
team: 'home',
|
||||||
|
cambi: [{ in: "10", out: "3" }]
|
||||||
|
})
|
||||||
|
expect(s.sp.striscia.at(-1).cambi).toEqual([
|
||||||
|
{ team: 'home', in: "10", out: "3", punteggio: { home: 2, guest: 1 } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dovrebbe registrare ogni cambio di una doppia sostituzione', () => {
|
||||||
|
const s = applyAction(state, {
|
||||||
|
type: 'confermaCambi',
|
||||||
|
team: 'guest',
|
||||||
|
cambi: [
|
||||||
|
{ in: "10", out: "1" },
|
||||||
|
{ in: "11", out: "2" }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
expect(s.sp.striscia.at(-1).cambi).toEqual([
|
||||||
|
{ team: 'guest', in: "10", out: "1", punteggio: { home: 0, guest: 0 } },
|
||||||
|
{ team: 'guest', in: "11", out: "2", punteggio: { home: 0, guest: 0 } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non dovrebbe registrare nulla se la validazione fallisce', () => {
|
||||||
|
const s = applyAction(state, {
|
||||||
|
type: 'confermaCambi',
|
||||||
|
team: 'home',
|
||||||
|
cambi: [{ in: "abc", out: "1" }]
|
||||||
|
})
|
||||||
|
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non dovrebbe registrare nulla se tutti i cambi sono vuoti', () => {
|
||||||
|
const s = applyAction(state, {
|
||||||
|
type: 'confermaCambi',
|
||||||
|
team: 'home',
|
||||||
|
cambi: [{ in: "", out: "" }]
|
||||||
|
})
|
||||||
|
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un nuovo set non dovrebbe ereditare i cambi del precedente', () => {
|
||||||
|
let s = applyAction(state, {
|
||||||
|
type: 'confermaCambi',
|
||||||
|
team: 'home',
|
||||||
|
cambi: [{ in: "10", out: "3" }]
|
||||||
|
})
|
||||||
|
s = applyAction(s, { type: 'nuovoSet', team: 'home' })
|
||||||
|
expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
|
||||||
|
expect(s.sp.striscia.at(-2).cambi).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -97,6 +97,38 @@ describe('buildRefertoHtml (referto.js)', () => {
|
|||||||
expect(html).toContain('Nessun punto registrato')
|
expect(html).toContain('Nessun punto registrato')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('elenca time out e cambi del set con squadra e punteggio', () => {
|
||||||
|
const striscia = [{
|
||||||
|
serv: 'h', ris: 'hhhg', vinc: null,
|
||||||
|
timeouts: [{ team: 'guest', punteggio: { home: 3, guest: 1 } }],
|
||||||
|
cambi: [{ team: 'home', in: '10', out: '3', punteggio: { home: 2, guest: 0 } }],
|
||||||
|
}]
|
||||||
|
const html = buildRefertoHtml(statoConSet(striscia), NOW)
|
||||||
|
expect(html).toContain('Cambi e time out')
|
||||||
|
expect(html).toContain('Time out <strong>Rivali</strong> sul 3-1')
|
||||||
|
expect(html).toContain('Cambio <strong>Antoniana</strong>: entra 10 per 3 sul 2-0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ordina gli eventi per punteggio crescente', () => {
|
||||||
|
const striscia = [{
|
||||||
|
serv: 'h', ris: 'hhhgg', vinc: null,
|
||||||
|
timeouts: [{ team: 'home', punteggio: { home: 3, guest: 2 } }],
|
||||||
|
cambi: [{ team: 'guest', in: '9', out: '4', punteggio: { home: 1, guest: 0 } }],
|
||||||
|
}]
|
||||||
|
const html = buildRefertoHtml(statoConSet(striscia), NOW)
|
||||||
|
const idxCambio = html.indexOf('entra 9 per 4')
|
||||||
|
const idxTimeout = html.indexOf('Time out')
|
||||||
|
expect(idxCambio).toBeGreaterThan(-1)
|
||||||
|
expect(idxTimeout).toBeGreaterThan(-1)
|
||||||
|
expect(idxCambio).toBeLessThan(idxTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omette la sezione eventi se il set non ne ha', () => {
|
||||||
|
const striscia = [{ serv: 'h', ris: 'h', vinc: null }]
|
||||||
|
const html = buildRefertoHtml(statoConSet(striscia), NOW)
|
||||||
|
expect(html).not.toContain('Cambi e time out')
|
||||||
|
})
|
||||||
|
|
||||||
it('header contiene nomi squadre, modalità e data iniettata', () => {
|
it('header contiene nomi squadre, modalità e data iniettata', () => {
|
||||||
const striscia = [{ serv: 'h', ris: '', vinc: null }]
|
const striscia = [{ serv: 'h', ris: '', vinc: null }]
|
||||||
const state = statoConSet(striscia)
|
const state = statoConSet(striscia)
|
||||||
|
|||||||
Reference in New Issue
Block a user