From f6f3dd11d4eb7271991fa0776b16ab4ce3baa537 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 11:16:42 +0200 Subject: [PATCH 01/10] feat(striscia): registra cambi e time out per set (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 6 ++- src/gameState.js | 16 ++++++ src/referto.js | 32 +++++++++++- tests/unit/gameState.test.js | 98 ++++++++++++++++++++++++++++++++++++ tests/unit/referto.test.js | 32 ++++++++++++ 5 files changed, 182 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e6cc6a9..75fff7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. +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/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. diff --git a/src/gameState.js b/src/gameState.js index d27491e..6df447f 100644 --- a/src/gameState.js +++ b/src/gameState.js @@ -171,6 +171,13 @@ export function applyAction(state, action) { case "startTimeout": { const team = action.team if (team !== 'home' && team !== 'guest') break + if (!s.timeout) { + const setCorrente = s.sp.striscia.at(-1) + ;(setCorrente.timeouts ??= []).push({ + team, + punteggio: punteggio(s.sp.striscia), + }) + } s.timeout = true s.timeoutTeam = team s.timeoutVideo = action.video ?? null @@ -209,6 +216,7 @@ export function applyAction(state, action) { const cambi = action.cambi || [] const form = s.sp.form[team].map((val) => String(val).trim()) const formAggiornata = [...form] + const cambiApplicati = [] let valid = true for (const cambio of cambi) { @@ -223,11 +231,19 @@ export function applyAction(state, action) { const idx = formAggiornata.findIndex((val) => String(val).trim() === cout) if (idx !== -1) { formAggiornata.splice(idx, 1, cin) + cambiApplicati.push({ in: cin, out: cout }) } } if (valid) { 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 } diff --git a/src/referto.js b/src/referto.js index c08e5ec..0c636c8 100644 --- a/src/referto.js +++ b/src/referto.js @@ -44,6 +44,31 @@ export function buildRefertoHtml(state, now = new Date()) { ` } + 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 ${nomeTeam(e.team)} ${sul}` + : `Cambio ${nomeTeam(e.team)}: entra ${e.in} per ${e.out} ${sul}` + return `
  • ${testo}
  • ` + }).join('') + + return ` +
    +
    Cambi e time out
    + +
    ` + } + const setsHtml = setReali.map((s, i) => { let h = 0, g = 0 const punti = [] @@ -68,7 +93,7 @@ export function buildRefertoHtml(state, now = new Date()) {
    Formazione di partenza
    ${formazioneHtml(s.formInizio)} -
    + ${eventiHtml(s)}
    ${puntiHtml || 'Nessun punto registrato'}
    ` }).join('') @@ -100,6 +125,11 @@ export function buildRefertoHtml(state, now = new Date()) { .punto-h { background: #d0e8ff; color: #003a6e; } .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-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; } diff --git a/tests/unit/gameState.test.js b/tests/unit/gameState.test.js index 147fa1d..fdbe7c8 100644 --- a/tests/unit/gameState.test.js +++ b/tests/unit/gameState.test.js @@ -532,6 +532,46 @@ describe('Game Logic (gameState.js)', () => { expect(s.timeout).toBe(false) expect(s.timeoutTeam).toBe(null) }) + + it('startTimeout dovrebbe registrare l\'evento nel set corrente con il punteggio', () => { + let s = state + for (let i = 0; i < 3; i++) s = applyAction(s, { type: 'incPunt', team: 'home' }) + s = applyAction(s, { type: 'incPunt', team: 'guest' }) + s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 1000 }) + expect(s.sp.striscia.at(-1).timeouts).toEqual([ + { team: 'guest', punteggio: { home: 3, guest: 1 } }, + ]) + }) + + it('startTimeout su time out già attivo non dovrebbe registrare un secondo evento', () => { + let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 }) + s = applyAction(s, { type: 'startTimeout', team: 'home', startedAt: 2000 }) + expect(s.sp.striscia.at(-1).timeouts).toHaveLength(1) + }) + + it('time out successivi dovrebbero accumularsi nel set corrente', () => { + let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 }) + s = applyAction(s, { type: 'stopTimeout' }) + s = applyAction(s, { type: 'incPunt', team: 'guest' }) + s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 2000 }) + expect(s.sp.striscia.at(-1).timeouts).toEqual([ + { team: 'home', punteggio: { home: 0, guest: 0 } }, + { team: 'guest', punteggio: { home: 0, guest: 1 } }, + ]) + }) + + it('un nuovo set non dovrebbe ereditare i time out del precedente', () => { + let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 }) + s = applyAction(s, { type: 'stopTimeout' }) + s = applyAction(s, { type: 'nuovoSet', team: 'home' }) + expect(s.sp.striscia.at(-1).timeouts).toBeUndefined() + expect(s.sp.striscia.at(-2).timeouts).toHaveLength(1) + }) + + it('startTimeout ignorato non dovrebbe registrare eventi', () => { + const s = applyAction(state, { type: 'startTimeout', team: 'arbitro', startedAt: 1000 }) + expect(s.sp.striscia.at(-1).timeouts).toBeUndefined() + }) }) // ============================================= @@ -705,6 +745,64 @@ describe('Game Logic (gameState.js)', () => { expect(s.sp.form.home).not.toContain("1") 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) + }) }) // ============================================= diff --git a/tests/unit/referto.test.js b/tests/unit/referto.test.js index 0ea4fb9..5cd8e58 100644 --- a/tests/unit/referto.test.js +++ b/tests/unit/referto.test.js @@ -97,6 +97,38 @@ describe('buildRefertoHtml (referto.js)', () => { 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 Rivali sul 3-1') + expect(html).toContain('Cambio Antoniana: 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', () => { const striscia = [{ serv: 'h', ris: '', vinc: null }] const state = statoConSet(striscia) From 5bdd8ad9868d0db2f7856d635c37765ecfc7351e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 11:31:55 +0200 Subject: [PATCH 02/10] style(referto): riprendi il layout del modulo ufficiale FIPAV Cornice nera, barre teal "SET N" per set, badge A/B per squadre e box risultato finale in stile tabella, per avvicinare il referto stampabile all'aspetto del referto cartaceo FIPAV mantenendo solo i dati che l'app traccia realmente. --- src/referto.js | 132 ++++++++++++++++++++++++++----------- tests/e2e/referto.spec.cjs | 3 +- tests/unit/referto.test.js | 8 +-- 3 files changed, 97 insertions(+), 46 deletions(-) diff --git a/src/referto.js b/src/referto.js index 0c636c8..c2a59cf 100644 --- a/src/referto.js +++ b/src/referto.js @@ -87,17 +87,31 @@ export function buildRefertoHtml(state, now = new Date()) { return `
    -
    - Set ${i + 1}  —  ${nomi.home} ${h} · ${g} ${nomi.guest}${etichettaVinc} +
    SET${i + 1}
    +
    +
    + ${nomi.home} ${h} · ${g} ${nomi.guest}${etichettaVinc} +
    +
    +
    Formazione di partenza
    + ${formazioneHtml(s.formInizio)} +
    ${eventiHtml(s)} +
    ${puntiHtml || 'Nessun punto registrato'}
    -
    -
    Formazione di partenza
    - ${formazioneHtml(s.formInizio)} -
    ${eventiHtml(s)} -
    ${puntiHtml || 'Nessun punto registrato'}
    ` }).join('') + const risultatoSetHtml = setReali.map((s, i) => { + let h = 0, g = 0 + for (const c of s.ris) c === 'h' ? h++ : g++ + const vinc = vincitoreSet(s) + return ` + ${i + 1} + ${h} + ${g} + ` + }).join('') + const html = ` @@ -105,55 +119,93 @@ export function buildRefertoHtml(state, now = new Date()) { Referto — ${nomi.home} vs ${nomi.guest} -
    -
    Referto di Gara
    -
    ${dataOra}  ·  Modalità: ${modalitaPartita}
    -
    +
    +
    +
    +
    Referto di Gara
    +
    ${dataOra}  ·  Modalità: ${modalitaPartita}
    +
    +
    + A + ${nomi.home} + vs + ${nomi.guest} + B +
    +
    -
    -
    ${nomi.home}
    -
    vs
    -
    ${nomi.guest}
    -
    -
    ${setVinti.home} – ${setVinti.guest}
    -
    set vinti
    + ${setsHtml} - ${setsHtml} +
    +
    +
    Risultato finale
    + + + ${risultatoSetHtml} +
    SetAB
    +
    Vince ${setVinti.home >= setVinti.guest ? nomi.home : nomi.guest}  ${setVinti.home} – ${setVinti.guest}
    +
    + +
    +
    ` diff --git a/tests/e2e/referto.spec.cjs b/tests/e2e/referto.spec.cjs index c4da05d..e16cb2a 100644 --- a/tests/e2e/referto.spec.cjs +++ b/tests/e2e/referto.spec.cjs @@ -40,8 +40,7 @@ test.describe('Referto di fine partita', () => { // Verifica il contenuto del referto const body = popup.locator('body'); await expect(body).toContainText('Referto di Gara'); - await expect(body).toContainText('Set 1'); - await expect(body).toContainText('Set 2'); + await expect(popup.locator('.set-numero')).toHaveText(['1', '2']); // Home ha vinto 2 set a 0 await expect(body).toContainText('2 – 0'); }); diff --git a/tests/unit/referto.test.js b/tests/unit/referto.test.js index 5cd8e58..96e6524 100644 --- a/tests/unit/referto.test.js +++ b/tests/unit/referto.test.js @@ -21,10 +21,10 @@ describe('buildRefertoHtml (referto.js)', () => { { serv: 'h', ris: 'h'.repeat(25) + 'g'.repeat(18), vinc: 'h' }, ] const html = buildRefertoHtml(statoConSet(striscia), NOW) - // due set reali → "Set 1" e "Set 2", mai "Set 3" - expect(html).toContain('Set 1') - expect(html).toContain('Set 2') - expect(html).not.toContain('Set 3') + // due set reali → numeri "1" e "2" nella barra SET, mai "3" + expect(html).toContain('1') + expect(html).toContain('2') + expect(html).not.toContain('3') }) it('calcola il punteggio finale di ogni set dalla ris', () => { From fdd1d0c9909163e286a186afff955a67b3036444 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 11:59:17 +0200 Subject: [PATCH 03/10] test(gameState): copri i branch residui di nuovoSet/confermaCambi/resetta/startTimeout Aggiunge casi per team 'guest' in nuovoSet, cambi senza array `cambi`, resetta con striscia vuota e startTimeout senza startedAt: gameState.js passa dal 100% al 100% statement ma sale al 99% branch. --- tests/unit/gameState.test.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/gameState.test.js b/tests/unit/gameState.test.js index fdbe7c8..e21c22e 100644 --- a/tests/unit/gameState.test.js +++ b/tests/unit/gameState.test.js @@ -365,6 +365,13 @@ describe('Game Logic (gameState.js)', () => { expect(setDi(s).guest).toBe(0) }) + it('dovrebbe incrementare il set guest e servire guest nel nuovo set', () => { + const s = applyAction(state, { type: 'nuovoSet', team: 'guest' }) + expect(setDi(s).home).toBe(0) + expect(setDi(s).guest).toBe(1) + expect(s.sp.striscia.at(-1).serv).toBe('g') + }) + it('in 2/3 alla palla match registra il set vincente senza aprirne uno nuovo', () => { state.modalitaPartita = '2/3' setSetVinti(state, 1, 0) @@ -508,6 +515,12 @@ describe('Game Logic (gameState.js)', () => { expect(s.timeoutVideo).toBe(null) }) + it('startTimeout senza startedAt dovrebbe lasciare timeoutStartedAt null', () => { + const s = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4' }) + expect(s.timeout).toBe(true) + expect(s.timeoutStartedAt).toBe(null) + }) + it('startTimeout senza squadra valida dovrebbe essere ignorato', () => { for (const team of [undefined, null, 'arbitro', '']) { const s = applyAction(state, { type: 'startTimeout', team, video: 'spot.mp4', startedAt: 1000 }) @@ -644,6 +657,12 @@ describe('Game Logic (gameState.js)', () => { // CAMBI GIOCATORI (confermaCambi) // ============================================= describe('confermaCambi', () => { + it('senza array cambi non dovrebbe modificare la formazione', () => { + state.sp.form.home = ["1", "2", "3", "4", "5", "6"] + const s = applyAction(state, { type: 'confermaCambi', team: 'home' }) + expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"]) + }) + it('dovrebbe effettuare una sostituzione valida', () => { state.sp.form.home = ["1", "2", "3", "4", "5", "6"] const s = applyAction(state, { @@ -941,6 +960,12 @@ describe('Game Logic (gameState.js)', () => { expect(s.sp.striscia[0].ris).toBe('') }) + it('con striscia vuota dovrebbe usare "h" come servizio iniziale di default', () => { + state.sp.striscia = [] + const s = applyAction(state, { type: 'resetta' }) + expect(s.sp.striscia).toEqual([{ serv: 'h', ris: '', vinc: null }]) + }) + it('dovrebbe impostare visuForm a false', () => { state.visuForm = true const s = applyAction(state, { type: 'resetta' }) From 2625284e99cede903219d0464bd8dc5969708aee Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 11:59:28 +0200 Subject: [PATCH 04/10] test(referto): copri generaReferto e il branch guest di vincitoreSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildRefertoHtml mancava il caso vinc nullo con guest in vantaggio; generaReferto (window.open/print) non era testato affatto perché richiede un DOM — aggiunto un test component in happy-dom che mocka window.open. --- tests/component/referto-generaReferto.test.js | 31 +++++++++++++++++++ tests/unit/referto.test.js | 9 ++++++ 2 files changed, 40 insertions(+) create mode 100644 tests/component/referto-generaReferto.test.js diff --git a/tests/component/referto-generaReferto.test.js b/tests/component/referto-generaReferto.test.js new file mode 100644 index 0000000..189cd11 --- /dev/null +++ b/tests/component/referto-generaReferto.test.js @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, afterEach } from 'vitest' +import { generaReferto } from '../../src/referto.js' +import { createInitialState } from '../../src/gameState.js' + +// generaReferto è l'unico punto con effetti collaterali di referto.js (window.open +// + stampa): qui verifichiamo che orchestri correttamente le API del browser, +// mockando window.open per non aprire davvero una finestra durante i test. +describe('generaReferto (referto.js)', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('apre una nuova scheda, scrive il referto e avvia la stampa', () => { + const popup = { + document: { write: vi.fn(), close: vi.fn() }, + print: vi.fn(), + } + const openSpy = vi.spyOn(window, 'open').mockReturnValue(popup) + + const state = createInitialState() + state.sp.nomi = { home: 'Antoniana', guest: 'Rivali' } + generaReferto(state) + + expect(openSpy).toHaveBeenCalledWith('', '_blank') + expect(popup.document.write).toHaveBeenCalledTimes(1) + expect(popup.document.write.mock.calls[0][0]).toContain('Antoniana') + expect(popup.document.close).toHaveBeenCalledTimes(1) + expect(popup.print).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/referto.test.js b/tests/unit/referto.test.js index 96e6524..da4e759 100644 --- a/tests/unit/referto.test.js +++ b/tests/unit/referto.test.js @@ -60,6 +60,15 @@ describe('buildRefertoHtml (referto.js)', () => { expect(html).toContain('1 – 0') }) + it('ricava il vincitore guest dal conteggio punti se vinc è nullo', () => { + const striscia = [ + { serv: 'h', ris: 'g'.repeat(25) + 'h'.repeat(20), vinc: null }, + ] + const html = buildRefertoHtml(statoConSet(striscia), NOW) + // conteggio punti guest > home, pur con vinc null → 0 – 1 + expect(html).toContain('0 – 1') + }) + it('include la progressione punto-punto con classi per squadra', () => { const striscia = [ { serv: 'h', ris: 'hhg', vinc: null }, From eb91b71d7ee9ac537309d409cdc0138b532b8994 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 11:59:43 +0200 Subject: [PATCH 05/10] test(server-utils): copri il rilevamento piattaforma (WSL/PowerShell) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getNetworkIPs() senza parametri usa isWSL()/execSync/os.networkInterfaces, codice mai esercitato perché tutti gli altri test iniettano `nets` per bypassarlo. Mocka fs/child_process/os per coprire in modo deterministico i rami WSL, non-WSL, fallimento PowerShell e lettura /proc fallita. --- tests/unit/server-utils-platform.test.js | 81 ++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/unit/server-utils-platform.test.js diff --git a/tests/unit/server-utils-platform.test.js b/tests/unit/server-utils-platform.test.js new file mode 100644 index 0000000..4aedba4 --- /dev/null +++ b/tests/unit/server-utils-platform.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' + +// getNetworkIPs() senza argomenti dipende dalla piattaforma (fs, child_process, +// os.networkInterfaces): qui mockiamo quei moduli per esercitare in modo +// deterministico i rami WSL/non-WSL, altrimenti scoperti dagli altri test +// (che iniettano sempre `nets` per bypassare questo codice). +describe('server-utils.js — rilevamento piattaforma', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + vi.doUnmock('fs') + vi.doUnmock('child_process') + vi.doUnmock('os') + }) + + it('senza WSL usa os.networkInterfaces()', async () => { + vi.doMock('fs', () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(), + })) + vi.doMock('child_process', () => ({ + execSync: vi.fn(), + })) + vi.doMock('os', () => ({ + networkInterfaces: vi.fn(() => ({ + eth0: [{ family: 'IPv4', internal: false, address: '10.0.0.9' }], + })), + })) + + const { getNetworkIPs } = await import('../../src/server-utils.js') + expect(getNetworkIPs()).toEqual(['10.0.0.9']) + }) + + it('su WSL interroga PowerShell e filtra gli IP LAN', async () => { + vi.doMock('fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => 'Microsoft WSL2'), + })) + vi.doMock('child_process', () => ({ + execSync: vi.fn(() => Buffer.from('192.168.1.42\n127.0.0.1\n172.17.0.1\n')), + })) + vi.doMock('os', () => ({ + networkInterfaces: vi.fn(), + })) + + const { getNetworkIPs } = await import('../../src/server-utils.js') + expect(getNetworkIPs()).toEqual(['192.168.1.42']) + }) + + it('su WSL se PowerShell fallisce restituisce array vuoto', async () => { + vi.doMock('fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => 'Microsoft WSL2'), + })) + vi.doMock('child_process', () => ({ + execSync: vi.fn(() => { throw new Error('powershell non disponibile') }), + })) + vi.doMock('os', () => ({ + networkInterfaces: vi.fn(), + })) + + const { getNetworkIPs } = await import('../../src/server-utils.js') + expect(getNetworkIPs()).toEqual([]) + }) + + it('se la lettura di /proc/sys/kernel/osrelease lancia, tratta la piattaforma come non-WSL', async () => { + vi.doMock('fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => { throw new Error('permesso negato') }), + })) + vi.doMock('child_process', () => ({ + execSync: vi.fn(), + })) + vi.doMock('os', () => ({ + networkInterfaces: vi.fn(() => ({})), + })) + + const { getNetworkIPs } = await import('../../src/server-utils.js') + expect(getNetworkIPs()).toEqual([]) + }) +}) From 3d54d162e60d62e03b9f2155e82bd9ddffa240b9 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 11:59:59 +0200 Subject: [PATCH 06/10] test(websocket-handler): copri i rami di errore su send/terminate/applyAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggiunge scenari in cui ws.send o ws.terminate lanciano eccezioni e in cui applyAction fallisce a metà azione: verifica che l'errore venga loggato e lo stato precedente ripristinato senza propagare l'eccezione al chiamante. --- .../websocket-handler-errori.test.js | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/integration/websocket-handler-errori.test.js diff --git a/tests/integration/websocket-handler-errori.test.js b/tests/integration/websocket-handler-errori.test.js new file mode 100644 index 0000000..d415b98 --- /dev/null +++ b/tests/integration/websocket-handler-errori.test.js @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { EventEmitter } from 'events' + +// Mock parziale di una WebSocket e del Server, analogo a websocket.test.js ma +// con send/terminate che possono essere forzati a lanciare per esercitare i +// rami di gestione errori altrimenti irraggiungibili. +class MockWebSocket extends EventEmitter { + constructor() { + super() + this.readyState = 1 // OPEN + } + send = vi.fn() + terminate = vi.fn() +} + +class MockWebSocketServer extends EventEmitter { + clients = new Set() +} + +describe('websocket-handler.js — rami di errore', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + vi.doUnmock('../../src/gameState.js') + }) + + describe('errori di invio (ws.send che lancia)', () => { + let wss, setupWebSocketHandler, consoleSpy + + beforeEach(async () => { + vi.resetModules() + ;({ setupWebSocketHandler } = await import('../../src/websocket-handler.js')) + wss = new MockWebSocketServer() + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('handleRegister: se ws.send lancia durante l\'invio dello stato iniziale, non deve propagare l\'errore', () => { + setupWebSocketHandler(wss) + const ws = new MockWebSocket() + ws.send.mockImplementation(() => { throw new Error('socket chiuso a metà') }) + wss.emit('connection', ws) + wss.clients.add(ws) + + expect(() => { + ws.emit('message', JSON.stringify({ type: 'register', role: 'display' })) + }).not.toThrow() + expect(consoleSpy).toHaveBeenCalledWith('Error sending initial state:', expect.any(Error)) + }) + + it('sendError: se ws.send lancia rispondendo a un JSON non valido, non deve propagare l\'errore', () => { + setupWebSocketHandler(wss) + const ws = new MockWebSocket() + ws.send.mockImplementation(() => { throw new Error('socket chiuso a metà') }) + wss.emit('connection', ws) + wss.clients.add(ws) + + // JSON non valido → handleMessage va nel catch → sendError prova a rispondere → ws.send lancia + expect(() => { + ws.emit('message', 'non è JSON {{{') + }).not.toThrow() + expect(consoleSpy).toHaveBeenCalledWith('Failed to send error message:', expect.any(Error)) + }) + + it('sendError: se ws.send lancia rispondendo a un\'azione invalida, non deve propagare l\'errore', () => { + setupWebSocketHandler(wss) + const controller = new MockWebSocket() + wss.emit('connection', controller) + wss.clients.add(controller) + controller.emit('message', JSON.stringify({ type: 'register', role: 'controller' })) + + controller.send.mockImplementation(() => { throw new Error('socket chiuso a metà') }) + expect(() => { + controller.emit('message', JSON.stringify({ type: 'action' })) + }).not.toThrow() + expect(consoleSpy).toHaveBeenCalledWith('Failed to send error message:', expect.any(Error)) + }) + }) + + describe('handleError: ws.terminate che lancia', () => { + it('non deve propagare l\'errore se la chiusura forzata fallisce', async () => { + vi.resetModules() + const { setupWebSocketHandler } = await import('../../src/websocket-handler.js') + const wss = new MockWebSocketServer() + setupWebSocketHandler(wss) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const ws = new MockWebSocket() + ws.terminate.mockImplementation(() => { throw new Error('già chiusa') }) + wss.emit('connection', ws) + + const err = new Error('Invalid UTF8') + err.code = 'WS_ERR_INVALID_UTF8' + expect(() => ws.emit('error', err)).not.toThrow() + expect(consoleSpy).toHaveBeenCalledWith('Error closing connection:', expect.any(Error)) + }) + }) + + describe('handleAction: applyAction che lancia', () => { + it('non deve propagare l\'errore, ripristina lo stato precedente e notifica il client', async () => { + vi.resetModules() + vi.doMock('../../src/gameState.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + applyAction: vi.fn((state, action) => { + if (action.type === 'boom') throw new Error('regola di gioco rotta') + return actual.applyAction(state, action) + }), + } + }) + const { setupWebSocketHandler } = await import('../../src/websocket-handler.js') + const wss = new MockWebSocketServer() + const handler = setupWebSocketHandler(wss) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const controller = new MockWebSocket() + wss.emit('connection', controller) + wss.clients.add(controller) + controller.emit('message', JSON.stringify({ type: 'register', role: 'controller' })) + const statoPrecedente = handler.getState() + controller.send.mockClear() + + expect(() => { + controller.emit('message', JSON.stringify({ type: 'action', action: { type: 'boom' } })) + }).not.toThrow() + + expect(consoleSpy).toHaveBeenCalledWith('Error applying action:', expect.any(Error)) + expect(handler.getState()).toEqual(statoPrecedente) + const msg = JSON.parse(controller.send.mock.calls.at(-1)[0]) + expect(msg.type).toBe('error') + expect(msg.message).toContain('Failed to apply action') + }) + }) +}) From cbf927255a0c758f2bc51eb2ec1adee9c3ac774a Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 12:00:12 +0200 Subject: [PATCH 07/10] test(wsMixin): copri cleanup, riconnessione e guardie residue Aggiunge test per beforeUnmount con connessione OPEN e con ws.close che lancia, l'annullamento del reconnectTimeout allo smontaggio, la guardia isConnecting, la chiusura della WebSocket precedente prima di riconnettersi (incluso il caso in cui close lancia), gli errori di registrazione/parsing nei gestori onopen/onmessage e la guardia di scheduleReconnect contro timer doppi. --- tests/component/wsMixin.test.js | 97 +++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/component/wsMixin.test.js b/tests/component/wsMixin.test.js index 69d4eef..52dbcd5 100644 --- a/tests/component/wsMixin.test.js +++ b/tests/component/wsMixin.test.js @@ -224,5 +224,102 @@ describe('createWsMixin (wsMixin.js)', () => { wrapper.unmount() expect(ws.close).toHaveBeenCalled() }) + + it('beforeUnmount con connessione OPEN chiude con codice 1000', () => { + const wrapper = mountWith() + const ws = ultimaWs() + ws.readyState = MockWebSocket.OPEN + wrapper.unmount() + expect(ws.close).toHaveBeenCalledWith(1000, 'Component unmounting') + }) + + it('beforeUnmount non lancia se ws.close lancia', () => { + const wrapper = mountWith() + const ws = ultimaWs() + ws.readyState = MockWebSocket.OPEN + ws.close.mockImplementation(() => { throw new Error('già chiusa') }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(() => wrapper.unmount()).not.toThrow() + expect(consoleSpy).toHaveBeenCalled() + }) + + it('beforeUnmount annulla un reconnectTimeout pendente', () => { + vi.useFakeTimers() + try { + const wrapper = mountWith() + wrapper.vm.isConnecting = false + wrapper.vm.scheduleReconnect() + expect(wrapper.vm.reconnectTimeout).not.toBe(null) + wrapper.unmount() + // se il timer non fosse stato annullato, allo scoccare del delay + // verrebbe creata una nuova connessione anche a componente smontato + const primaDelTimeout = MockWebSocket.instances.length + vi.advanceTimersByTime(30000) + expect(MockWebSocket.instances.length).toBe(primaDelTimeout) + } finally { + vi.useRealTimers() + } + }) + }) + + describe('rami aggiuntivi', () => { + it('connectWebSocket non fa nulla se è già in corso un tentativo di connessione', () => { + const wrapper = mountWith() + const primaDelTimeout = MockWebSocket.instances.length + wrapper.vm.isConnecting = true + wrapper.vm.connectWebSocket() + expect(MockWebSocket.instances.length).toBe(primaDelTimeout) + }) + + it('riconnettendosi con la WebSocket precedente OPEN, la chiude con codice 1000', () => { + const wrapper = mountWith() + const prima = ultimaWs() + prima.readyState = MockWebSocket.OPEN + wrapper.vm.isConnecting = false + wrapper.vm.connectWebSocket() + expect(prima.close).toHaveBeenCalledWith(1000, 'Reconnecting') + }) + + it('se la chiusura della WebSocket precedente lancia, logga e continua a riconnettersi', () => { + const wrapper = mountWith() + const prima = ultimaWs() + prima.readyState = MockWebSocket.OPEN + prima.close.mockImplementation(() => { throw new Error('già chiusa') }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + wrapper.vm.isConnecting = false + + expect(() => wrapper.vm.connectWebSocket()).not.toThrow() + expect(consoleSpy).toHaveBeenCalled() + expect(ultimaWs()).not.toBe(prima) + }) + + it('onopen non lancia se il registro fallisce (send che lancia)', () => { + const wrapper = mountWith() + const ws = ultimaWs() + ws.readyState = MockWebSocket.OPEN + ws.send.mockImplementation(() => { throw new Error('boom') }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => ws.onopen()).not.toThrow() + expect(wrapper.vm.wsConnected).toBe(true) + expect(consoleSpy).toHaveBeenCalled() + }) + + it('onmessage non lancia su JSON non valido', () => { + mountWith() + const ws = ultimaWs() + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => ws.onmessage({ data: 'non è JSON {{{' })).not.toThrow() + expect(consoleSpy).toHaveBeenCalled() + }) + + it('scheduleReconnect non arma un secondo timer se uno è già pendente', () => { + const wrapper = mountWith() + const spy = vi.spyOn(globalThis, 'setTimeout') + wrapper.vm.reconnectTimeout = 123 // timer già pendente (valore fittizio) + wrapper.vm.scheduleReconnect() + expect(spy).not.toHaveBeenCalled() + }) }) }) From 6638d6fe44b6ce1b29aecf6366850c1e5d008654 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 12:00:25 +0200 Subject: [PATCH 08/10] test(server): copri l'avvio effettivo di startServer (HTTP + WebSocket) startServer() non era mai invocato nei test: solo createApp() (routing) era coperto. Mocka src/persist.js per non toccare .segnapunti/state.json reale, e verifica l'ascolto HTTP, l'accettazione dell'upgrade su /ws, il rifiuto su altri percorsi e la persistenza dello stato dopo un'azione del controller. --- tests/integration/startServer.test.js | 75 +++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/integration/startServer.test.js diff --git a/tests/integration/startServer.test.js b/tests/integration/startServer.test.js new file mode 100644 index 0000000..aafc163 --- /dev/null +++ b/tests/integration/startServer.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest' +import WebSocket from 'ws' +import { createInitialState } from '../../src/gameState.js' + +// startServer() usa src/persist.js per caricare/salvare lo stato reale su disco +// (.segnapunti/state.json): lo mockiamo per non toccare lo stato di una partita +// vera durante i test, ed esercitare in isolamento l'avvio di HTTP + WebSocket. +const saveState = vi.fn() +vi.mock('../../src/persist.js', () => ({ + loadState: () => createInitialState(), + saveState, +})) + +const { startServer } = await import('../../server.js') + +describe('startServer (server.js)', () => { + let server + let port + + beforeAll(async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + server = startServer(0) + await new Promise((resolve) => server.on('listening', resolve)) + port = server.address().port + consoleSpy.mockRestore() + }) + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)) + }) + + it('stampa le info del server all\'avvio', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const s = startServer(0) + await new Promise((resolve) => s.on('listening', resolve)) + const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n') + expect(allLogs).toContain('Segnapunti Server') + consoleSpy.mockRestore() + await new Promise((resolve) => s.close(resolve)) + }) + + it('accetta una connessione WebSocket su /ws', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise((resolve, reject) => { + ws.on('open', resolve) + ws.on('error', reject) + }) + ws.close() + }) + + it('rifiuta l\'upgrade su un percorso diverso da /ws', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/altro-percorso`) + await new Promise((resolve) => { + ws.on('error', () => resolve()) + ws.on('open', () => { ws.close(); resolve() }) + }) + }) + + it('un\'azione dal controller invoca saveState (onStateChange)', async () => { + saveState.mockClear() + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise((resolve, reject) => { + ws.on('open', resolve) + ws.on('error', reject) + }) + ws.send(JSON.stringify({ type: 'register', role: 'controller' })) + await new Promise((resolve) => ws.once('message', resolve)) + + ws.send(JSON.stringify({ type: 'action', action: { type: 'incPunt', team: 'home' } })) + await new Promise((resolve) => ws.once('message', resolve)) + + expect(saveState).toHaveBeenCalled() + ws.close() + }) +}) From 0b7cbd00f6c707623afaef8b8ea2e7b1ed5cbf9f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 12:00:40 +0200 Subject: [PATCH 09/10] test(component): porta ControllerPage e DisplayPage al 100% di statement coverage Aggiunge 65 test per ControllerPage.vue (layout esteso/landscape, popup automatico di set vinto con soglia decisiva, modal di configurazione, validazione completa dei cambi giocatore, modal time out con selezione video/ticker/countdown, cleanup dei listener) e 35 per DisplayPage.vue (countdown overlay, riallineamento video su riconnessione, fallback muto per autoplay bloccato, selezione voce per la sintesi vocale). Nessun bug riscontrato nei componenti. --- tests/component/ControllerPage.test.js | 608 +++++++++++++++++++++++++ tests/component/DisplayPage.test.js | 259 +++++++++++ 2 files changed, 867 insertions(+) diff --git a/tests/component/ControllerPage.test.js b/tests/component/ControllerPage.test.js index 53cbeca..1c6ffc9 100644 --- a/tests/component/ControllerPage.test.js +++ b/tests/component/ControllerPage.test.js @@ -133,6 +133,14 @@ describe('ControllerPage.vue', () => { const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla')) expect(btn.attributes('disabled')).toBeDefined() }) + + it('il click quando abilitato invia cambiaPalla', async () => { + const wrapper = mountController() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla')) + await btn.trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'cambiaPalla' }) + }) }) // ============================================= @@ -349,6 +357,25 @@ describe('ControllerPage.vue', () => { expect(spy).toHaveBeenCalledWith({ type: 'stopTimeout' }) }) + it('avviaTimeout non invia nulla se nessuna squadra è selezionata', () => { + const wrapper = mountController() + wrapper.vm.timeoutTeamSel = null + const spy = vi.spyOn(wrapper.vm, 'sendAction') + wrapper.vm.avviaTimeout() + expect(spy).not.toHaveBeenCalled() + }) + + it('il ticker del time out aggiorna nowTick periodicamente', async () => { + const wrapper = mountController() + wrapper.vm.state.timeoutTeam = 'home' + wrapper.vm.state.timeoutStartedAt = Date.now() + wrapper.vm.state.timeout = true + await wrapper.vm.$nextTick() + const before = wrapper.vm.nowTick + vi.advanceTimersByTime(500) + expect(wrapper.vm.nowTick).toBeGreaterThan(before) + }) + it('durante il time out mostra banner con squadra e countdown', async () => { const wrapper = mountController() wrapper.vm.state.timeoutTeam = 'home' @@ -365,6 +392,587 @@ describe('ControllerPage.vue', () => { }) }) + // ============================================= + // MODALITÀ ESTESA (landscape) + // ============================================= + describe('Modalità estesa', () => { + it('con isLandscape=true mostra il layout esteso con i pannelli squadra', async () => { + const wrapper = mountController() + wrapper.vm.isLandscape = true + await wrapper.vm.$nextTick() + expect(wrapper.find('.e-dash').exists()).toBe(true) + expect(wrapper.findAll('.e-panel')).toHaveLength(2) + // in modalità estesa non compare il layout mobile + expect(wrapper.find('.score-preview').exists()).toBe(false) + }) + + it('il resize della finestra aggiorna isLandscape', async () => { + const wrapper = mountController() + expect(wrapper.vm.isLandscape).toBe(false) + Object.defineProperty(window, 'innerWidth', { value: 800, writable: true, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 400, writable: true, configurable: true }) + window.dispatchEvent(new Event('resize')) + await wrapper.vm.$nextTick() + expect(wrapper.vm.isLandscape).toBe(true) + // ripristina le dimensioni portrait usate dagli altri test + Object.defineProperty(window, 'innerWidth', { value: 400, writable: true, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 800, writable: true, configurable: true }) + }) + + it('il click sul punteggio in modalità estesa invia incPunt', async () => { + const wrapper = mountController() + wrapper.vm.isLandscape = true + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await wrapper.findAll('.e-panel__score')[0].trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'home' }) + }) + + it('smontare il componente rimuove i listener di resize e ferma il ticker del time out', async () => { + const wrapper = mountController() + wrapper.vm.state.timeout = true + await wrapper.vm.$nextTick() + expect(vi.getTimerCount()).toBeGreaterThan(0) + const removeSpy = vi.spyOn(window, 'removeEventListener') + wrapper.unmount() + expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + expect(removeSpy).toHaveBeenCalledWith('orientationchange', expect.any(Function)) + }) + }) + + // ============================================= + // SET VINTO AUTOMATICO (watch squadraVincente) + // ============================================= + describe('Set vinto automatico', () => { + it('apre la modal SET VINTO quando il punteggio raggiunge la soglia', async () => { + const wrapper = mountController() + setScore(wrapper, 25, 10) + await wrapper.vm.$nextTick() + expect(wrapper.vm.showSetVinto).toBe(true) + expect(wrapper.vm.setVintoTeam).toBe('home') + expect(wrapper.find('.dialog-title').text()).toBe('SET VINTO') + }) + + it('non riapre la modal se è già aperta', async () => { + const wrapper = mountController() + setScore(wrapper, 25, 10) + await wrapper.vm.$nextTick() + wrapper.vm.setVintoTeam = 'guest' + setScore(wrapper, 25, 24) + await wrapper.vm.$nextTick() + // il watch non deve sovrascrivere setVintoTeam perché showSetVinto è già true + expect(wrapper.vm.setVintoTeam).toBe('guest') + }) + + it('INDIETRO annulla l\'ultimo punto e chiude la modal', async () => { + const wrapper = mountController() + setScore(wrapper, 25, 10) + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await wrapper.find('.btn-cancel').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'decPunt' }) + expect(wrapper.vm.showSetVinto).toBe(false) + }) + + it('VAI AL SET SUCCESSIVO invia nuovoSet e apre la configurazione', async () => { + const wrapper = mountController() + setScore(wrapper, 25, 10) + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + const btn = wrapper.findAll('.btn-confirm').find(b => b.text().includes('SET SUCCESSIVO')) + await btn.trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'nuovoSet', team: 'home' }) + expect(wrapper.vm.showSetVinto).toBe(false) + expect(wrapper.vm.showConfig).toBe(true) + }) + }) + + // ============================================= + // CONFIGURAZIONE + // ============================================= + describe('Configurazione', () => { + it('apre la modal di configurazione precompilata con lo stato corrente', async () => { + const wrapper = mountController() + await wrapper.find('.btn-ctrl:not(.btn-timeout)').exists() + const btn = wrapper.findAll('.btn-ctrl').find(b => b.text() === 'Config') + await btn.trigger('click') + expect(wrapper.vm.showConfig).toBe(true) + expect(wrapper.vm.configData.nomeHome).toBe('Antoniana') + expect(wrapper.vm.configData.nomeGuest).toBe('Guest') + expect(wrapper.vm.configData.modalita).toBe('3/5') + }) + + it('OK invia le action di configurazione e chiude la modal', async () => { + const wrapper = mountController() + wrapper.vm.showConfig = true + wrapper.vm.configData = { + nomeHome: 'Casa', nomeGuest: 'Ospiti', modalita: '2/3', + formHome: ['1', '2', '3', '4', '5', '6'], + formGuest: ['1', '2', '3', '4', '5', '6'], + } + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await wrapper.find('.dialog-config .btn-confirm').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'setNomi', home: 'Casa', guest: 'Ospiti' }) + expect(spy).toHaveBeenCalledWith({ type: 'setModalita', modalita: '2/3' }) + expect(spy).toHaveBeenCalledWith({ type: 'setFormazione', team: 'home', form: ['1', '2', '3', '4', '5', '6'] }) + expect(spy).toHaveBeenCalledWith({ type: 'setFormazione', team: 'guest', form: ['1', '2', '3', '4', '5', '6'] }) + expect(wrapper.vm.showConfig).toBe(false) + }) + + it('Annulla chiude la modal di configurazione senza inviare nulla', async () => { + const wrapper = mountController() + wrapper.vm.showConfig = true + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await wrapper.find('.dialog-config .btn-cancel').trigger('click') + expect(spy).not.toHaveBeenCalled() + expect(wrapper.vm.showConfig).toBe(false) + }) + + it('scegliendo la modalità 2/3 il bottone diventa attivo', async () => { + const wrapper = mountController() + wrapper.vm.showConfig = true + await wrapper.vm.$nextTick() + const btn23 = wrapper.findAll('.btn-mode').find(b => b.text() === '2/3') + await btn23.trigger('click') + expect(wrapper.vm.configData.modalita).toBe('2/3') + expect(btn23.classes()).toContain('active') + }) + }) + + // ============================================= + // GESTIONE CAMBI GIOCATORE + // ============================================= + describe('Gestione cambi', () => { + it('il click su Cambi apre la selezione squadra, poi la modal cambio per la squadra scelta', async () => { + const wrapper = mountController() + const btnCambi = wrapper.findAll('.btn-ctrl').find(b => b.text() === 'Cambi') + await btnCambi.trigger('click') + expect(wrapper.vm.showCambiTeam).toBe(true) + await wrapper.find('.dialog-title').exists() + const btnHome = wrapper.find('.overlay .btn-set.home-bg') + await btnHome.trigger('click') + expect(wrapper.vm.showCambiTeam).toBe(false) + expect(wrapper.vm.showCambi).toBe(true) + expect(wrapper.vm.cambiTeam).toBe('home') + }) + + it('Annulla chiude la modal cambi e azzera i campi', async () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + wrapper.vm.cambiData = [{ in: '10', out: '1' }, { in: '', out: '' }] + await wrapper.vm.$nextTick() + await wrapper.find('.dialog .btn-cancel').trigger('click') + expect(wrapper.vm.showCambi).toBe(false) + expect(wrapper.vm.cambiData).toEqual([{ in: '', out: '' }, { in: '', out: '' }]) + }) + + it('CONFERMA con un cambio valido invia confermaCambi e chiude la modal', async () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + wrapper.vm.cambiData = [{ in: '10', out: '1' }, { in: '', out: '' }] + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await wrapper.find('.dialog .btn-confirm').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'confermaCambi', team: 'home', cambi: [{ in: '10', out: '1' }] }) + expect(wrapper.vm.showCambi).toBe(false) + }) + + it('segnala errore se il numero del giocatore non è formato da sole cifre', async () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + wrapper.vm.cambiData = [{ in: 'ab', out: '1' }, { in: '', out: '' }] + wrapper.vm.confermaCambi() + expect(wrapper.vm.cambiError).toBe('I numeri dei giocatori devono essere cifre') + }) + + it('segnala errore se il giocatore entrante coincide con l\'uscente', async () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + wrapper.vm.cambiData = [{ in: '1', out: '1' }, { in: '', out: '' }] + wrapper.vm.confermaCambi() + expect(wrapper.vm.cambiError).toBe('Il giocatore 1 non può sostituire sé stesso') + }) + + it('segnala errore se il giocatore entrante è già in formazione', async () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + // "2" è già presente nella formazione di default ["1".."6"] + wrapper.vm.cambiData = [{ in: '2', out: '1' }, { in: '', out: '' }] + wrapper.vm.confermaCambi() + expect(wrapper.vm.cambiError).toBe('Il giocatore 2 è già in formazione') + }) + + it('segnala errore se il giocatore uscente non è in formazione', async () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + wrapper.vm.cambiData = [{ in: '10', out: '9' }, { in: '', out: '' }] + wrapper.vm.confermaCambi() + expect(wrapper.vm.cambiError).toBe('Il giocatore 9 non è in formazione') + }) + + it('confermaCambi non fa nulla se cambiValid è false', () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + const spy = vi.spyOn(wrapper.vm, 'sendAction') + wrapper.vm.cambiData = [{ in: '', out: '' }, { in: '', out: '' }] + wrapper.vm.confermaCambi() + expect(spy).not.toHaveBeenCalled() + }) + }) + + // ============================================= + // GESTIONE ERRORI WEBSOCKET + // ============================================= + describe('Gestione errori WebSocket', () => { + it('onWsMessage con type error logga il messaggio', () => { + const wrapper = mountController() + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + wrapper.vm.onWsMessage({ type: 'error', message: 'boom' }) + expect(spy).toHaveBeenCalledWith('[Controller] Error:', 'boom') + spy.mockRestore() + }) + + it('sendAction senza type non invia nulla', () => { + const wrapper = mountController() + const spy = vi.spyOn(wrapper.vm, 'sendWs') + wrapper.vm.sendAction({}) + expect(spy).not.toHaveBeenCalled() + }) + + it('sendAction mostra un feedback di errore se non connesso', () => { + const wrapper = mountController() + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + wrapper.vm.wsConnected = false + wrapper.vm.sendAction({ type: 'incPunt', team: 'home' }) + expect(errSpy).toHaveBeenCalledWith('[Controller] Error:', 'Non connesso al server') + errSpy.mockRestore() + }) + }) + + // ============================================= + // DURATA VIDEO SPOT + // ============================================= + describe('Durata video spot', () => { + it('formatDurata mostra i secondi arrotondati o un placeholder', () => { + const wrapper = mountController() + expect(wrapper.vm.formatDurata(12.4)).toBe('12s') + expect(wrapper.vm.formatDurata(undefined)).toBe('…') + expect(wrapper.vm.formatDurata(NaN)).toBe('…') + }) + + it('probeDurata popola spotDurations quando i metadata del video sono pronti', () => { + const wrapper = mountController() + const videoEls = [] + const originalCreateElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + const el = originalCreateElement(tag) + if (tag === 'video') videoEls.push(el) + return el + }) + wrapper.vm.probeDurata('a.mp4') + const videoEl = videoEls[0] + Object.defineProperty(videoEl, 'duration', { value: 42, configurable: true }) + videoEl.onloadedmetadata() + expect(wrapper.vm.spotDurations['a.mp4']).toBe(42) + document.createElement.mockRestore() + }) + }) + + // ============================================= + // PULSANTI MOBILE (click reali sui bottoni) + // ============================================= + describe('Pulsanti layout mobile', () => { + it('ANNULLA PUNTO invia decPunt', async () => { + const wrapper = mountController() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await wrapper.find('.btn-undo').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'decPunt' }) + }) + + it('i due bottoni SET inviano incSet per la rispettiva squadra', async () => { + const wrapper = mountController() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + const setBtns = wrapper.findAll('.action-row .btn-set') + await setBtns[0].trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'home' }) + await setBtns[1].trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'guest' }) + }) + + it('Formazioni/Striscia/Inverti/Voce inviano le rispettive action', async () => { + const wrapper = mountController() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + const byLabel = (label) => wrapper.findAll('.btn-ctrl').find(b => b.text().includes(label)) + await byLabel('Formazioni').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'toggleFormazione' }) + await byLabel('Striscia').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'toggleStriscia' }) + await byLabel('Inverti').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'toggleOrder' }) + const wsSendSpy = vi.spyOn(wrapper.vm, 'sendWs') + wrapper.vm.wsConnected = true + wrapper.vm.ws = { readyState: 1, send: vi.fn() } + await byLabel('Voce').trigger('click') + expect(wsSendSpy).toHaveBeenCalled() + }) + + it('con order=false lo score-preview mostra prima guest e poi home', async () => { + const wrapper = mountController() + wrapper.vm.state.order = false + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + const scores = wrapper.findAll('.team-score') + expect(scores[0].classes()).toContain('guest-bg') + expect(scores[1].classes()).toContain('home-bg') + await scores[0].trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'guest' }) + }) + }) + + // ============================================= + // MODALITÀ ESTESA: interazioni aggiuntive + // ============================================= + describe('Modalità estesa: interazioni', () => { + it('il click sul punteggio e sul bottone SET della seconda squadra funzionano', async () => { + const wrapper = mountController() + wrapper.vm.isLandscape = true + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await wrapper.findAll('.e-panel__score')[1].trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'guest' }) + await wrapper.findAll('.e-panel__setbtn')[0].trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'home' }) + await wrapper.findAll('.e-panel__setbtn')[1].trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'incSet', team: 'guest' }) + }) + + it('la barra e-actions invia le action corrispondenti ai bottoni', async () => { + const wrapper = mountController() + wrapper.vm.isLandscape = true + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + const byLabel = (label) => wrapper.findAll('.e-act').find(b => b.text().includes(label)) + await byLabel('Annulla').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'decPunt' }) + await byLabel('Cambio Palla').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'cambiaPalla' }) + await byLabel('Inverti').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'toggleOrder' }) + await byLabel('Striscia').trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'toggleStriscia' }) + expect(wrapper.vm.showCambiTeam).toBe(false) + const btnCambi = wrapper.findAll('.e-act').find(b => b.text() === 'Cambi') + await btnCambi.trigger('click') + expect(wrapper.vm.showCambiTeam).toBe(true) + wrapper.vm.showCambiTeam = false + await byLabel('Config').trigger('click') + expect(wrapper.vm.showConfig).toBe(true) + wrapper.vm.showConfig = false + await wrapper.find('.e-act--danger').trigger('click') + expect(wrapper.vm.confirmReset).toBe(true) + }) + + it('il bottone Voce e il bottone Time Out della barra estesa funzionano', async () => { + const wrapper = mountController() + wrapper.vm.isLandscape = true + await wrapper.vm.$nextTick() + wrapper.vm.wsConnected = true + wrapper.vm.ws = { readyState: 1, send: vi.fn() } + const wsSendSpy = vi.spyOn(wrapper.vm, 'sendWs') + const btnVoce = wrapper.findAll('.e-act').find(b => b.text() === 'Voce') + await btnVoce.trigger('click') + expect(wsSendSpy).toHaveBeenCalled() + + const btnTimeout = wrapper.find('.e-act.btn-timeout') + await btnTimeout.trigger('click') + expect(wrapper.vm.showTimeoutModal).toBe(true) + wrapper.vm.showTimeoutModal = false + wrapper.vm.state.timeout = true + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await btnTimeout.trigger('click') + expect(spy).toHaveBeenCalledWith({ type: 'stopTimeout' }) + }) + }) + + // ============================================= + // CHIUSURA DIALOG CLICCANDO FUORI (overlay self) + // ============================================= + describe('Chiusura dialog cliccando fuori', () => { + it('conferma reset si chiude cliccando fuori dal dialog', async () => { + const wrapper = mountController() + wrapper.vm.confirmReset = true + await wrapper.vm.$nextTick() + await wrapper.find('.overlay').trigger('click') + expect(wrapper.vm.confirmReset).toBe(false) + }) + + it('configurazione si chiude cliccando fuori dal dialog', async () => { + const wrapper = mountController() + wrapper.vm.showConfig = true + await wrapper.vm.$nextTick() + await wrapper.find('.overlay').trigger('click') + expect(wrapper.vm.showConfig).toBe(false) + }) + + it('scelta squadra cambi si chiude cliccando fuori dal dialog', async () => { + const wrapper = mountController() + wrapper.vm.showCambiTeam = true + await wrapper.vm.$nextTick() + await wrapper.find('.overlay').trigger('click') + expect(wrapper.vm.showCambiTeam).toBe(false) + }) + + it('la modal cambi si chiude cliccando fuori dal dialog', async () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + await wrapper.vm.$nextTick() + await wrapper.find('.overlay').trigger('click') + expect(wrapper.vm.showCambi).toBe(false) + }) + + it('la modal time out si chiude cliccando fuori dal dialog', async () => { + const wrapper = mountController() + await wrapper.find('.btn-timeout').trigger('click') + await flushPromises() + await wrapper.find('.overlay').trigger('click') + expect(wrapper.vm.showTimeoutModal).toBe(false) + }) + }) + + // ============================================= + // DIALOG PARTITA FINITA / CONFIGURAZIONE: dettagli + // ============================================= + describe('Dettagli dialog', () => { + it('CHIUDI chiude il dialog partita finita senza inviare action', async () => { + const wrapper = mountController() + wrapper.vm.state.modalitaPartita = '2/3' + wrapper.vm.state.sp.striscia = [ + { serv: 'h', ris: '', vinc: 'h' }, + { serv: 'h', ris: '', vinc: null }, + ] + wrapper.vm.setVintoTeam = 'home' + wrapper.vm.showSetVinto = true + await wrapper.vm.$nextTick() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + const chiudi = wrapper.findAll('.btn-confirm').find(b => b.text() === 'CHIUDI') + await chiudi.trigger('click') + expect(spy).not.toHaveBeenCalled() + expect(wrapper.vm.showSetVinto).toBe(false) + }) + + it('isPartitaFinita è false in modalità amichevole anche a set vinto', () => { + const wrapper = mountController() + wrapper.vm.state.modalitaPartita = 'amichevole' + wrapper.vm.setVintoTeam = 'home' + expect(wrapper.vm.isPartitaFinita).toBe(false) + }) + + it('la vittoria della squadra guest apre comunque la modal SET VINTO', async () => { + const wrapper = mountController() + setScore(wrapper, 10, 25, 'g') + await wrapper.vm.$nextTick() + expect(wrapper.vm.setVintoTeam).toBe('guest') + }) + + it('nel set decisivo la soglia di vittoria scende a 15 punti', async () => { + const wrapper = mountController() + // 4 set già assegnati (2 a testa): il quinto è il set decisivo (3/5) + wrapper.vm.state.sp.striscia = [ + { serv: 'h', ris: '', vinc: 'h' }, + { serv: 'h', ris: '', vinc: 'g' }, + { serv: 'h', ris: '', vinc: 'h' }, + { serv: 'h', ris: '', vinc: 'g' }, + { serv: 'h', ris: '', vinc: null }, + ] + setScore(wrapper, 15, 10) + await wrapper.vm.$nextTick() + expect(wrapper.vm.setVintoTeam).toBe('home') + }) + + it('i bottoni modalità 3/5 e amichevole aggiornano configData', async () => { + const wrapper = mountController() + wrapper.vm.showConfig = true + await wrapper.vm.$nextTick() + const btn35 = wrapper.findAll('.btn-mode').find(b => b.text() === '3/5') + await btn35.trigger('click') + expect(wrapper.vm.configData.modalita).toBe('3/5') + const btnAmichevole = wrapper.findAll('.btn-mode').find(b => b.text() === 'Amichevole') + await btnAmichevole.trigger('click') + expect(wrapper.vm.configData.modalita).toBe('amichevole') + expect(btnAmichevole.classes()).toContain('active') + }) + + it('i campi di testo di configurazione aggiornano configData tramite v-model', async () => { + const wrapper = mountController() + wrapper.vm.showConfig = true + await wrapper.vm.$nextTick() + const inputs = wrapper.findAll('.dialog-config .input-field') + await inputs[0].setValue('Casa Nuova') + await inputs[1].setValue('Ospiti Nuovi') + expect(wrapper.vm.configData.nomeHome).toBe('Casa Nuova') + expect(wrapper.vm.configData.nomeGuest).toBe('Ospiti Nuovi') + }) + + it('i campi numerici della formazione aggiornano configData tramite v-model', async () => { + const wrapper = mountController() + wrapper.vm.showConfig = true + await wrapper.vm.$nextTick() + const numInputs = wrapper.findAll('.dialog-config .input-num') + // 6 campi per home + 6 per guest: li valorizziamo tutti per coprire ogni binding v-model + for (let i = 0; i < numInputs.length; i++) { + await numInputs[i].setValue(String(i)) + } + // il DOM ordina le celle come 3,2,1 / 4,5,0: verifichiamo l'insieme dei valori digitati + expect(new Set(wrapper.vm.configData.formHome)).toEqual(new Set(['0', '1', '2', '3', '4', '5'])) + expect(new Set(wrapper.vm.configData.formGuest)).toEqual(new Set(['6', '7', '8', '9', '10', '11'])) + }) + + it('la selezione squadra guest per i cambi apre la modal con cambiTeam=guest', async () => { + const wrapper = mountController() + wrapper.vm.showCambiTeam = true + await wrapper.vm.$nextTick() + await wrapper.find('.overlay .btn-set.guest-bg').trigger('click') + expect(wrapper.vm.cambiTeam).toBe('guest') + expect(wrapper.vm.showCambi).toBe(true) + }) + + it('i campi IN/OUT della modal cambi aggiornano cambiData tramite v-model', async () => { + const wrapper = mountController() + wrapper.vm.openCambi('home') + await wrapper.vm.$nextTick() + const inField = wrapper.find('.cambi-in-field') + const outField = wrapper.find('.cambi-out-field') + await inField.setValue('10') + await outField.setValue('1') + expect(wrapper.vm.cambiData[0]).toEqual({ in: '10', out: '1' }) + }) + + it('nella modal time out si può selezionare un video dall\'elenco tramite radio', async () => { + fetchMock.mockResolvedValueOnce({ json: async () => ['a.mp4'] }) + const wrapper = mountController() + await wrapper.find('.btn-timeout').trigger('click') + await flushPromises() + const radios = wrapper.findAll('input[type=radio]') + await radios[0].setValue(true) + expect(wrapper.vm.timeoutVideoSel).toBe('a.mp4') + await radios[1].setValue(true) + expect(wrapper.vm.timeoutVideoSel).toBe(null) + }) + + it('Annulla nella modal time out la chiude senza avviarlo', async () => { + const wrapper = mountController() + await wrapper.find('.btn-timeout').trigger('click') + await flushPromises() + const spy = vi.spyOn(wrapper.vm, 'sendAction') + await wrapper.find('.dialog-timeout .btn-cancel').trigger('click') + expect(spy).not.toHaveBeenCalled() + expect(wrapper.vm.showTimeoutModal).toBe(false) + }) + }) + // ============================================= // BARRA CONNESSIONE // ============================================= diff --git a/tests/component/DisplayPage.test.js b/tests/component/DisplayPage.test.js index 0cc6167..2527b29 100644 --- a/tests/component/DisplayPage.test.js +++ b/tests/component/DisplayPage.test.js @@ -31,6 +31,15 @@ vi.stubGlobal('speechSynthesis', { getVoices: () => [] }) +// happy-dom non implementa la Web Speech API: stub minimo dell'utterance. +vi.stubGlobal('SpeechSynthesisUtterance', class { + constructor(text) { + this.text = text + this.voice = null + this.lang = '' + } +}) + // happy-dom non implementa la riproduzione media: stub di play/pause/load // così l'overlay time out può montare il