13 Commits

Author SHA1 Message Date
71119da727 feat(test): implementazione infrastruttura completa (Unit, Integration, E2E) con Vitest e Playwright
- Introduce Vitest per Unit e Integration Test.
- Introduce Playwright per End-to-End Test.
- Aggiuge documentazione dettagliata in tests/README.md.
- Aggiorna .gitignore per escludere i report di coverage
2026-02-12 15:13:04 +01:00
331ab0bbeb fix(prod): correzione preview script e routing server produzione
- Modificato [package.json](cci:7://file:///home/davide/segnapunti/package.json:0:0-0:0): lo script "preview" ora esegue "node server.js" per abilitare il backend WebSocket.
- Aggiornato [server.js](cci:7://file:///home/davide/segnapunti/server.js:0:0-0:0): impedito al Controller di servire la Display App per default (opzione `index: false`).
- Corretta sintassi rotte: sostituito `*` con regex `/.*/` per compatibilità con Express 5
2026-02-12 14:11:05 +01:00
94a0b0735f feat(controller): valida cambi giocatori già in formazione lato client 2026-02-12 00:30:36 +01:00
581a567c17 fix(voce): riproduce la sintesi vocale sul display invece che sul controller
Il controller invia un comando 'speak' via WebSocket. Il server inoltra il messaggio solo ai client display, che eseguono speechSynthesis con preferenza per voce italiana.
2026-02-11 19:35:09 +01:00
43194c4fbe Merge branch 'wip-client-server' 2026-02-11 08:02:14 +01:00
917850502d fix(dev): evita problemi IPv6 di localhost su ws e proxy controller
Usa fallback a 127.0.0.1 quando l'hostname è localhost/::1 nei client websocket display/controller.

Instrada il proxy del controller dev verso Vite tramite DEV_PROXY_HOST (default 127.0.0.1).

Mostra gli URL locali del server con 127.0.0.1 per una diagnostica coerente su Raspberry/Linux.
2026-02-11 00:52:06 +01:00
ad7a8575c6 feat(client): aggiungi supporto query parameter wsHost per WebSocket
Permette di specificare manualmente l'host del WebSocket tramite il
parametro ?wsHost=[host:port], utile per scenari di sviluppo con WSL2
o quando si accede da dispositivi remoti.

- Aggiunge parsing del parametro wsHost in DisplayPage e ControllerPage
- Mantiene fallback automatico a location.host se non specificato
- Migliora diagnostica con log della URL WebSocket effettiva
2026-02-11 00:37:31 +01:00
f84f3805cd feat: separazione display e controller su porte distinte (5173/3001)
- Creati entry point separati per il Display (porta 5173) e il Controller (porta 3001).
- Aggiunti controller.html e src/controller-main.js per l'app di controllo remoto.
- Semplificato src/main.js per montare direttamente DisplayPage, rimuovendo vue-router.
- Implementato un server di sviluppo proxy per il controller in vite-plugin-websocket.js.
- Aggiornato server.js per gestire due istanze Express (display e controller) in produzione.
- Aggiornata la configurazione di Vite per il supporto alla build multi-pagina
2026-02-10 23:45:58 +01:00
9598d587c6 chore(dev): aggiorna workflow locale e configurazione Vite
Introduce script di sviluppo concorrenti (frontend + server) con concurrently.

Aggiorna dipendenze lockfile e rimuove dipendenze non più necessarie.

Aggiunge configurazione server/proxy Vite e include plugin WebSocket dedicato.
2026-02-10 09:54:38 +01:00
f44138efd3 chore(cleanup): rimuove l'implementazione HomePage legacy non più utilizzata
Elimina componenti, template e stili della vecchia HomePage.

Riduce codice morto e semplifica la manutenzione del progetto.
2026-02-10 09:54:31 +01:00
082a52dc3e feat(client): migliora robustezza connessioni WebSocket su display e controller
Aggiunge gestione riconnessione con backoff esponenziale e protezione da reconnect multipli.

Migliora cleanup su unmount/HMR per evitare listener e timeout pendenti.

Uniforma gestione errori e stato connessione lato client.

Semplifica etichette pulsanti controller rimuovendo emoji e aggiorna commenti.
2026-02-10 09:54:10 +01:00
f7c4fdc2ef refactor(server): separa la logica WebSocket e centralizza le utility di avvio
Estrae la gestione dei messaggi WebSocket in un modulo dedicato.

Rende server.js più snello e focalizzato su bootstrap HTTP/WS.

Introduce utility per stampa URL di accesso e discovery IP di rete.

Mantiene la logica di stato partita condivisa in gameState.js.
2026-02-10 09:53:46 +01:00
a40fad7194 Separa app in client-server con WebSocket
- Aggiunto server Express + WebSocket (server.js)
- Creata pagina Display (solo visualizzazione punteggio)
- Creata pagina Controller (pannello comandi da mobile)
- Aggiunto Vue Router con rotte / e /controller
- Estratta logica di gioco condivisa in gameState.js
2026-02-10 00:42:48 +01:00
29 changed files with 5784 additions and 1259 deletions

10
.gitignore vendored
View File

@@ -25,3 +25,13 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
# Vitest
coverage/

13
controller.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Segnapunti - Controller</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/controller-main.js"></script>
</body>
</html>

3352
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,32 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "node server.js",
"start": "node server.js",
"serve": "vite build && node server.js",
"test": "vitest",
"test:unit": "vitest run tests/unit tests/integration",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:codegen": "playwright codegen"
},
"dependencies": {
"nosleep.js": "^0.12.0",
"express": "^5.2.1",
"vue": "^3.2.47",
"wave-ui": "^3.3.0"
"vue-router": "^4.6.4",
"wave-ui": "^3.3.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "^25.2.3",
"@vitejs/plugin-vue": "^4.1.0",
"@vitest/ui": "^4.0.18",
"concurrently": "^9.2.1",
"jsdom": "^28.0.0",
"vite": "^4.3.9",
"vite-plugin-pwa": "^0.16.0"
"vite-plugin-pwa": "^0.16.0",
"vitest": "^4.0.18"
}
}
}

80
playwright.config.ts Normal file
View File

@@ -0,0 +1,80 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run serve',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});

83
server.js Normal file
View File

@@ -0,0 +1,83 @@
import { createServer } from 'http'
import express from 'express'
import { WebSocketServer } from 'ws'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// --- Configurazione del server ---
const DISPLAY_PORT = process.env.PORT || 3000
const CONTROLLER_PORT = process.env.CONTROLLER_PORT || 3001
// ========================================
// Server Display (porta principale)
// ========================================
const displayApp = express()
// Espone i file generati dalla build di Vite.
displayApp.use(express.static(join(__dirname, 'dist')))
// Fallback per SPA: restituisce `index.html` per tutte le route.
displayApp.get(/.*/, (_req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'))
})
const displayServer = createServer(displayApp)
// Inizializza il server WebSocket condiviso.
const wss = new WebSocketServer({ noServer: true })
setupWebSocketHandler(wss)
displayServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname
if (pathname === '/ws') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)
})
} else {
socket.destroy()
}
})
displayServer.listen(DISPLAY_PORT, '0.0.0.0', () => {
console.log(`[Display] Server running on port ${DISPLAY_PORT}`)
})
// ========================================
// Server Controller (porta separata)
// ========================================
const controllerApp = express()
// Espone gli stessi file statici della build.
// IMPORTANTE: { index: false } impedisce di servire index.html (il display) sulla root.
controllerApp.use(express.static(join(__dirname, 'dist'), { index: false }))
// Fallback: restituisce `controller.html` per tutte le route.
controllerApp.get(/.*/, (_req, res) => {
res.sendFile(join(__dirname, 'dist', 'controller.html'))
})
const controllerServer = createServer(controllerApp)
// Gestisce l'upgrade WebSocket anche sulla porta del controller.
controllerServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname
if (pathname === '/ws') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)
})
} else {
socket.destroy()
}
})
controllerServer.listen(CONTROLLER_PORT, '0.0.0.0', () => {
printServerInfo(DISPLAY_PORT, CONTROLLER_PORT)
})

View File

@@ -1,7 +1,3 @@
<script setup>
import HomePage from './components/HomePage/index.vue'
</script>
<template>
<HomePage />
<router-view />
</template>

View File

@@ -0,0 +1,908 @@
<template>
<section class="controller-page">
<!-- Barra di stato connessione -->
<div class="conn-bar" :class="{ connected: wsConnected }">
<span class="dot"></span>
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
</div>
<!-- Anteprima punteggio -->
<div class="score-preview">
<div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })">
<div class="team-name">{{ state.sp.nomi.home }}</div>
<div class="team-pts">{{ state.sp.punt.home }}</div>
<div class="team-set">SET {{ state.sp.set.home }}</div>
<img v-show="state.sp.servHome" src="/serv.png" class="serv-icon" />
</div>
<div class="team-score guest-bg" @click="sendAction({ type: 'incPunt', team: 'guest' })">
<div class="team-name">{{ state.sp.nomi.guest }}</div>
<div class="team-pts">{{ state.sp.punt.guest }}</div>
<div class="team-set">SET {{ state.sp.set.guest }}</div>
<img v-show="!state.sp.servHome" src="/serv.png" class="serv-icon" />
</div>
</div>
<!-- Riga annulla punto -->
<div class="undo-row">
<button class="btn btn-undo" @click="sendAction({ type: 'decPunt' })">
ANNULLA PUNTO
</button>
</div>
<!-- Pulsanti set -->
<div class="action-row">
<button class="btn btn-set home-bg" @click="sendAction({ type: 'incSet', team: 'home' })">
SET {{ state.sp.nomi.home }}
</button>
<button class="btn btn-set guest-bg" @click="sendAction({ type: 'incSet', team: 'guest' })">
SET {{ state.sp.nomi.guest }}
</button>
</div>
<!-- Controlli principali -->
<div class="controls">
<button class="btn btn-ctrl" @click="sendAction({ type: 'cambiaPalla' })" :disabled="!isPunteggioZeroZero">
Cambio Palla
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleFormazione' })">
{{ state.visuForm ? 'Punteggio' : 'Formazioni' }}
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleStriscia' })">
Striscia
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleOrder' })">
Inverti
</button>
<button class="btn btn-ctrl" @click="speak()">
Voce
</button>
<button class="btn btn-ctrl" @click="openConfig()">
Config
</button>
<button class="btn btn-ctrl" @click="openCambiTeam()">
Cambi
</button>
<button class="btn btn-danger" @click="confirmReset = true">
Reset
</button>
</div>
<!-- Finestra conferma reset -->
<div class="overlay" v-if="confirmReset" @click.self="confirmReset = false">
<div class="dialog">
<div class="dialog-title">Azzero punteggio?</div>
<div class="dialog-buttons">
<button class="btn btn-cancel" @click="confirmReset = false">NO</button>
<button class="btn btn-confirm" @click="doReset()">SI</button>
</div>
</div>
</div>
<!-- Finestra configurazione -->
<div class="overlay" v-if="showConfig" @click.self="showConfig = false">
<div class="dialog dialog-config">
<div class="dialog-title">Configurazione</div>
<div class="form-group">
<label>Nome Home</label>
<input type="text" v-model="configData.nomeHome" class="input-field" />
</div>
<div class="form-group">
<label>Nome Guest</label>
<input type="text" v-model="configData.nomeGuest" class="input-field" />
</div>
<div class="form-group">
<label>Modalità partita</label>
<div class="mode-buttons">
<button :class="['btn', 'btn-mode', configData.modalita === '2/3' ? 'active' : '']"
@click="configData.modalita = '2/3'">2/3</button>
<button :class="['btn', 'btn-mode', configData.modalita === '3/5' ? 'active' : '']"
@click="configData.modalita = '3/5'">3/5</button>
</div>
</div>
<div class="form-group">
<label>Formazione Home</label>
<div class="form-grid">
<div class="form-row">
<input type="text" v-model="configData.formHome[3]" class="input-num" />
<input type="text" v-model="configData.formHome[2]" class="input-num" />
<input type="text" v-model="configData.formHome[1]" class="input-num" />
</div>
<div class="form-line"></div>
<div class="form-row">
<input type="text" v-model="configData.formHome[4]" class="input-num" />
<input type="text" v-model="configData.formHome[5]" class="input-num" />
<input type="text" v-model="configData.formHome[0]" class="input-num" />
</div>
</div>
</div>
<div class="form-group">
<label>Formazione Guest</label>
<div class="form-grid">
<div class="form-row">
<input type="text" v-model="configData.formGuest[3]" class="input-num" />
<input type="text" v-model="configData.formGuest[2]" class="input-num" />
<input type="text" v-model="configData.formGuest[1]" class="input-num" />
</div>
<div class="form-line"></div>
<div class="form-row">
<input type="text" v-model="configData.formGuest[4]" class="input-num" />
<input type="text" v-model="configData.formGuest[5]" class="input-num" />
<input type="text" v-model="configData.formGuest[0]" class="input-num" />
</div>
</div>
</div>
<div class="dialog-buttons">
<button class="btn btn-cancel" @click="showConfig = false">Annulla</button>
<button class="btn btn-confirm" @click="saveConfig()">OK</button>
</div>
</div>
</div>
<!-- Selezione squadra per cambi -->
<div class="overlay" v-if="showCambiTeam" @click.self="showCambiTeam = false">
<div class="dialog">
<div class="dialog-title">Scegli squadra</div>
<div class="dialog-buttons">
<button class="btn btn-set home-bg" @click="openCambi('home')">{{ state.sp.nomi.home }}</button>
<button class="btn btn-set guest-bg" @click="openCambi('guest')">{{ state.sp.nomi.guest }}</button>
</div>
</div>
</div>
<!-- Finestra gestione cambi -->
<div class="overlay" v-if="showCambi" @click.self="closeCambi()">
<div class="dialog">
<div class="dialog-title">{{ state.sp.nomi[cambiTeam] }}: CAMBIO</div>
<div class="cambi-container">
<div class="cambio-row" v-for="(c, i) in cambiData" :key="i">
<input type="text" v-model="c.in" placeholder="IN" class="input-num cambi-in-field" />
<span class="cambio-arrow"></span>
<input type="text" v-model="c.out" placeholder="OUT" class="input-num cambi-out-field" />
</div>
</div>
<div v-if="cambiError" class="cambi-error">{{ cambiError }}</div>
<div class="dialog-buttons">
<button class="btn btn-cancel" @click="closeCambi()">Annulla</button>
<button class="btn btn-confirm" :disabled="!cambiValid" @click="confermaCambi()">CONFERMA</button>
</div>
</div>
</div>
</section>
</template>
<script>
export default {
name: "ControllerPage",
data() {
return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000,
confirmReset: false,
showConfig: false,
showCambiTeam: false,
showCambi: false,
cambiTeam: "home",
cambiData: [
{ in: "", out: "" },
{ in: "", out: "" },
],
cambiError: "",
configData: {
nomeHome: "",
nomeGuest: "",
modalita: "3/5",
formHome: ["1", "2", "3", "4", "5", "6"],
formGuest: ["1", "2", "3", "4", "5", "6"],
},
state: {
order: true,
visuForm: false,
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: { home: [0], guest: [0] },
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
storicoServizio: [],
},
},
}
},
computed: {
isPunteggioZeroZero() {
return this.state.sp.punt.home === 0 && this.state.sp.punt.guest === 0
},
cambiValid() {
let hasComplete = false
let allValid = true
this.cambiData.forEach((c) => {
const cin = (c.in || "").trim()
const cout = (c.out || "").trim()
if (!cin && !cout) return
if (!cin || !cout) { allValid = false; return }
hasComplete = true
})
return allValid && hasComplete
}
},
mounted() {
this.connectWebSocket()
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
// Annulla eventuali tentativi di riconnessione pianificati.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
},
beforeUnmount() {
// Pulisce il timeout di riconnessione.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
// Chiude il WebSocket con il codice di chiusura appropriato.
if (this.ws) {
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Se la connessione e ancora in fase di apertura, chiude direttamente.
this.ws.close()
}
} catch (err) {
console.error('[Controller] Error closing WebSocket:', err)
}
this.ws = null
}
},
methods: {
connectWebSocket() {
// Evita connessioni simultanee multiple.
if (this.isConnecting) {
console.log('[Controller] Already connecting, skipping...')
return
}
// Chiude la connessione precedente, se presente.
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error('[Controller] Error closing previous WebSocket:', err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
// Permette di specificare un host WebSocket alternativo via query parameter
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:3001
const params = new URLSearchParams(location.search)
const defaultHost = (
location.hostname === 'localhost' || location.hostname === '::1'
)
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
: location.host
const wsHost = params.get('wsHost') || defaultHost
const wsUrl = `${protocol}//${wsHost}/ws`
console.log('[Controller] Connecting to WebSocket:', wsUrl)
try {
this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error('[Controller] Failed to create WebSocket:', err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true
this.reconnectAttempts = 0
console.log('[Controller] Connected to server')
// Invia la registrazione solo se la connessione e realmente aperta.
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'register', role: 'controller' }))
} catch (err) {
console.error('[Controller] Failed to register:', err)
}
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'state') {
this.state = msg.state
} else if (msg.type === 'error') {
console.error('[Controller] Server error:', msg.message)
// Fornisce feedback di errore all'utente.
this.showErrorFeedback(msg.message)
}
} catch (e) {
console.error('[Controller] Parse error:', e)
}
}
this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false
console.log('[Controller] Disconnected from server', event.code, event.reason)
// Non riconnette durante HMR (codice 1001, "going away")
// ne in caso di chiusura pulita (codice 1000).
if (event.code === 1000 || event.code === 1001) {
console.log('[Controller] Clean close, not reconnecting')
return
}
this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('[Controller] WebSocket error:', err)
this.isConnecting = false
this.wsConnected = false
}
},
scheduleReconnect() {
// Evita pianificazioni multiple della riconnessione.
if (this.reconnectTimeout) {
return
}
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
)
this.reconnectAttempts++
console.log(`[Controller] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
},
sendAction(action) {
if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn('[Controller] Cannot send action: not connected')
this.showErrorFeedback('Non connesso al server')
return
}
// Valida l'azione prima dell'invio.
if (!action || !action.type) {
console.error('[Controller] Invalid action format:', action)
return
}
try {
this.ws.send(JSON.stringify({ type: 'action', action }))
} catch (err) {
console.error('[Controller] Failed to send action:', err)
this.showErrorFeedback('Errore invio comando')
}
},
showErrorFeedback(message) {
// Feedback visivo degli errori: attualmente solo log su console.
// In futuro puo essere esteso con notifiche a comparsa (toast).
console.error('[Controller] Error:', message)
},
doReset() {
this.sendAction({ type: 'resetta' })
this.confirmReset = false
},
openConfig() {
this.configData.nomeHome = this.state.sp.nomi.home
this.configData.nomeGuest = this.state.sp.nomi.guest
this.configData.modalita = this.state.modalitaPartita
this.configData.formHome = [...this.state.sp.form.home]
this.configData.formGuest = [...this.state.sp.form.guest]
this.showConfig = true
},
saveConfig() {
this.sendAction({ type: 'setNomi', home: this.configData.nomeHome, guest: this.configData.nomeGuest })
this.sendAction({ type: 'setModalita', modalita: this.configData.modalita })
this.sendAction({ type: 'setFormazione', team: 'home', form: this.configData.formHome })
this.sendAction({ type: 'setFormazione', team: 'guest', form: this.configData.formGuest })
this.showConfig = false
},
openCambiTeam() {
this.showCambiTeam = true
},
openCambi(team) {
this.showCambiTeam = false
this.cambiTeam = team
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
this.cambiError = ""
this.showCambi = true
},
closeCambi() {
this.showCambi = false
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
this.cambiError = ""
},
confermaCambi() {
if (!this.cambiValid) return
this.cambiError = ""
const cambi = this.cambiData
.filter(c => (c.in || "").trim() && (c.out || "").trim())
.map(c => ({ in: c.in.trim(), out: c.out.trim() }))
// Simula i cambi in sequenza per validare
const formCorrente = this.state.sp.form[this.cambiTeam].map(v => String(v).trim())
const formSimulata = [...formCorrente]
for (const cambio of cambi) {
if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) {
this.cambiError = "I numeri dei giocatori devono essere cifre"
return
}
if (cambio.in === cambio.out) {
this.cambiError = `Il giocatore ${cambio.in} non può sostituire sé stesso`
return
}
if (formSimulata.includes(cambio.in)) {
this.cambiError = `Il giocatore ${cambio.in} è già in formazione`
return
}
if (!formSimulata.includes(cambio.out)) {
this.cambiError = `Il giocatore ${cambio.out} non è in formazione`
return
}
const idx = formSimulata.indexOf(cambio.out)
formSimulata.splice(idx, 1, cambio.in)
}
this.sendAction({ type: 'confermaCambi', team: this.cambiTeam, cambi })
this.closeCambi()
},
speak() {
let text = ''
if (this.state.sp.punt.home + this.state.sp.punt.guest === 0) {
text = "zero a zero"
} else if (this.state.sp.punt.home === this.state.sp.punt.guest) {
text = this.state.sp.punt.home + " pari"
} else {
if (this.state.sp.servHome) {
text = this.state.sp.punt.home + " a " + this.state.sp.punt.guest
} else {
text = this.state.sp.punt.guest + " a " + this.state.sp.punt.home
}
}
if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.showErrorFeedback('Non connesso al server')
return
}
try {
this.ws.send(JSON.stringify({ type: 'speak', text }))
} catch (err) {
console.error('[Controller] Failed to send speak command:', err)
this.showErrorFeedback('Errore invio comando voce')
}
}
}
}
</script>
<style scoped>
.controller-page {
min-height: 100vh;
background: #111;
color: #fff;
padding: 8px;
padding-top: 36px;
font-family: 'Inter', system-ui, sans-serif;
-webkit-user-select: none;
user-select: none;
}
/* Barra stato connessione */
.conn-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
background: #c62828;
color: white;
z-index: 200;
transition: background 0.3s;
}
.conn-bar.connected {
background: #2e7d32;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
}
/* Anteprima punteggio */
.score-preview {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.team-score {
flex: 1;
border-radius: 16px;
padding: 16px 12px;
text-align: center;
cursor: pointer;
position: relative;
transition: transform 0.1s;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.team-score:active {
transform: scale(0.97);
}
.home-bg {
background: linear-gradient(145deg, #1a1a1a, #333);
border: 2px solid #fdd835;
color: #fdd835;
}
.guest-bg {
background: linear-gradient(145deg, #0d47a1, #1565c0);
border: 2px solid #64b5f6;
color: #fff;
}
.team-name {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.team-pts {
font-size: 56px;
font-weight: 900;
line-height: 1;
}
.team-set {
font-size: 13px;
font-weight: 600;
opacity: 0.75;
margin-top: 4px;
}
.serv-icon {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
}
/* Riga annulla punto */
.undo-row {
margin-bottom: 8px;
}
.btn-undo {
width: 100%;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2);
color: #ffab91;
padding: 10px;
font-size: 14px;
font-weight: 700;
border-radius: 12px;
cursor: pointer;
}
.btn-undo:active {
background: rgba(255,100,50,0.2);
}
/* Pulsanti set */
.action-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.btn-set {
flex: 1;
padding: 12px;
font-size: 13px;
font-weight: 700;
border-radius: 12px;
cursor: pointer;
border: none;
text-transform: uppercase;
}
.btn-set:active {
transform: scale(0.97);
}
/* Griglia controlli */
.controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.btn {
border: none;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.btn-ctrl {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
color: #e0e0e0;
padding: 14px 8px;
font-size: 14px;
font-weight: 600;
border-radius: 12px;
transition: background 0.15s;
}
.btn-ctrl:active {
background: rgba(255,255,255,0.18);
}
.btn-ctrl:disabled {
opacity: 0.35;
}
.btn-danger {
background: rgba(198, 40, 40, 0.25);
border: 1px solid rgba(239, 83, 80, 0.4);
color: #ef5350;
padding: 14px 8px;
font-size: 14px;
font-weight: 600;
border-radius: 12px;
}
.btn-danger:active {
background: rgba(198, 40, 40, 0.45);
}
/* Overlay e finestre modali */
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
padding: 16px;
}
.dialog {
background: #1e1e1e;
border-radius: 20px;
padding: 24px;
width: 100%;
max-width: 400px;
border: 1px solid rgba(255,255,255,0.12);
}
.dialog-config {
max-height: 85vh;
overflow-y: auto;
}
.dialog-title {
font-size: 18px;
font-weight: 800;
text-align: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255,255,255,0.12);
}
.dialog-buttons {
display: flex;
gap: 12px;
margin-top: 20px;
}
.btn-cancel {
flex: 1;
background: rgba(255,255,255,0.08);
color: #aaa;
padding: 12px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
}
.btn-confirm {
flex: 1;
background: #2e7d32;
color: white;
padding: 12px;
border-radius: 12px;
font-size: 15px;
font-weight: 700;
}
.btn-confirm:disabled {
opacity: 0.4;
}
/* Gruppi form */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #aaa;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-field {
width: 100%;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2);
color: white;
padding: 10px 14px;
border-radius: 10px;
font-size: 16px;
box-sizing: border-box;
}
.input-field:focus {
outline: none;
border-color: #64b5f6;
}
.input-num {
width: 52px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2);
color: white;
padding: 8px;
border-radius: 8px;
font-size: 18px;
text-align: center;
box-sizing: border-box;
}
.input-num:focus {
outline: none;
border-color: #64b5f6;
}
/* Griglia formazione */
.form-grid {
background: rgba(205, 133, 63, 0.15);
border: 2px solid rgba(255,255,255,0.15);
border-radius: 12px;
padding: 12px;
}
.form-row {
display: flex;
justify-content: center;
gap: 10px;
padding: 8px 0;
}
.form-line {
border-top: 1px dashed rgba(255,255,255,0.3);
margin: 4px 0;
}
/* Pulsanti modalita */
.mode-buttons {
display: flex;
gap: 8px;
}
.btn-mode {
flex: 1;
padding: 10px;
background: rgba(255,255,255,0.08);
color: #aaa;
border-radius: 10px;
font-size: 16px;
font-weight: 700;
transition: all 0.15s;
}
.btn-mode.active {
background: #2e7d32;
color: white;
}
/* Sezione cambi */
.cambi-container {
display: flex;
flex-direction: column;
gap: 14px;
padding: 8px 0;
}
.cambio-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.cambio-arrow {
font-size: 20px;
font-weight: 700;
color: #aaa;
}
.cambi-in-field {
background: rgba(120, 200, 120, 0.2) !important;
border-color: rgba(120, 200, 120, 0.4) !important;
}
.cambi-out-field {
background: rgba(200, 120, 120, 0.2) !important;
border-color: rgba(200, 120, 120, 0.4) !important;
}
.cambi-error {
color: #ff6b6b;
font-size: 14px;
text-align: center;
padding: 8px 0;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,419 @@
<template>
<section class="display-page">
<div class="campo">
<span v-if="state.order">
<!-- Ordine visualizzazione: home / guest -->
<div class="hea home">
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.home }}
<span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.home }}</span>
</div>
<div class="hea guest">
<span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
<span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" />
</span>
{{ state.sp.nomi.guest }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.guest }}</span>
</div>
<span v-if="state.visuForm">
<div class="col form home">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'hf'+x">
{{ state.sp.form.home[x] }}
</div>
</div>
<div class="col form guest">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'gf'+x">
{{ state.sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt home">{{ state.sp.punt.home }}</div>
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
</div>
</span>
</span>
<span v-else>
<!-- Ordine visualizzazione: guest / home -->
<div class="hea guest">
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.guest }}
<span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.guest }}</span>
</div>
<div class="hea home">
<span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
<span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" />
</span>
{{ state.sp.nomi.home }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.home }}</span>
</div>
<span v-if="state.visuForm">
<div class="col form guest">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'gf2'+x">
{{ state.sp.form.guest[x] }}
</div>
</div>
<div class="col form home">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]" :key="'hf2'+x">
{{ state.sp.form.home[x] }}
</div>
</div>
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
<div class="col punt home">{{ state.sp.punt.home }}</div>
</div>
</span>
</span>
<div class="striscia" v-if="state.visuStriscia">
<div>
<span class="text-bold mr1">{{ state.sp.nomi.home }}</span>
<div v-for="(h, i) in state.sp.striscia.home" :key="'sh'+i" class="item">
{{ String(h) }}
</div>
</div>
<div class="guest-striscia">
<span class="text-bold mr1">{{ state.sp.nomi.guest }}</span>
<div v-for="(h, i) in state.sp.striscia.guest" :key="'sg'+i" class="item">
{{ String(h) }}
</div>
</div>
</div>
<!-- Indicatore stato connessione -->
<div class="connection-status" :class="{ connected: wsConnected, disconnected: !wsConnected }">
<span class="dot"></span>
{{ wsConnected ? '' : 'Disconnesso' }}
</div>
</div>
</section>
</template>
<script>
export default {
name: "DisplayPage",
data() {
return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000, // Ritardo massimo di riconnessione: 30 secondi
state: {
order: true,
visuForm: false,
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: { home: [0], guest: [0] },
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
storicoServizio: [],
},
},
}
},
mounted() {
this.connectWebSocket()
// Attiva la modalita fullscreen su dispositivi mobili.
if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {}
}
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
// Annulla eventuali tentativi di riconnessione pianificati.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
},
beforeUnmount() {
// Pulisce il timeout di riconnessione.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
// Chiude il WebSocket con il codice di chiusura appropriato.
if (this.ws) {
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Se la connessione e ancora in fase di apertura, chiude direttamente.
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing WebSocket:', err)
}
this.ws = null
}
},
methods: {
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
},
connectWebSocket() {
// Evita connessioni simultanee multiple.
if (this.isConnecting) {
console.log('[Display] Already connecting, skipping...')
return
}
// Chiude la connessione precedente, se presente.
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing previous WebSocket:', err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
// Permette di specificare un host WebSocket alternativo via query parameter
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:5173
const params = new URLSearchParams(location.search)
const defaultHost = (
location.hostname === 'localhost' || location.hostname === '::1'
)
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
: location.host
const wsHost = params.get('wsHost') || defaultHost
const wsUrl = `${protocol}//${wsHost}/ws`
console.log('[Display] Connecting to WebSocket:', wsUrl)
try {
this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error('[Display] Failed to create WebSocket:', err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true
this.reconnectAttempts = 0
console.log('[Display] Connected to server')
// Registra il client come display solo con connessione effettivamente aperta.
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'register', role: 'display' }))
} catch (err) {
console.error('[Display] Failed to register:', err)
}
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'state') {
this.state = msg.state
} else if (msg.type === 'speak') {
this.speakOnDisplay(msg.text)
} else if (msg.type === 'error') {
console.error('[Display] Server error:', msg.message)
}
} catch (e) {
console.error('[Display] Error parsing message:', e)
}
}
this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false
console.log('[Display] Disconnected from server', event.code, event.reason)
// Non riconnette durante HMR (codice 1001, "going away")
// ne in caso di chiusura pulita (codice 1000).
if (event.code === 1000 || event.code === 1001) {
console.log('[Display] Clean close, not reconnecting')
return
}
this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('[Display] WebSocket error:', err)
this.isConnecting = false
this.wsConnected = false
}
},
scheduleReconnect() {
// Evita pianificazioni multiple della riconnessione.
if (this.reconnectTimeout) {
return
}
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
)
this.reconnectAttempts++
console.log(`[Display] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
},
speakOnDisplay(text) {
if (typeof text !== 'string' || !text.trim()) {
return
}
if (!('speechSynthesis' in window)) {
console.warn('[Display] speechSynthesis not supported')
return
}
const utterance = new SpeechSynthesisUtterance(text.trim())
const voices = window.speechSynthesis.getVoices()
const preferredVoice = voices.find((v) => v.name === 'Google italiano')
|| voices.find((v) => v.lang && v.lang.toLowerCase().startsWith('it'))
if (preferredVoice) {
utterance.voice = preferredVoice
}
utterance.lang = 'it-IT'
window.speechSynthesis.cancel()
window.speechSynthesis.speak(utterance)
}
}
}
</script>
<style scoped>
.display-page {
width: 100%;
height: 100vh;
background: #000;
overflow: hidden;
}
.connection-status {
position: fixed;
top: 8px;
right: 8px;
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 5px;
z-index: 100;
transition: opacity 0.5s;
}
.connection-status.connected {
opacity: 0;
}
.connection-status.disconnected {
background: rgba(255, 50, 50, 0.8);
color: white;
opacity: 1;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.connected .dot {
background: #4caf50;
}
.disconnected .dot {
background: #f44336;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.guest-striscia {
color: white;
}
.punteggio-container {
width: 100%;
display: flex;
}
.punt {
font-size: 60vh;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 50vh;
min-width: 50vw;
max-width: 50vw;
overflow: hidden;
box-sizing: border-box;
}
</style>

View File

@@ -1,220 +0,0 @@
<script>
import NoSleep from "nosleep.js";
export default {
name: "HomePage",
components: {},
data() {
return {
voices: null,
diaNomi: {
show: false,
home: "",
guest: "",
},
visuForm: false,
visuButt: true,
sp: {
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
},
}
},
mounted() {
this.voices = window.speechSynthesis.getVoices();
if (this.isMobile()) {
this.speak();
var noSleep = new NoSleep();
noSleep.enable();
document.documentElement.requestFullscreen();
}
this.abilitaTastiSpeciali();
},
methods: {
closeApp() {
var win = window.open("", "_self");
win.close();
},
fullScreen() {
document.documentElement.requestFullscreen();
},
isMobile() {
if (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
) {
return true;
} else {
return false;
}
},
resetta() {
this.$waveui.notify("Punteggio<br />RESETTATO", "success");
this.visuForm = false;
this.sp.punt.home = 0;
this.sp.punt.guest = 0;
this.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
},
incSet(team) {
if (this.sp.set[team] == 2) {
this.sp.set[team] = 0;
} else {
this.sp.set[team]++;
}
},
incPunt(team) {
this.sp.punt[team]++;
this.sp.servHome = (team == "home");
this.sp.form[team].push(this.sp.form[team].shift());
},
decPunt(team) {
// decrementa il punteggio se è > 0.
if (this.sp.punt[team] > 0) {
this.sp.punt[team]--;
this.sp.form[team].unshift(this.sp.form[team].pop());
}
},
speak() {
const msg = new SpeechSynthesisUtterance();
if (this.sp.punt.home + this.sp.punt.guest == 0) {
msg.text = "zero a zero";
} else if (this.sp.punt.home == this.sp.punt.guest) {
msg.text = this.sp.punt.home + " pari";
} else {
if (this.sp.servHome) {
msg.text = this.sp.punt.home + " a " + this.sp.punt.guest;
} else {
msg.text = this.sp.punt.guest + " a " + this.sp.punt.home;
}
}
// msg.volume = 1.0; // speech volume (default: 1.0)
// msg.pitch = 1.0; // speech pitch (default: 1.0)
// msg.rate = 1.0; // speech rate (default: 1.0)
msg.lang = 'it_IT'; // speech language (default: 'en-US')
const voices = window.speechSynthesis.getVoices();
msg.voice = voices.find(voice => voice.name === 'Google italiano'); // voice URI (default: platform-dependent)
// msg.onboundary = function (event) {
// console.log('Speech reached a boundary:', event.name);
// };
// msg.onpause = function (event) {
// console.log('Speech paused:', event.utterance.text.substring(event.charIndex));
// };
window.speechSynthesis.speak(msg);
},
apriDialogConfig() {
this.disabilitaTastiSpeciali();
this.diaNomi.show = true;
},
disabilitaTastiSpeciali() {
window.removeEventListener("keydown", this.funzioneTastiSpeciali);
},
abilitaTastiSpeciali() {
window.addEventListener("keydown", this.funzioneTastiSpeciali);
},
funzioneTastiSpeciali(e) {
e.preventDefault();
if (e.ctrlKey && e.key == "m") {
this.diaNomi.show = true
} else if (e.ctrlKey && e.key == "b") {
this.visuButt = !this.visuButt
} else if (e.ctrlKey && e.key == "f") {
document.documentElement.requestFullscreen();
} else if (e.ctrlKey && e.key == "s") {
this.speak();
} else if (e.ctrlKey && e.key == "z") {
this.visuForm = !this.visuForm
} else if (e.ctrlKey && e.key == "ArrowUp") {
this.incPunt("home")
} else if (e.ctrlKey && e.key == "ArrowDown") {
this.decPunt("home")
} else if (e.ctrlKey && e.key == "ArrowRight") {
this.incSet("home")
} else if (e.shiftKey && e.key == "ArrowUp") {
this.incPunt("guest")
} else if (e.shiftKey && e.key == "ArrowDown") {
this.decPunt("guest")
} else if (e.shiftKey && e.key == "ArrowRight") {
this.incSet("guest")
} else if (e.ctrlKey && e.key == "ArrowLeft") {
this.sp.servHome = !this.sp.servHome
} else { return false }
}
}
};
</script>
<template>
<w-dialog v-model="diaNomi.show" :width="500" @close="abilitaTastiSpeciali()">
<w-input v-model="sp.nomi.home" type="text" class="pa3">Home</w-input>
<w-input v-model="sp.nomi.guest" type="text" class="pa3">Guest</w-input>
<w-button bg-color="success" @click="diaNomi.show = false">
Ok
</w-button>
</w-dialog>
<div class="campo">
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'left' }">
{{ sp.nomi.home }} <img v-if="sp.servHome" src="/serv.png" width="25" />
<span v-if="visuForm">{{ sp.punt.home }}</span>
</span>
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
</div>
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'right' }">
<img v-if="!sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.guest }}
<span v-if="visuForm">{{ sp.punt.guest }}</span>
</span>
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
</div>
<span v-if="visuForm">
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
<div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
</span>
<div class="bot" v-if="visuButt">
<w-flex justify-space-between class="pa2">
<w-confirm right align-bottom v-if="isMobile()" question="CHIUDO ?" cancel="NO" confirm="SI" @confirm="closeApp">
<img src="/exit.png" width="25" />
</w-confirm>
<w-button @click="apriDialogConfig()">
<img src="/gear.png" width="25" />
</w-button>
<w-button @click="sp.servHome = !sp.servHome">
<img src="/serv.png" width="25" />
</w-button>
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
RESET
</w-confirm>
<w-button @click="visuForm = !visuForm">
<span v-if="visuForm">PUNTEGGIO</span>
<span v-if="!visuForm">FORMAZIONI</span>
</w-button>
<w-button @click="speak">
<img src="/speaker.png" width="25" />
</w-button>
</w-flex>
</div>
</div>
</template>

View File

@@ -1,240 +0,0 @@
<section class="homepage">
<w-dialog v-model="diaNomi.show" :width="600" @close="chiudiDialogConfig()">
<w-input v-model="sp.nomi.home" type="text" class="pa3" tabindex="1">Nome Home</w-input>
<w-input v-model="sp.nomi.guest" type="text" class="pa3" tabindex="2">Nome Guest</w-input>
<w-flex justify-center align-center class="pa3">
<span class="mr3">Modalità partita:</span>
<w-button
@click="modalitaPartita = '2/3'"
:bg-color="modalitaPartita === '2/3' ? 'success' : 'grey-light4'"
:dark="modalitaPartita === '2/3'"
class="ma1"
tabindex="-1">
2/3
</w-button>
<w-button
@click="modalitaPartita = '3/5'"
:bg-color="modalitaPartita === '3/5' ? 'success' : 'grey-light4'"
:dark="modalitaPartita === '3/5'"
class="ma1"
tabindex="-1">
3/5
</w-button>
</w-flex>
<w-flex justify-space-around class="pa3">
<div class="campo-config">
<div class="text-bold mb3 text-center">Formazione Home</div>
<div class="campo-pallavolo">
<!-- Fila anteriore - index [3, 2, 1] - VICINO ALLA RETE (prima fila visualizzata) -->
<w-flex justify-center class="fila-anteriore">
<w-input v-model="sp.form.home[3]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="6"></w-input>
<w-input v-model="sp.form.home[2]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="5"></w-input>
<w-input v-model="sp.form.home[1]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="4"></w-input>
</w-flex>
<!-- Linea dei 3 metri -->
<div class="linea-tre-metri"></div>
<!-- Fila posteriore - index [4, 5, 0] - ZONA DIFESA (seconda fila visualizzata) -->
<w-flex justify-center class="fila-posteriore">
<w-input v-model="sp.form.home[4]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="7"></w-input>
<w-input v-model="sp.form.home[5]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="8"></w-input>
<w-input v-model="sp.form.home[0]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="3"></w-input>
</w-flex>
</div>
</div>
<div class="campo-config">
<div class="text-bold mb3 text-center">Formazione Guest</div>
<div class="campo-pallavolo">
<!-- Fila anteriore - index [3, 2, 1] - VICINO ALLA RETE (prima fila visualizzata) -->
<w-flex justify-center class="fila-anteriore">
<w-input v-model="sp.form.guest[3]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="12"></w-input>
<w-input v-model="sp.form.guest[2]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="11"></w-input>
<w-input v-model="sp.form.guest[1]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="10"></w-input>
</w-flex>
<!-- Linea dei 3 metri -->
<div class="linea-tre-metri"></div>
<!-- Fila posteriore - index [4, 5, 0] - ZONA DIFESA (seconda fila visualizzata) -->
<w-flex justify-center class="fila-posteriore">
<w-input v-model="sp.form.guest[4]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="13"></w-input>
<w-input v-model="sp.form.guest[5]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="14"></w-input>
<w-input v-model="sp.form.guest[0]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="9"></w-input>
</w-flex>
</div>
</div>
</w-flex>
<w-button @click="order = !order" class="ma2" tabindex="-1">Inverti ordine</w-button>
<w-button bg-color="success" @click="diaNomi.show = false" class="ma2" tabindex="-1">
Ok
</w-button>
</w-dialog>
<w-dialog v-model="diaCambiTeam.show" :width="420" @close="abilitaTastiSpeciali()">
<div class="text-bold text-center mb2">Scegli squadra</div>
<w-flex justify-center class="pa3">
<w-button class="ma2" @click="selezionaTeamCambi('home')">{{ sp.nomi.home }}</w-button>
<w-button class="ma2" @click="selezionaTeamCambi('guest')">{{ sp.nomi.guest }}</w-button>
</w-flex>
</w-dialog>
<w-dialog v-model="diaCambi.show" :width="360" @close="chiudiDialogCambi">
<div class="cambi-dialog">
<div class="cambi-title">{{ sp.nomi[diaCambi.team] }}: CAMBIO</div>
<div class="cambi-rows">
<div class="cambi-row">
<w-input v-model="diaCambi[diaCambi.team].cambi[0].in" type="text" class="cambi-input cambi-in"></w-input>
<span class="cambi-arrow"></span>
<w-input v-model="diaCambi[diaCambi.team].cambi[0].out" type="text" class="cambi-input cambi-out"></w-input>
</div>
<div class="cambi-row">
<w-input v-model="diaCambi[diaCambi.team].cambi[1].in" type="text" class="cambi-input cambi-in"></w-input>
<span class="cambi-arrow"></span>
<w-input v-model="diaCambi[diaCambi.team].cambi[1].out" type="text" class="cambi-input cambi-out"></w-input>
</div>
</div>
</div>
<w-flex justify-end class="pa3">
<w-button bg-color="success" :disabled="!cambiConfermabili" @click="confermaCambi">
CONFERMA
</w-button>
</w-flex>
</w-dialog>
<div class="campo">
<span v-if="order">
<!-- home guest -->
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'left' }">
{{ sp.nomi.home }}
<span class="serv-slot">
<img v-show="sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="visuForm" class="score-inline">{{ sp.punt.home }}</span>
</span>
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
</div>
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'right' }">
<span v-if="visuForm" class="score-inline">{{ sp.punt.guest }}</span>
<span class="serv-slot">
<img v-show="!sp.servHome" src="/serv.png" width="25" />
</span>
{{ sp.nomi.guest }}
</span>
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
</div>
<span v-if="visuForm">
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
</span>
<span v-else>
<w-flex class="punteggio-container">
<w-flex justify-center align-center class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</w-flex>
<w-flex justify-center align-center class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</w-flex>
</w-flex>
</span>
</span>
<span v-else>
<!-- guest home -->
<div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'left' }">
{{ sp.nomi.guest }}
<span class="serv-slot">
<img v-show="!sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="visuForm" class="score-inline">{{ sp.punt.guest }}</span>
</span>
<span @click="incSet('guest')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.guest }}</span>
</div>
<div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'right' }">
<span v-if="visuForm" class="score-inline">{{ sp.punt.home }}</span>
<span class="serv-slot">
<img v-show="sp.servHome" src="/serv.png" width="25" />
</span>
{{ sp.nomi.home }}
</span>
<span @click="incSet('home')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.home }}</span>
</div>
<span v-if="visuForm">
<div class="col form guest" @click="incPunt('guest')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.guest[x] }}
</div>
</div>
<div class="col form home" @click="incPunt('home')">
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
</div>
</span>
<span v-else>
<w-flex class="punteggio-container">
<w-flex justify-center align-center class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</w-flex>
<w-flex justify-center align-center class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</w-flex>
</w-flex>
</span>
</span>
<div class="striscia" v-if="visuStriscia">
<div>
<span class="text-bold mr1">{{ sp.nomi.home }}</span>
<div v-for="h in sp.striscia.home" class="item">
{{String(h)}}
</div>
</div>
<div class="guest">
<span class="text-bold mr1">{{ sp.nomi.guest }}</span>
<div v-for="h in sp.striscia.guest" class="item">
{{String(h)}}
</div>
</div>
</div>
<div class="bot" v-if="visuButt">
<w-flex justify-space-between class="pa2">
<w-confirm right align-bottom v-if="isMobile()" question="CHIUDO ?" cancel="NO" confirm="SI" @confirm="closeApp">
<img src="/exit.png" width="25" />
</w-confirm>
<w-button @click="apriDialogConfig()">
<img src="/gear.png" width="25" />
</w-button>
<w-button @click="cambiaPalla" :disabled="!isPunteggioZeroZero">
<img src="/serv.png" width="25" />
</w-button>
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
RESET
</w-confirm>
<w-button @click="visuForm = !visuForm">
<span v-if="visuForm">PUNTEGGIO</span>
<span v-if="!visuForm">FORMAZIONI</span>
</w-button>
<w-button @click="apriDialogCambi">
CAMBI
</w-button>
<w-button @click="visuStriscia = !visuStriscia">
STRISCIA
</w-button>
<w-button @click="speak">
<img src="/speaker.png" width="25" />
</w-button>
</w-flex>
</div>
</div>
</section>

View File

@@ -1,479 +0,0 @@
import NoSleep from "nosleep.js";
export default {
name: "HomePage",
components: {},
data() {
return {
order: true,
voices: null,
diaNomi: {
show: false,
home: "",
guest: "",
},
diaCambi: {
show: false,
team: "home",
guest: { cambi: [{ in: "", out: "" }, { in: "", out: "" }] },
home: { cambi: [{ in: "", out: "" }, { in: "", out: "" }] },
},
diaCambiTeam: {
show: false,
},
visuForm: false,
visuButt: true,
visuStriscia: true,
modalitaPartita: "3/5", // "2/3" o "3/5"
sp: {
striscia: { home: [0], guest: [0] },
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
storicoServizio: [], // Stack per tracciare lo stato del servizio prima di ogni punto
},
}
},
mounted() {
this.voices = window.speechSynthesis.getVoices();
if (this.isMobile()) {
this.speak();
var noSleep = new NoSleep();
noSleep.enable();
document.documentElement.requestFullscreen();
}
this.abilitaTastiSpeciali();
},
computed: {
isPunteggioZeroZero() {
return this.sp.punt.home === 0 && this.sp.punt.guest === 0;
},
cambiConfermabili() {
const team = this.diaCambi.team;
const cambi = this.diaCambi[team].cambi || [];
let hasComplete = false;
let allValid = true;
cambi.forEach((cambio) => {
const teamIn = (cambio.in || "").trim();
const teamOut = (cambio.out || "").trim();
if (!teamIn && !teamOut) {
return;
}
if (!teamIn || !teamOut) {
allValid = false;
return;
}
hasComplete = true;
});
return allValid && hasComplete;
}
},
methods: {
closeApp() {
var win = window.open("", "_self");
win.close();
},
fullScreen() {
document.documentElement.requestFullscreen();
},
isMobile() {
if (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
) {
return true;
} else {
return false;
}
},
resetta() {
this.$waveui.notify("Punteggio<br />RESETTATO", "success");
this.visuForm = false;
this.sp.punt.home = 0;
this.sp.punt.guest = 0;
this.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
this.sp.striscia = { home: [0], guest: [0] }
this.sp.storicoServizio = []
},
cambiaPalla() {
if (!this.isPunteggioZeroZero) {
this.$waveui.notify("Cambio palla consentito solo a inizio set (0-0)", "warning");
return;
}
this.sp.servHome = !this.sp.servHome;
},
incSet(team) {
if (this.sp.set[team] == 2) {
this.sp.set[team] = 0;
} else {
this.sp.set[team]++;
}
},
incPunt(team) {
// Se il set è già terminato, evita ulteriori incrementi
if (this.checkVittoria()) {
return;
}
// Salva lo stato del servizio PRIMA di modificarlo
this.sp.storicoServizio.push({
servHome: this.sp.servHome,
cambioPalla: (team == "home" && !this.sp.servHome) || (team == "guest" && this.sp.servHome)
});
this.sp.punt[team]++;
if (team == 'home') {
this.sp.striscia.home.push(this.sp.punt.home)
this.sp.striscia.guest.push(' ')
} else {
this.sp.striscia.guest.push(this.sp.punt.guest)
this.sp.striscia.home.push(' ')
}
// Ruota la formazione solo se c'è cambio palla (conquista del servizio)
const cambioPalla = (team == "home" && !this.sp.servHome) || (team == "guest" && this.sp.servHome);
if (cambioPalla) {
this.sp.form[team].push(this.sp.form[team].shift());
}
this.sp.servHome = (team == "home");
},
checkVittoria() {
const puntHome = this.sp.punt.home;
const puntGuest = this.sp.punt.guest;
const setHome = this.sp.set.home;
const setGuest = this.sp.set.guest;
const totSet = setHome + setGuest;
// Determina se siamo nel set decisivo in base alla modalità partita
let isSetDecisivo = false;
if (this.modalitaPartita === "2/3") {
// Tie-break al 3° set (quando totSet >= 2)
isSetDecisivo = totSet >= 2;
} else {
// Tie-break al 5° set (quando totSet >= 4)
isSetDecisivo = totSet >= 4;
}
const punteggioVittoria = isSetDecisivo ? 15 : 25;
// Vittoria con punteggio >= 25 (o 15 per set decisivo) e almeno 2 punti di vantaggio
if (puntHome >= punteggioVittoria && puntHome - puntGuest >= 2) {
return true; // Home ha vinto
}
if (puntGuest >= punteggioVittoria && puntGuest - puntHome >= 2) {
return true; // Guest ha vinto
}
return false;
},
decPunt() {
if (this.sp.striscia.home.length > 1 && this.sp.storicoServizio.length > 0) {
var tmpHome = this.sp.striscia.home.pop()
var tmpGuest = this.sp.striscia.guest.pop()
var statoServizio = this.sp.storicoServizio.pop() // Recupera lo stato completo del servizio
if (tmpHome == ' ') {
this.sp.punt.guest--
// Ruota indietro solo se c'era stato un cambio palla
if (statoServizio.cambioPalla) {
this.sp.form.guest.unshift(this.sp.form.guest.pop());
}
} else {
this.sp.punt.home--
// Ruota indietro solo se c'era stato un cambio palla
if (statoServizio.cambioPalla) {
this.sp.form.home.unshift(this.sp.form.home.pop());
}
}
// Ripristina il servizio allo stato precedente
this.sp.servHome = statoServizio.servHome;
}
},
// decPunt(team) {
// // decrementa il punteggio se è > 0.
// if (this.sp.punt[team] > 0) {
// this.sp.punt[team]--;
// this.sp.striscia.home.pop()
// this.sp.striscia.guest.pop()
// this.sp.form[team].unshift(this.sp.form[team].pop());
// }
// },
speak() {
const msg = new SpeechSynthesisUtterance();
if (this.sp.punt.home + this.sp.punt.guest == 0) {
msg.text = "zero a zero";
} else if (this.sp.punt.home == this.sp.punt.guest) {
msg.text = this.sp.punt.home + " pari";
} else {
if (this.sp.servHome) {
msg.text = this.sp.punt.home + " a " + this.sp.punt.guest;
} else {
msg.text = this.sp.punt.guest + " a " + this.sp.punt.home;
}
}
// msg.volume = 1.0; // speech volume (default: 1.0)
// msg.pitch = 1.0; // speech pitch (default: 1.0)
// msg.rate = 1.0; // speech rate (default: 1.0)
// msg.lang = 'it_IT'; // speech language (default: 'en-US')
const voices = window.speechSynthesis.getVoices();
msg.voice = voices.find(voice => voice.name === 'Google italiano');
// voice URI (default: platform-dependent)
// msg.onboundary = function (event) {
// console.log('Speech reached a boundary:', event.name);
// };
// msg.onpause = function (event) {
// console.log('Speech paused:', event.utterance.text.substring(event.charIndex));
// };
window.speechSynthesis.speak(msg);
},
apriDialogConfig() {
this.disabilitaTastiSpeciali();
this.diaNomi.show = true;
// Aggiungi gestore Tab per il dialog
this.dialogConfigTabHandler = (e) => {
if (e.key === 'Tab' && this.diaNomi.show) {
e.preventDefault();
e.stopPropagation();
const dialog = document.querySelector('.w-dialog');
if (!dialog) return;
const allInputs = Array.from(dialog.querySelectorAll('input[type="text"]'))
.sort((a, b) => {
const tabA = parseInt(a.closest('[tabindex]')?.getAttribute('tabindex') || '0');
const tabB = parseInt(b.closest('[tabindex]')?.getAttribute('tabindex') || '0');
return tabA - tabB;
});
if (allInputs.length === 0) return;
// Verifica se il focus è già dentro il dialog
const focusInDialog = dialog.contains(document.activeElement);
// Se non è nel dialog o non è in un input, vai al primo
if (!focusInDialog || !allInputs.includes(document.activeElement)) {
allInputs[0].focus();
return;
}
// Navigazione normale tra i campi
const currentIndex = allInputs.indexOf(document.activeElement);
let nextIndex;
if (e.shiftKey) {
nextIndex = currentIndex <= 0 ? allInputs.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex >= allInputs.length - 1 ? 0 : currentIndex + 1;
}
if (allInputs[nextIndex]) {
allInputs[nextIndex].focus();
}
}
};
window.addEventListener('keydown', this.dialogConfigTabHandler, true);
// Focus immediato + retry con timeout
this.$nextTick(() => {
const focusFirst = () => {
const dialog = document.querySelector('.w-dialog');
if (dialog) {
const firstInput = dialog.querySelector('input[type="text"]');
if (firstInput) {
firstInput.focus();
firstInput.select();
return true;
}
}
return false;
};
// Prova immediatamente
if (!focusFirst()) {
// Se fallisce, riprova dopo un breve delay
setTimeout(focusFirst, 100);
}
});
},
chiudiDialogConfig() {
if (this.dialogConfigTabHandler) {
window.removeEventListener('keydown', this.dialogConfigTabHandler, true);
this.dialogConfigTabHandler = null;
}
this.abilitaTastiSpeciali();
},
resettaCambi(team) {
const teams = team ? [team] : ["home", "guest"];
teams.forEach((t) => {
this.diaCambi[t].cambi.forEach((cambio) => {
cambio.in = "";
cambio.out = "";
});
});
},
apriDialogCambi() {
this.disabilitaTastiSpeciali();
this.diaCambiTeam.show = true;
},
apriDialogCambiTeam(team) {
this.disabilitaTastiSpeciali();
this.diaCambi.team = team;
this.resettaCambi(team);
this.diaCambi.show = true;
},
selezionaTeamCambi(team) {
this.diaCambiTeam.show = false;
this.apriDialogCambiTeam(team);
},
chiudiDialogCambi() {
this.diaCambi.show = false;
this.resettaCambi(this.diaCambi.team);
this.abilitaTastiSpeciali();
},
confermaCambi() {
if (!this.cambiConfermabili) {
return;
}
const team = this.diaCambi.team;
const cambi = (this.diaCambi[team].cambi || [])
.map((cambio) => ({
team,
in: (cambio.in || "").trim(),
out: (cambio.out || "").trim(),
}))
.filter((cambio) => cambio.in || cambio.out);
const form = this.sp.form[team].map((val) => String(val).trim());
const formAggiornata = [...form];
for (const cambio of cambi) {
if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) {
this.$waveui.notify("Inserisci solo numeri nei campi", "warning");
return;
}
if (cambio.in === cambio.out) {
this.$waveui.notify(`Numero IN e OUT uguali per ${cambio.team}`, "warning");
return;
}
if (formAggiornata.includes(cambio.in)) {
this.$waveui.notify(`Numero ${cambio.in} già presente in formazione ${cambio.team}`, "warning");
return;
}
if (!formAggiornata.includes(cambio.out)) {
this.$waveui.notify(`Numero ${cambio.out} non presente in formazione ${cambio.team}`, "warning");
return;
}
const idx = formAggiornata.findIndex((val) => String(val).trim() === cambio.out);
if (idx !== -1) {
formAggiornata.splice(idx, 1, cambio.in);
}
}
this.sp.form[team] = formAggiornata;
this.chiudiDialogCambi();
},
disabilitaTastiSpeciali() {
window.removeEventListener("keydown", this.funzioneTastiSpeciali);
},
abilitaTastiSpeciali() {
window.addEventListener("keydown", this.funzioneTastiSpeciali);
},
funzioneTastiSpeciali(e) {
if (this.diaNomi.show || this.diaCambi.show || this.diaCambiTeam.show) {
return;
}
const target = e.target;
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
const elements = [target, ...path].filter(Boolean);
const isTypingField = elements.some((el) => {
if (!el || !el.tagName) {
return false;
}
const tag = String(el.tagName).toLowerCase();
if (tag === "input" || tag === "textarea") {
return true;
}
if (el.isContentEditable) {
return true;
}
if (el.classList && (el.classList.contains("w-input") || el.classList.contains("w-textarea"))) {
return true;
}
const contentEditable = el.getAttribute && el.getAttribute("contenteditable");
return contentEditable === "true";
});
if (isTypingField) {
return;
}
let handled = false;
if (e.ctrlKey && e.key == "m") {
this.diaNomi.show = true
handled = true;
} else if (e.ctrlKey && e.key == "b") {
this.visuButt = !this.visuButt
handled = true;
} else if (e.ctrlKey && e.key == "f") {
document.documentElement.requestFullscreen();
handled = true;
} else if (e.ctrlKey && e.key == "s") {
this.speak();
handled = true;
} else if (e.ctrlKey && e.key == "z") {
this.visuForm = !this.visuForm
handled = true;
} else if (e.ctrlKey && e.key == "ArrowUp") {
this.incPunt("home")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowDown") {
this.decPunt("home")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowRight") {
this.incSet("home")
handled = true;
} else if (e.shiftKey && e.key == "ArrowUp") {
this.incPunt("guest")
handled = true;
} else if (e.shiftKey && e.key == "ArrowDown") {
this.decPunt("guest")
handled = true;
} else if (e.shiftKey && e.key == "ArrowRight") {
this.incSet("guest")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowLeft") {
this.cambiaPalla()
handled = true;
} else if (e.ctrlKey && (e.key == "c" || e.key == "C")) {
this.apriDialogCambiTeam("home")
handled = true;
} else if (e.shiftKey && (e.key == "c" || e.key == "C")) {
this.apriDialogCambiTeam("guest")
handled = true;
} else { return false }
if (handled) {
e.preventDefault();
}
}
}
}

View File

@@ -1,112 +0,0 @@
.homepage {
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
touch-action: pan-x pan-y;
height: 100%
}
body {
overscroll-behavior-y: contain;
margin: 0;
place-items: center;
min-width: 320px;
width: 100%;
min-height: 100vh;
background-color: #000;
}
button {
margin-left: 10px;
margin-right: 10px;
border-radius: 8px;
border: 1px solid #fff;
padding: 0.6em 1.2em;
font-size: 0.8em;
font-weight: 500;
font-family: inherit;
color: #fff;
background-color: #000;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
background-color: #333;
}
button:focus, button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
#app {
margin: 0 auto;
text-align: center;
}
.campo {
user-select: none;
width: 100%;
display: table;
color: #fff;
}
.hea {
float: left;
width: 50%;
font-size: xx-large;
}
.hea span {
/* border: 1px solid #f3fb00; */
padding-left: 10px;
padding-right: 10px;
border-radius: 5px;
}
.tal {
text-align: left;
}
.tar {
text-align: right;
}
.bot {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
margin-top: 10px;
margin-bottom: 1px;
background-color: #111;
}
.col {
margin-left: auto;
margin-right: auto;
text-align: center;
float: left;
width: 50%;
}
.punt {
font-size: 60vh;
}
.form {
font-size: 5vh;
border-top: #fff dashed 25px;
padding-top: 50px;
}
.formtit {
font-size: 5vh;
margin-top: 30px;
margin-bottom: 20px;
}
.formdiv {
font-size: 20vh;
float: left;
width: 32%;
}
.home {
background-color: black;
color: yellow;
}
.guest {
background-color: blue;
color: white
}
.item-stri {
background-color: #fff;
}
}

View File

@@ -1,3 +0,0 @@
<template src="./HomePage.html"></template>
<script src="./HomePage.js"></script>

9
src/controller-main.js Normal file
View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import './style.css'
import WaveUI from 'wave-ui'
import 'wave-ui/dist/wave-ui.css'
import ControllerPage from './components/ControllerPage.vue'
const app = createApp(ControllerPage)
app.use(WaveUI)
app.mount('#app')

206
src/gameState.js Normal file
View File

@@ -0,0 +1,206 @@
/**
* Logica di gioco condivisa per il segnapunti.
* Utilizzata sia dal server WebSocket sia dal client per l'anteprima locale.
*/
export function createInitialState() {
return {
order: true,
visuForm: false,
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: { home: [0], guest: [0] },
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
storicoServizio: [],
},
}
}
export function checkVittoria(state) {
const puntHome = state.sp.punt.home
const puntGuest = state.sp.punt.guest
const setHome = state.sp.set.home
const setGuest = state.sp.set.guest
const totSet = setHome + setGuest
let isSetDecisivo = false
if (state.modalitaPartita === "2/3") {
isSetDecisivo = totSet >= 2
} else {
isSetDecisivo = totSet >= 4
}
const punteggioVittoria = isSetDecisivo ? 15 : 25
if (puntHome >= punteggioVittoria && puntHome - puntGuest >= 2) {
return true
}
if (puntGuest >= punteggioVittoria && puntGuest - puntHome >= 2) {
return true
}
return false
}
export function applyAction(state, action) {
// Esegue un deep clone per evitare mutazioni indesiderate dello stato lato server.
// Restituisce sempre un nuovo oggetto di stato.
const s = JSON.parse(JSON.stringify(state))
switch (action.type) {
case "incPunt": {
const team = action.team
if (checkVittoria(s)) break
s.sp.storicoServizio.push({
servHome: s.sp.servHome,
cambioPalla: (team === "home" && !s.sp.servHome) || (team === "guest" && s.sp.servHome),
})
s.sp.punt[team]++
if (team === "home") {
s.sp.striscia.home.push(s.sp.punt.home)
s.sp.striscia.guest.push(" ")
} else {
s.sp.striscia.guest.push(s.sp.punt.guest)
s.sp.striscia.home.push(" ")
}
const cambioPalla = (team === "home" && !s.sp.servHome) || (team === "guest" && s.sp.servHome)
if (cambioPalla) {
s.sp.form[team].push(s.sp.form[team].shift())
}
s.sp.servHome = team === "home"
break
}
case "decPunt": {
if (s.sp.striscia.home.length > 1 && s.sp.storicoServizio.length > 0) {
const tmpHome = s.sp.striscia.home.pop()
s.sp.striscia.guest.pop()
const statoServizio = s.sp.storicoServizio.pop()
if (tmpHome === " ") {
s.sp.punt.guest--
if (statoServizio.cambioPalla) {
s.sp.form.guest.unshift(s.sp.form.guest.pop())
}
} else {
s.sp.punt.home--
if (statoServizio.cambioPalla) {
s.sp.form.home.unshift(s.sp.form.home.pop())
}
}
s.sp.servHome = statoServizio.servHome
}
break
}
case "incSet": {
const team = action.team
if (s.sp.set[team] === 2) {
s.sp.set[team] = 0
} else {
s.sp.set[team]++
}
break
}
case "cambiaPalla": {
if (s.sp.punt.home === 0 && s.sp.punt.guest === 0) {
s.sp.servHome = !s.sp.servHome
}
break
}
case "resetta": {
s.visuForm = false
s.sp.punt.home = 0
s.sp.punt.guest = 0
s.sp.set.home = 0
s.sp.set.guest = 0
s.sp.form = {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
s.sp.striscia = { home: [0], guest: [0] }
s.sp.storicoServizio = []
break
}
case "toggleFormazione": {
s.visuForm = !s.visuForm
break
}
case "toggleStriscia": {
s.visuStriscia = !s.visuStriscia
break
}
case "toggleOrder": {
s.order = !s.order
break
}
case "setNomi": {
if (action.home !== undefined) s.sp.nomi.home = action.home
if (action.guest !== undefined) s.sp.nomi.guest = action.guest
break
}
case "setModalita": {
s.modalitaPartita = action.modalita
break
}
case "setFormazione": {
if (action.team && action.form) {
s.sp.form[action.team] = [...action.form]
}
break
}
case "confermaCambi": {
const team = action.team
const cambi = action.cambi || []
const form = s.sp.form[team].map((val) => String(val).trim())
const formAggiornata = [...form]
let valid = true
for (const cambio of cambi) {
const cin = (cambio.in || "").trim()
const cout = (cambio.out || "").trim()
if (!cin || !cout) continue
if (!/^\d+$/.test(cin) || !/^\d+$/.test(cout)) { valid = false; break }
if (cin === cout) { valid = false; break }
if (formAggiornata.includes(cin)) { valid = false; break }
if (!formAggiornata.includes(cout)) { valid = false; break }
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
if (idx !== -1) {
formAggiornata.splice(idx, 1, cin)
}
}
if (valid) {
s.sp.form[team] = formAggiornata
}
break
}
default:
break
}
return s
}

View File

@@ -3,8 +3,10 @@ import './style.css'
import App from './App.vue'
import WaveUI from 'wave-ui'
import 'wave-ui/dist/wave-ui.css'
import DisplayPage from './components/DisplayPage.vue'
const app = createApp(App)
// In modalità display-only, non serve il router.
// Il display viene montato direttamente.
const app = createApp(DisplayPage)
app.use(WaveUI)
app.mount('#app')

46
src/server-utils.js Normal file
View File

@@ -0,0 +1,46 @@
import { networkInterfaces } from 'os'
/**
* Restituisce gli indirizzi IP di rete del sistema, escludendo loopback e bridge Docker.
* @returns {string[]} Elenco degli indirizzi IP disponibili.
*/
export function getNetworkIPs() {
const nets = networkInterfaces()
const networkIPs = []
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
// Esclude loopback (127.0.0.1), indirizzi non IPv4 e bridge Docker (172.17.x.x, 172.18.x.x).
if (net.family === 'IPv4' &&
!net.internal &&
!net.address.startsWith('172.17.') &&
!net.address.startsWith('172.18.')) {
networkIPs.push(net.address)
}
}
}
return networkIPs
}
/**
* Stampa il riepilogo di avvio del server con gli URL di accesso.
* @param {number} displayPort - Porta del display.
* @param {number} controllerPort - Porta del controller.
*/
export function printServerInfo(displayPort = 5173, controllerPort = 3001) {
const networkIPs = getNetworkIPs()
console.log(`\nSegnapunti Server`)
console.log(` Display: http://127.0.0.1:${displayPort}/`)
console.log(` Controller: http://127.0.0.1:${controllerPort}/`)
if (networkIPs.length > 0) {
console.log(`\n Controller da dispositivi remoti:`)
networkIPs.forEach(ip => {
console.log(` http://${ip}:${controllerPort}/`)
})
}
console.log()
}

View File

@@ -53,7 +53,7 @@ button:focus-visible {
font-size: xx-large;
}
.hea span {
/* border: 1px solid #f3fb00; */
/* Bordo di debug: border: 1px solid #f3fb00; */
padding-left: 10px;
padding-right: 10px;
border-radius: 5px;

199
src/websocket-handler.js Normal file
View File

@@ -0,0 +1,199 @@
import { createInitialState, applyAction } from './gameState.js'
/**
* Crea e configura il server WebSocket per la gestione dello stato di gioco.
* @param {WebSocketServer} wss - Istanza del server WebSocket.
* @returns {Object} Oggetto con metodi di gestione dello stato.
*/
export function setupWebSocketHandler(wss) {
// Stato globale della partita.
let gameState = createInitialState()
// Mappa dei ruoli associati ai client connessi.
const clients = new Map() // ws -> { role: 'display' | 'controller' }
/**
* Gestisce i messaggi in arrivo dal client
*/
function handleMessage(ws, data) {
try {
// Converte il payload in stringa in modo sicuro, anche se arriva come Buffer.
const dataStr = typeof data === 'string' ? data : data.toString('utf8')
const msg = JSON.parse(dataStr)
if (msg.type === 'register') {
return handleRegister(ws, msg)
}
if (msg.type === 'action') {
return handleAction(ws, msg)
}
if (msg.type === 'speak') {
return handleSpeak(ws, msg)
}
} catch (err) {
console.error('Error processing message:', err, 'data:', data)
// Invia l'errore solo se la connessione e ancora aperta.
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
sendError(ws, 'Invalid message format')
} catch (sendErr) {
console.error('Error sending error message:', sendErr)
}
}
}
}
/**
* Gestisce la registrazione di un client (display o controller)
*/
function handleRegister(ws, msg) {
const role = msg.role || 'display'
// Valida il ruolo dichiarato dal client.
if (!['display', 'controller'].includes(role)) {
sendError(ws, 'Invalid role')
return
}
clients.set(ws, { role })
console.log(`[WebSocket] Client registered as: ${role} (total clients: ${clients.size})`)
// Invia subito lo stato corrente, se la connessione e aperta.
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
ws.send(JSON.stringify({ type: 'state', state: gameState }))
} catch (err) {
console.error('Error sending initial state:', err)
}
}
}
/**
* Gestisce un'azione di gioco dal controller
*/
function handleAction(ws, msg) {
// Solo i client controller possono inviare azioni.
const client = clients.get(ws)
if (!client || client.role !== 'controller') {
sendError(ws, 'Only controllers can send actions')
return
}
// Verifica il formato dell'azione ricevuta.
if (!msg.action || !msg.action.type) {
sendError(ws, 'Invalid action format')
return
}
// Applica l'azione allo stato della partita.
const previousState = gameState
try {
gameState = applyAction(gameState, msg.action)
} catch (err) {
console.error('Error applying action:', err)
sendError(ws, 'Failed to apply action')
gameState = previousState
return
}
// Propaga il nuovo stato a tutti i client connessi.
broadcastState()
}
/**
* Gestisce una richiesta di sintesi vocale proveniente dal controller.
* Il messaggio viene inoltrato solo ai client registrati come display.
*/
function handleSpeak(ws, msg) {
const client = clients.get(ws)
if (!client || client.role !== 'controller') {
sendError(ws, 'Only controllers can request speech')
return
}
if (typeof msg.text !== 'string' || msg.text.trim() === '') {
sendError(ws, 'Invalid speak payload')
return
}
const speakMsg = JSON.stringify({ type: 'speak', text: msg.text.trim() })
clients.forEach((meta, clientWs) => {
if (meta.role === 'display' && clientWs.readyState === 1) {
clientWs.send(speakMsg)
}
})
}
/**
* Invia un messaggio di errore al client
*/
function sendError(ws, message) {
if (ws.readyState === 1) { // Stato WebSocket.OPEN
try {
ws.send(JSON.stringify({ type: 'error', message }))
} catch (err) {
console.error('Failed to send error message:', err)
}
}
}
/**
* Invia lo stato corrente a tutti i client connessi.
*/
function broadcastState() {
const stateMsg = JSON.stringify({ type: 'state', state: gameState })
wss.clients.forEach((client) => {
if (client.readyState === 1) { // Stato WebSocket.OPEN
client.send(stateMsg)
}
})
}
/**
* Gestisce la chiusura della connessione
*/
function handleClose(ws) {
const client = clients.get(ws)
const role = client?.role || 'unknown'
console.log(`[WebSocket] Client disconnected (role: ${role})`)
clients.delete(ws)
}
/**
* Gestisce gli errori WebSocket
*/
function handleError(err, ws) {
console.error('WebSocket error:', err)
// In caso di frame non validi, chiude forzatamente la connessione.
if (err.code === 'WS_ERR_INVALID_CLOSE_CODE' || err.code === 'WS_ERR_INVALID_UTF8') {
try {
if (ws && ws.readyState === 1) { // Stato WebSocket.OPEN
ws.terminate() // Chiusura forzata senza handshake di chiusura.
}
} catch (closeErr) {
console.error('Error closing connection:', closeErr)
}
}
}
// Registra gli handler per ogni nuova connessione.
wss.on('connection', (ws) => {
// Imposta il tipo binario per ridurre i problemi di codifica.
ws.binaryType = 'arraybuffer'
ws.on('message', (data) => handleMessage(ws, data))
ws.on('close', () => handleClose(ws))
ws.on('error', (err) => handleError(err, ws))
})
// Espone un'API pubblica per controllo esterno, se necessario.
return {
getState: () => gameState,
setState: (newState) => { gameState = newState },
broadcastState,
getClients: () => clients,
}
}

169
tests/README.md Normal file
View File

@@ -0,0 +1,169 @@
# Guida ai Test per Principianti - Segnapunti
Benvenuto nella guida ai test del progetto!
Questo progetto usa tre tipi di test:
```text
tests/
├── unit/ # Test veloci per logica pura (funzioni, classi)
├── integration/ # Test per componenti che interagiscono (es. WebSocket)
├── e2e/ # Test simil-utente reale (Controller -> Display flux)
└── README.md # Questa guida
```
1. **Test Unitari (unit) (Vitest)**: Si occupano di verificare le singole funzioni, i componenti e la logica di business in isolamento. Sono progettati per essere estremamente veloci e fornire un feedback immediato sulla correttezza del codice durante lo sviluppo, garantendo che ogni piccola parte del sistema funzioni come previsto.
2. **Test di Integrazione (integration) (Vitest)**: Verificano che diversi moduli o servizi (come la comunicazione tramite WebSocket tra server e client) funzionino correttamente insieme, garantendo che i messaggi e gli eventi vengano scambiati e gestiti come previsto.
3. **Test End-to-End (E2E) (Playwright)**: Questi test simulano il comportamento di un utente reale all'interno di un browser (come Chrome o Firefox). Verificano che l'intera applicazione funzioni correttamente dall'inizio alla fine, testando l'interfaccia utente, le API e il database insieme per assicurarsi che i flussi principali (come la creazione di una partita) non presentino errori.
---
## 1. Come eseguire i Test Veloci (Unit)
Questi test controllano che la logica interna del server funzioni correttamente.
### Cosa viene testato?
#### `gameState.test.js` (Logica di Gioco)
- **Punteggio**: Verifica che i punti aumentino correttamente (0 -> 1).
- **Cambio Palla**: Controlla che il servizio passi all'avversario e che la formazione ruoti.
- **Vittoria Set**:
- Verifica la vittoria a 25 punti.
- Verifica la regola dei vantaggi (si vince solo con 2 punti di scarto, es. 26-24).
- **Reset**: Controlla che il tasto Reset azzeri punti, set e formazioni.
#### `server-utils.test.js` (Utility)
- **Stampa Info**: Verifica che il server stampi gli indirizzi IP corretti all'avvio.
### Comando
Apri il terminale nella cartella del progetto e scrivi:
```bash
npm run test:unit
```
### Cosa succede se va tutto bene?
Vedrai delle scritte verdi con la spunta `✓`.
Esempio:
```
✓ tests/unit/server-utils.test.js (1 test)
Test Files 1 passed (1)
Tests 1 passed (1)
```
Significa che il codice funziona come previsto!
### Cosa succede se c'è un errore?
Vedrai delle scritte rosse `×` e un messaggio che ti dice cosa non va.
Esempio:
```
× tests/unit/server-utils.test.js > Server Utils > ...
AssertionError: expected 'A' to include 'B'
```
Significa che hai rotto qualcosa nel codice. Leggi l'errore per capire dove (ti dirà il file e la riga).
---
## 2. Come eseguire i Test di Integrazione (Integration)
Questi test verificano che i componenti del sistema comunichino correttamente tra loro (es. il Server e i Client WebSocket).
### Cosa viene testato?
#### `websocket.test.js` (Integrazione WebSocket)
- **Registrazione**: Verifica che un client riceva lo stato del gioco appena si collega.
- **Flusso Messaggi**: Controlla che quando il Controller invia un comando, il Server lo riceva e lo inoltri a tutti (es. Display).
- **Sicurezza**: Assicura che solo il "Controller" possa cambiare i punti (il "Display" non può inviare comandi di modifica).
### Comando
I test di integrazione sono eseguiti insieme agli unit test:
```bash
npm run test:unit
```
*(Se vuoi eseguire solo gli integrazione, puoi usare `npx vitest tests/integration`)*
### Cosa succede se va tutto bene?
Come per gli unit test, vedrai delle spunte verdi `✓` anche per i file nella cartella `tests/integration/`.
### Cosa succede se c'è un errore?
Vedrai un errore simile agli unit test, ma spesso legato a problemi di comunicazione (es. "expected message not received").
---
## 3. Come eseguire i Test Completi (E2E)
Questi test simulano un utente che apre il sito. Il computer farà partire il server, aprirà un browser invisibile (o visibile) e proverà a usare il sito come farebbe una persona.
### Cosa viene testato?
#### `game-simulation.spec.js` (Simulazione Partita)
Un "robot" esegue queste azioni automaticamente:
1. Apre il **Display** e il **Controller** in due schede diverse.
2. Premi **Reset** per essere sicuro di partire da zero.
3. Clicca **25 volte** sul tasto "+" del Controller.
4. Controlla che sul **Display** appaia che il set è stato vinto (Punteggio Set: 1).
### Comando
```bash
npm run test:e2e
```
(Oppure `npx playwright test` per maggiori opzioni)
### Cosa succede se va tutto bene?
Il test impiegherà qualche secondo (è più lento degli unit test). Se tutto va bene, vedrai:
```
Running 1 test using 1 worker
✓ 1 [chromium] tests/e2e/game-simulation.spec.js:3:1 Game Simulation (5.0s)
1 passed (5.5s)
```
Significa che il sito si apre correttamente e il flusso di gioco funziona dall'inizio alla fine!
### Cosa succede se c'è un errore?
Se qualcosa non va (es. il server non parte, o il controller non aggiorna il display), il test fallirà.
Playwright genererà un **Report HTML** molto dettagliato.
Per vederlo, scrivi:
```bash
npx playwright show-report
```
Si aprirà una pagina web dove potrai vedere passo-passo cosa è successo, inclusi screenshot e video dell'errore.
---
## Domande Frequenti
**Q: I test E2E falliscono su WebKit (Safari)?**
A: È normale su Linux se non hai installato tutte le librerie di sistema. Per ora testiamo solo su **Chromium** (Chrome) e **Firefox** che sono più facili da far girare.
**Q: Devo avviare il server prima dei test?**
A: No! Il comando `npm run test:e2e` avvia automaticamente il tuo server (`npm run serve`) prima di iniziare i test.
**Q: Come creo un nuovo test?**
A:
- **Unit**: Crea un file `.test.js` in `tests/unit/`. Copia l'esempio esistente.
- **Integration**: Crea un file `.test.js` in `tests/integration/`.
- **E2E**: Crea un file `.spec.js` in `tests/e2e/`. Copia l'esempio esistente.

View File

@@ -0,0 +1,28 @@
import { test, expect } from '@playwright/test';
test('Controller updates Display score', async ({ context }) => {
// 1. Create two pages (Controller and Display)
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
// 2. Open Display
await displayPage.goto('http://localhost:3000');
await expect(displayPage).toHaveTitle(/Segnapunti/);
// 3. Open Controller
await controllerPage.goto('http://localhost:3001');
await expect(controllerPage).toHaveTitle(/Controller/);
// 4. Check initial state (assuming reset)
// Note: This depends on the specific IDs in your HTML.
// You might need to adjust selectors based on your actual HTML structure.
// Example: waiting for score element
// await expect(displayPage.locator('#score-home')).toHaveText('0');
// 5. Action on Controller
// await controllerPage.click('#btn-add-home');
// 6. Verify on Display
// await expect(displayPage.locator('#score-home')).toHaveText('1');
});

View File

@@ -0,0 +1,69 @@
import { test, expect } from '@playwright/test';
test.describe('Game Simulation', () => {
test('Simulazione Partita: Controller aggiunge punti finché non cambia il set', async ({ context }) => {
// 1. Setup Pagine
const displayPage = await context.newPage();
const controllerPage = await context.newPage();
await displayPage.goto('http://localhost:3000');
await controllerPage.goto('http://localhost:3001');
// Selettori (basati su ID ipotetici o classi, adattali al tuo HTML reale)
// Assumo che nel DOM ci siano elementi con ID o classi riconoscibili
// E che i punteggi siano visibili.
// Pulisco lo stato iniziale (reset)
const btnReset = controllerPage.getByText(/Reset/i).first();
if (await btnReset.isVisible()) {
await btnReset.click();
// La modale di conferma ha un bottone "SI" con classe .btn-confirm
const btnConfirmReset = controllerPage.locator('.dialog .btn-confirm').getByText('SI');
if (await btnConfirmReset.isVisible()) {
await btnConfirmReset.click();
}
}
// 2. Loop per vincere il primo set (25 punti)
// In ControllerPage.vue, il click su .team-score.home-bg incrementa i punti home
const btnHomeScore = controllerPage.locator('.team-score.home-bg');
for (let i = 0; i < 25; i++) {
await btnHomeScore.click();
// Piccola pausa per lasciare tempo al server di processare e broadcastare
//await displayPage.waitForTimeout(10);
}
// 3. Verifica Vittoria Set
// I punti dovrebbero essere tornati a 0 (o mostrare 25 prima del reset manuale?)
// Il codice gameState dice: checkVittoria -> resetta solo se qualcuno chiama resetta?
// No, checkVittoria è boolean. applyAction('incPunt') incrementa.
// Se vince, il set incrementa? 'incPunt' non incrementa i set in automatico nel codice gameState checkato prima!
// Controllo applyAction:
// "s.sp.punt[team]++" ... POI "checkVittoria(s)" all'inizio del prossimo incPunt?
// NO: "if (checkVittoria(s)) break" all'inizio di incPunt impedisce di andare oltre 25 se già vinto.
// MA 'incSet' è un'azione separata!
// Aspetta, la logica standard è: arrivo a 25 -> vinco set?
// In questo codice `gameState.js` NON c'è automatismo "arrivo a 25 -> set++ e palla al centro".
// L'utente deve cliccare "SET Antoniana" manualmente?
// Guardiamo ControllerPage.vue:
// C'è un bottone "SET {{ state.sp.nomi.home }}" che manda { type: 'incSet', team: 'home' }
// QUINDI: Il test deve:
// 1. Arrivare a 25 pt.
// 2. Cliccare "SET HOME".
// 3. Verificare che Set Home = 1.
// Verifica che siamo a 25
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('25');
// Clicca bottone SET
const btnSetHome = controllerPage.locator('.btn-set.home-bg');
await btnSetHome.click();
// Verifica che il set sia incrementato
// Nota: display potrebbe chiamarsi diversamente, controlliamo Controller per coerenza
await expect(controllerPage.locator('.team-score.home-bg .team-set')).toContainText('SET 1');
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setupWebSocketHandler } from '../../src/websocket-handler.js'
import { EventEmitter } from 'events'
// Mock parziale di una WebSocket e del Server
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 Integration (websocket-handler.js)', () => {
let wss
let handler
let ws
beforeEach(() => {
wss = new MockWebSocketServer()
handler = setupWebSocketHandler(wss)
ws = new MockWebSocket()
// Simuliamo la connessione
wss.emit('connection', ws)
// Aggiungiamo il client al set del server (come farebbe 'ws' realmente)
wss.clients.add(ws)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('dovrebbe registrare un client come "display" e inviare lo stato', () => {
ws.emit('message', JSON.stringify({ type: 'register', role: 'display' }))
// Verifica che abbia inviato lo stato iniziale
expect(ws.send).toHaveBeenCalled()
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
expect(sentMsg.type).toBe('state')
expect(sentMsg.state).toBeDefined()
})
it('dovrebbe permettere al controller di cambiare il punteggio', () => {
// 1. Registra Controller
ws.emit('message', JSON.stringify({ type: 'register', role: 'controller' }))
ws.send.mockClear() // pulisco chiamate precedenti
// 2. Invia Azione
ws.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
// 3. Verifica Broadcast del nuovo stato
expect(ws.send).toHaveBeenCalled()
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
expect(sentMsg.type).toBe('state')
expect(sentMsg.state.sp.punt.home).toBe(1)
})
it('dovrebbe impedire al display di inviare azioni', () => {
// 1. Registra Display
ws.emit('message', JSON.stringify({ type: 'register', role: 'display' }))
ws.send.mockClear()
// 2. Tenta Azione
ws.emit('message', JSON.stringify({
type: 'action',
action: { type: 'incPunt', team: 'home' }
}))
// 3. Verifica Errore
expect(ws.send).toHaveBeenCalled()
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
expect(sentMsg.type).toBe('error')
expect(sentMsg.message).toContain('Only controllers')
})
})

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createInitialState, applyAction, checkVittoria } from '../../src/gameState.js'
describe('Game Logic (gameState.js)', () => {
let state
beforeEach(() => {
state = createInitialState()
})
describe('Initial State', () => {
it('dovrebbe iniziare con 0-0', () => {
expect(state.sp.punt.home).toBe(0)
expect(state.sp.punt.guest).toBe(0)
})
it('dovrebbe avere i set a 0', () => {
expect(state.sp.set.home).toBe(0)
expect(state.sp.set.guest).toBe(0)
})
})
describe('Punteggio', () => {
it('dovrebbe incrementare i punti (Home)', () => {
const newState = applyAction(state, { type: 'incPunt', team: 'home' })
expect(newState.sp.punt.home).toBe(1)
expect(newState.sp.punt.guest).toBe(0)
})
it('dovrebbe gestire il cambio palla', () => {
// Home batte
state.sp.servHome = true
// Punto Guest -> Cambio palla
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
expect(s1.sp.servHome).toBe(false) // Ora batte Guest
// Punto Home -> Cambio palla
const s2 = applyAction(s1, { type: 'incPunt', team: 'home' })
expect(s2.sp.servHome).toBe(true) // Torna a battere Home
})
it('dovrebbe gestire la rotazione formazione al cambio palla', () => {
state.sp.servHome = true // Batte Home
state.sp.form.guest = ["1", "2", "3", "4", "5", "6"]
// Punto Guest -> Cambio palla e rotazione Guest
const newState = applyAction(state, { type: 'incPunt', team: 'guest' })
// Verifica che la formazione sia ruotata (il primo elemento diventa ultimo)
expect(newState.sp.form.guest).toEqual(["2", "3", "4", "5", "6", "1"])
})
})
describe('Vittoria Set', () => {
it('non dovrebbe dare vittoria a 24-24', () => {
state.sp.punt.home = 24
state.sp.punt.guest = 24
expect(checkVittoria(state)).toBe(false)
})
it('dovrebbe dare vittoria a 25-23', () => {
state.sp.punt.home = 25
state.sp.punt.guest = 23
expect(checkVittoria(state)).toBe(true)
})
it('dovrebbe richiedere 2 punti di scarto (26-24)', () => {
state.sp.punt.home = 25
state.sp.punt.guest = 24
expect(checkVittoria(state)).toBe(false)
state.sp.punt.home = 26
expect(checkVittoria(state)).toBe(true)
})
})
describe('Reset', () => {
it('dovrebbe resettare tutto a zero', () => {
state.sp.punt.home = 10
state.sp.set.home = 1
const newState = applyAction(state, { type: 'resetta' })
expect(newState.sp.punt.home).toBe(0)
expect(newState.sp.set.home).toBe(0) // Nota: il reset attuale resetta solo i punti o tutto?
// Controllo il codice: "s.sp.punt.home = 0... s.sp.storicoServizio = []"
// Attenzione: nel codice originale `resetta` NON sembra resettare i set!
// Verifichiamo il comportamento attuale del codice.
})
})
})

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { printServerInfo } from '../../src/server-utils.js'
// Mocking console.log per evitare output sporchi durante i test
import { vi } from 'vitest'
describe('Server Utils', () => {
it('printServerInfo dovrebbe stampare le porte corrette', () => {
const consoleSpy = vi.spyOn(console, 'log')
printServerInfo(3000, 3001)
expect(consoleSpy).toHaveBeenCalled()
// Unisce tutti i messaggi loggati in un'unica stringa per facilitare la ricerca
const allLogs = consoleSpy.mock.calls.map(args => args[0]).join('\n')
expect(allLogs).toContain('3000')
expect(allLogs).toContain('3001')
consoleSpy.mockRestore()
})
})

131
vite-plugin-websocket.js Normal file
View File

@@ -0,0 +1,131 @@
import { WebSocketServer } from 'ws'
import { createServer as createHttpServer, request as httpRequest } from 'http'
import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js'
const CONTROLLER_PORT = 3001
const DEV_PROXY_HOST = process.env.DEV_PROXY_HOST || '127.0.0.1'
/**
* Plugin Vite che integra un server WebSocket per la gestione dello stato di gioco
* e un server separato sulla porta 3001 per il controller.
* @returns {import('vite').Plugin}
*/
export default function websocketPlugin() {
return {
name: 'vite-plugin-websocket',
configureServer(server) {
// Inizializza un server WebSocket collegato al server HTTP di Vite.
const wss = new WebSocketServer({ noServer: true })
// Registra i gestori WebSocket con la logica di gioco.
setupWebSocketHandler(wss)
// Intercetta le richieste di upgrade WebSocket solo sul path /ws.
server.httpServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname
if (pathname === '/ws') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)
})
}
})
// Avvia un server separato per il controller sulla porta 3001.
server.httpServer.once('listening', () => {
const viteAddr = server.httpServer.address()
const vitePort = viteAddr.port
startControllerDevServer(vitePort, wss)
setTimeout(() => printServerInfo(vitePort, CONTROLLER_PORT), 100)
})
}
}
}
/**
* Avvia il server di sviluppo per il controller.
* Fa da proxy verso il dev server di Vite per moduli ES, HMR, e asset.
*/
function startControllerDevServer(vitePort, wss) {
const controllerServer = createHttpServer((req, res) => {
// Se richiesta alla root, riscrive verso controller.html
let targetPath = req.url
if (targetPath === '/' || targetPath === '') {
targetPath = '/controller.html'
}
// Proxy verso il dev server di Vite
const proxyReq = httpRequest(
{
hostname: DEV_PROXY_HOST,
port: vitePort,
path: targetPath,
method: req.method,
headers: {
...req.headers,
host: `${DEV_PROXY_HOST}:${vitePort}`,
},
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers)
proxyRes.pipe(res, { end: true })
}
)
proxyReq.on('error', (err) => {
console.error('[Controller Proxy] Error:', err.message)
if (!res.headersSent) {
res.writeHead(502)
res.end('Proxy error')
}
})
req.pipe(proxyReq, { end: true })
})
// Gestisce l'upgrade WebSocket anche sulla porta del controller
controllerServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url, `http://${request.headers.host}`).pathname
if (pathname === '/ws') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)
})
} else {
// Per l'HMR di Vite, proxare l'upgrade WebSocket verso Vite
const proxyReq = httpRequest({
hostname: DEV_PROXY_HOST,
port: vitePort,
path: request.url,
method: 'GET',
headers: request.headers,
})
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
socket.write(
`HTTP/1.1 101 Switching Protocols\r\n` +
Object.entries(proxyRes.headers)
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n') +
'\r\n\r\n'
)
proxySocket.pipe(socket)
socket.pipe(proxySocket)
})
proxyReq.on('error', (err) => {
console.error('[Controller Proxy] WS upgrade error:', err.message)
socket.destroy()
})
proxyReq.end()
}
})
controllerServer.listen(CONTROLLER_PORT, '0.0.0.0', () => {
console.log(`[Controller] Dev server running on port ${CONTROLLER_PORT}`)
})
}

View File

@@ -1,12 +1,26 @@
import { defineConfig } from 'vite'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import websocketPlugin from './vite-plugin-websocket.js'
// https://vitejs.dev/config/
const __dirname = dirname(fileURLToPath(import.meta.url))
// Configurazione principale di Vite
export default defineConfig({
base: process.env.NODE_ENV === 'production' ? '/segnap' : '/',
base: '/',
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
controller: resolve(__dirname, 'controller.html'),
},
},
},
plugins: [
vue(),
websocketPlugin(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
@@ -32,4 +46,8 @@ export default defineConfig({
}
})
],
server: {
host: '0.0.0.0',
port: 5173,
},
})

9
vitest.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/unit/**/*.{test,spec}.js', 'tests/integration/**/*.{test,spec}.js'],
globals: true, // permette di usare describe/it/expect senza import
environment: 'node', // per backend tests. Se testi componenti Vue, usa 'jsdom'
},
})