1 Commits

Author SHA1 Message Date
d9e1ac751f feat(partita): storico strisce multi-set, conferma fine set e avviso fine partita
- sp.strisce[] archivia la striscia di ogni set completato
- fine set rilevata automaticamente: controller mostra modale di conferma
- confermaSet salva il set, incrementa il contatore e prepara il successivo
- fine partita rilevata da checkVittoriaPartita(): display mostra overlay con
  vincitore e punteggio set; incPunt bloccato
- decPunt pulisce setFinito (undo dell'ultimo punto di vittoria)
- resetta azzera strisce[], setFinito e partitaFinita
2026-02-21 17:49:19 +01:00
8 changed files with 1834 additions and 1763 deletions

255
README.md
View File

@@ -1,90 +1,98 @@
# Segnapunti Anto # Segnapunti Anto
Applicazione web **fullstack real-time** per il tracciamento dei punteggi di partite di pallavolo, installabile come PWA. Applicazione web **Progressive Web App (PWA)** per tracciare i punteggi di partite di pallavolo in tempo reale.
--- ---
## Panoramica ## Panoramica
**Segnapunti Anto** è un'applicazione fullstack per il tracciamento del punteggio durante partite di pallavolo, ottimizzata per tablet e smartphone in contesto sportivo. **Segnapunti Anto** e un'applicazione digitale per il tracciamento del punteggio durante partite di pallavolo, ottimizzata per tablet e smartphone.
### Architettura L'app e composta da due interfacce:
- **Display** (tabellone pubblico)
- **Controller** (pannello operatore)
Il sistema è composto da un **backend Node.js/Express** e due interfacce web separate: Le due interfacce condividono lo stato in tempo reale tramite WebSocket.
| Interfaccia | Porta | Ruolo | ### Funzionalita Principali
|-------------|-------|-------|
| **Display** | 3000 | Tabellone pubblico — mostra punteggi, formazioni e storico |
| **Controller** | 3001 | Pannello operatore — invia azioni e gestisce la partita |
Le due interfacce comunicano tramite **WebSocket** (`/ws`): ogni azione del Controller viene elaborata dal server e trasmessa in broadcast a tutti i client connessi. - **Gestione partita in tempo reale**
- Tracciamento punti home/guest
- Gestione set
- Indicatore servizio
- Storico punti (striscia)
- Blocchi logici quando il set e gia vinto
La logica di gioco risiede interamente **lato server** (`gameState.js`), con aggiornamenti di stato immutabili. Il frontend Vue 3 è puramente reattivo: riceve lo stato e lo visualizza senza gestirne la consistenza. - **Regole pallavolo integrate**
- Set normali: vittoria a 25 con almeno 2 punti di scarto
- Set decisivo: vittoria a 15 con almeno 2 punti di scarto
- Modalita partita `2/3` o `3/5`
In produzione, entrambi i server sono gestiti da un unico processo Node.js (`server.js`) e l'intera applicazione è containerizzabile via Docker. Il frontend è installabile come **PWA** (service worker, manifest, modalità fullscreen landscape) per utilizzo kiosk su dispositivi sportivi. - **Formazioni e cambi**
- Gestione formazione a 6 giocatori
- Rotazione automatica al cambio palla
- Dialog cambi con validazioni (`IN -> OUT`)
--- - **Controlli e personalizzazione**
- Configurazione nomi squadre
## Funzionalità - Toggle ordine squadre (inverti)
- Toggle visualizzazione punteggio/formazioni
### Gestione partita in tempo reale - Toggle striscia storico
- Tracciamento punti home/guest con indicatore di servizio - Sintesi vocale punteggio (Web Speech API)
- Gestione set e storico punti (striscia)
- Blocco azioni quando il set è già vinto
### Regole pallavolo integrate
- Set normali: vittoria a 25 con almeno 2 punti di scarto
- Set decisivo (5° set): vittoria a 15 con almeno 2 punti di scarto
- Modalità partita `2/3` o `3/5`
### Formazioni e cambi
- Gestione formazione a 6 giocatori per squadra
- Rotazione automatica al cambio palla
- Dialog cambi con validazione `IN → OUT`
### Controlli e personalizzazione
- Configurazione nomi squadre
- Inversione ordine di visualizzazione squadre
- Toggle punteggio/formazioni e visibilità striscia storico
- Sintesi vocale del punteggio (Web Speech API)
--- ---
## Requisiti ## Requisiti
### Ambiente di sviluppo ### Requisiti di Sistema
| Requisito | Versione minima | Consigliata | #### Per Sviluppo
|-----------|-----------------|-------------| - **Sistema Operativo**: Linux, macOS, Windows
| **Node.js** | `>= 18.19.0` | `24 LTS` | - **Node.js**: `>= 18.19.0` (consigliato `20 LTS`)
| **npm** | `>= 9` | — | - **npm**: `>= 9`
| **RAM** | 2 GB | 4 GB | - **RAM**: minimo 2GB (consigliato 4GB)
| **OS** | Linux, macOS, Windows | — |
### Test E2E #### Per Esecuzione Test E2E
- Browser Playwright installati (`chromium`, `firefox`)
- Su Linux, eventuali dipendenze sistema per browser headless
I test end-to-end richiedono i browser Playwright. Su Linux potrebbero essere necessarie dipendenze di sistema aggiuntive. Comandi utili:
```bash ```bash
node -v
npm -v
npx playwright install chromium firefox npx playwright install chromium firefox
# Linux (con dipendenze di sistema): # Linux, se necessario:
# npx playwright install --with-deps chromium firefox # npx playwright install --with-deps chromium firefox
``` ```
### Requisiti browser (utente finale) ### Requisiti Browser (Utente Finale)
| API | Utilizzo | Necessità | | Requisito | Dettaglio | Necessita |
|-----|----------|-----------| |-----------|-----------|-----------|
| JavaScript ES6+ | Moduli, async/await | Obbligatorio | | JavaScript ES6+ | Moduli, async/await | Obbligatorio |
| WebSocket | Sincronizzazione stato live | Obbligatorio | | WebSocket | Sincronizzazione stato live | Obbligatorio |
| Service Worker | Supporto PWA offline | Consigliato | | Service Worker API | Supporto PWA offline | Consigliato |
| Web Speech API | Annunci vocali punteggio | Opzionale | | Web Speech API | Annunci vocali | Opzionale |
**Browser testati:** Chrome/Chromium, Firefox, Mobile Chrome (Playwright Pixel 5). ### Browser Testati e Supportati
| Browser | Supporto | Note |
|---------|----------|------|
| Chrome/Chromium | ✅ | Completo |
| Firefox | ✅ | Completo |
| Mobile Chrome (Playwright Pixel 5) | ✅ | Copertura E2E mobile |
--- ---
## Installazione ## Installazione e Setup
### Prerequisiti
- Node.js `>= 18.19.0`
- npm `>= 9`
### Installazione
```bash ```bash
git clone https://santantonio.sytes.net/attilio/segnapunti.git git clone https://santantonio.sytes.net/attilio/segnapunti.git
@@ -94,120 +102,107 @@ npm install
--- ---
## Sviluppo ## Comandi per Sviluppo
### Dev server ### Dev Server
Avvia il server di sviluppo Vite:
```bash ```bash
npm run dev npm run dev
``` ```
Avvia il server Vite con hot reload: Accesso tipico in sviluppo:
- `http://localhost:5173/` Display - `http://localhost:5173/` -> Display
- `http://localhost:5173/controller.html` Controller - `http://localhost:5173/controller.html` -> Controller
### Build di produzione ### Modalita Sviluppo
- Hot reload attivo
- Build veloce lato Vite
- Buona per sviluppo UI/UX
---
## Comandi per Build
### Build Produzione
```bash ```bash
npm run build npm run build
``` ```
Genera la cartella `dist/` con asset ottimizzati, manifest e service worker PWA. Output:
- cartella `dist/`
- asset ottimizzati
- file PWA (manifest + service worker)
### Avvio in produzione (locale) ### Avvio Server Applicativo Locale (Display + Controller)
```bash ```bash
npm run serve npm run serve
``` ```
Espone i due server: Espone:
- `http://localhost:3000` Display - `http://localhost:3000` -> Display
- `http://localhost:3001` Controller - `http://localhost:3001` -> Controller
--- ### Altri comandi utili
## Terminal Controller (CLI)
Il CLI è un controller da terminale che si connette al server via WebSocket e permette di gestire la partita senza browser.
### Avvio
```bash ```bash
# Modalità produzione (server su porta 3000) npm run preview
npm run cli npm run start
# Modalità sviluppo (server Vite su porta 5173)
npm run cli:dev
# Porta custom
node cli.js <porta>
``` ```
Il CLI richiede che il server sia già in esecuzione in un altro terminale. ---
### Comandi disponibili ## Configurazione PWA
#### Punteggio L'app usa `vite-plugin-pwa` (vedi `vite.config.js`) con:
- `registerType: 'autoUpdate'`
- manifest installabile
- orientamento landscape
- modalita fullscreen
| Comando | Alias | Effetto | Caratteristiche principali:
|---------|-------|---------| - installabile su dispositivi supportati
| `punto casa` | `+`, `pc` | Assegna un punto alla squadra di casa | - aggiornamento automatico del service worker
| `punto ospite` | `-`, `po` | Assegna un punto alla squadra ospite | - supporto utilizzo offline (in base alle risorse cache)
| `undo` | `u` | Annulla l'ultimo punto assegnato |
#### Set
| Comando | Effetto |
|---------|---------|
| `set casa` | Incrementa il contatore set della squadra di casa |
| `set ospite` | Incrementa il contatore set della squadra ospite |
#### Partita
| Comando | Effetto |
|---------|---------|
| `serv` | Cambia il servizio (disponibile solo se il punteggio è 0-0) |
| `reset` | Resetta la partita — chiede conferma prima di procedere |
| `nomi <casa> <ospite>` | Imposta i nomi delle squadre (es. `nomi Antoniana Teate`) |
| `modalita 2/3` | Imposta la modalità best-of-3 |
| `modalita 3/5` | Imposta la modalità best-of-5 |
#### Informazioni
| Comando | Alias | Effetto |
|---------|-------|---------|
| `stato` | — | Mostra il punteggio corrente nel terminale |
| `help` | — | Mostra la lista dei comandi |
| `exit` | `q` | Chiude il CLI |
### Note
- **Tab**: completamento automatico dei comandi
- **Freccia su/giù**: navigazione nella history dei comandi (ultime 100 voci)
- Il Display nel browser si aggiorna in tempo reale ad ogni comando inviato
--- ---
## Test ## Logica Regolamentare Pallavolo
La suite di test copre tutti i livelli dell'applicazione: ### Vittoria Set
| Suite | Comando | Descrizione | - Set normali: vittoria a 25 con almeno 2 punti di scarto
|-------|---------|-------------| - Set decisivo: vittoria a 15 con almeno 2 punti di scarto
| Tutti | `npm run test:all` | Unit + integration + component + stress | - Modalita partita supportate: `2/3` e `3/5`
| Unit + integration | `npm run test:unit` | Logica di gioco e WebSocket |
| Component | `npm run test:component` | Componenti Vue |
| Stress | `npm run test:stress` | Load test WebSocket |
| E2E | `npm run test:e2e` | Playwright (chromium, firefox, mobile) |
Per la guida completa ai test, consultare [`tests/README.md`](tests/README.md). ### Rotazione Formazione
La rotazione avviene durante i cambi palla secondo la logica implementata in `src/gameState.js`.
### Formazione in Campo
Il sistema gestisce 6 posizioni per squadra e permette cambi validati da Controller.
--- ---
## Docker ## Test (stato attuale)
Suite presenti:
- Unit
- Integration
- Component
- Stress
- E2E (Playwright)
Comandi principali:
```bash ```bash
docker-compose up --build npm run test:all
npm run test:e2e
``` ```
Espone le porte `3000` (Display) e `3001` (Controller). Guida completa test:
- `tests/README.md`

302
cli.js
View File

@@ -1,302 +0,0 @@
import { WebSocket } from 'ws';
import readline from 'readline';
// ---------------------------------------------------------------------------
// ANSI helpers
// ---------------------------------------------------------------------------
const c = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
cyan: '\x1b[36m',
brightWhite: '\x1b[97m',
};
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const port = process.argv[2] || process.env.PORT || 3000;
const url = `ws://localhost:${port}/ws`;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let currentState = null;
let connected = false;
// ---------------------------------------------------------------------------
// Startup banner
// ---------------------------------------------------------------------------
console.log(`\n${c.bold}${c.cyan} Segnapunti Anto — Terminal Controller${c.reset}`);
console.log(`${c.dim} Connessione a ${url}...${c.reset}\n`);
// ---------------------------------------------------------------------------
// WebSocket
// ---------------------------------------------------------------------------
const ws = new WebSocket(url);
ws.on('error', (err) => {
console.error(`${c.red}Errore di connessione: ${err.message}${c.reset}`);
console.error(`${c.dim}Assicurati che il server sia avviato su ${url}${c.reset}`);
process.exit(1);
});
ws.on('open', () => {
connected = true;
console.log(` ${c.green}Connesso.${c.reset} Digita ${c.bold}help${c.reset} per i comandi disponibili.\n`);
ws.send(JSON.stringify({ type: 'register', role: 'controller' }));
});
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'state') {
currentState = msg.state;
printState(currentState);
} else if (msg.type === 'error') {
clearLine();
console.error(` ${c.red}Errore server: ${msg.message}${c.reset}`);
}
} catch {
// ignora messaggi malformati
}
rl.prompt(true);
});
ws.on('close', () => {
console.log(`\n${c.dim} Connessione chiusa.${c.reset}\n`);
process.exit(0);
});
// ---------------------------------------------------------------------------
// Output helpers
// ---------------------------------------------------------------------------
/** Cancella la riga corrente del terminale (evita di sovrascrivere il prompt). */
function clearLine() {
process.stdout.write('\r\x1b[K');
}
function printState(s) {
if (!s) return;
const { nomi, punt, set, servHome } = s.sp;
const homeServ = servHome ? `${c.yellow}${c.reset}` : ' ';
const guestServ = !servHome ? `${c.yellow}${c.reset}` : ' ';
const homeName = nomi.home.padEnd(15);
const guestName = nomi.guest.padEnd(15);
const homeScore = String(punt.home).padStart(3);
const guestScore = String(punt.guest).padStart(3);
clearLine();
console.log(
` ${homeServ} ${c.bold}${homeName}${c.reset}` +
`${c.brightWhite}${homeScore}${c.reset} ${c.dim}(set ${set.home})${c.reset}` +
` ${c.dim}${c.reset} ` +
`${c.brightWhite}${guestScore}${c.reset} ${c.dim}(set ${set.guest})${c.reset}` +
` ${c.bold}${guestName}${c.reset} ${guestServ}` +
` ${c.dim}[${s.modalitaPartita}]${c.reset}`
);
}
function printHelp() {
console.log(`
${c.bold} Punteggio${c.reset}
${c.cyan}+${c.reset} / ${c.cyan}pc${c.reset} Punto casa (shortcut)
${c.cyan}-${c.reset} / ${c.cyan}po${c.reset} Punto ospite (shortcut)
${c.cyan}punto casa${c.reset} Punto casa
${c.cyan}punto ospite${c.reset} Punto ospite
${c.cyan}undo${c.reset} / ${c.cyan}u${c.reset} Annulla ultimo punto
${c.bold} Set${c.reset}
${c.cyan}set casa${c.reset} Incrementa set casa
${c.cyan}set ospite${c.reset} Incrementa set ospite
${c.bold} Partita${c.reset}
${c.cyan}serv${c.reset} Cambia servizio (solo se 0-0)
${c.cyan}reset${c.reset} Resetta la partita (chiede conferma)
${c.cyan}nomi <casa> <ospite>${c.reset} Imposta nomi squadre
${c.cyan}modalita 2/3${c.reset} Imposta modalità best-of-3
${c.cyan}modalita 3/5${c.reset} Imposta modalità best-of-5
${c.bold} Informazioni${c.reset}
${c.cyan}stato${c.reset} Mostra punteggio attuale
${c.cyan}help${c.reset} Mostra questo aiuto
${c.cyan}exit${c.reset} / ${c.cyan}q${c.reset} Esci
${c.dim}Suggerimento: usa Tab per il completamento automatico dei comandi.${c.reset}
`);
}
// ---------------------------------------------------------------------------
// Command dispatch
// ---------------------------------------------------------------------------
function sendAction(action) {
if (!connected || ws.readyState !== WebSocket.OPEN) {
console.error(` ${c.red}Non connesso al server.${c.reset}`);
rl.prompt();
return;
}
ws.send(JSON.stringify({ type: 'action', action }));
}
function parseCommand(line) {
const parts = line.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
switch (cmd) {
case '+':
case 'pc':
sendAction({ type: 'incPunt', team: 'home' });
break;
case '-':
case 'po':
sendAction({ type: 'incPunt', team: 'guest' });
break;
case 'punto': {
const team = parts[1]?.toLowerCase();
if (team === 'casa' || team === 'home') {
sendAction({ type: 'incPunt', team: 'home' });
} else if (team === 'ospite' || team === 'guest') {
sendAction({ type: 'incPunt', team: 'guest' });
} else {
console.error(` ${c.red}Uso: punto casa | punto ospite${c.reset}`);
rl.prompt();
}
break;
}
case 'undo':
case 'u':
sendAction({ type: 'decPunt' });
break;
case 'set': {
const team = parts[1]?.toLowerCase();
if (team === 'casa' || team === 'home') {
sendAction({ type: 'incSet', team: 'home' });
} else if (team === 'ospite' || team === 'guest') {
sendAction({ type: 'incSet', team: 'guest' });
} else {
console.error(` ${c.red}Uso: set casa | set ospite${c.reset}`);
rl.prompt();
}
break;
}
case 'serv':
sendAction({ type: 'cambiaPalla' });
break;
case 'reset':
rl.question(` ${c.yellow}Confermi reset partita? (s/N) ${c.reset}`, (answer) => {
if (answer.trim().toLowerCase() === 's') {
sendAction({ type: 'resetta' });
} else {
console.log(` ${c.dim}Reset annullato.${c.reset}`);
rl.prompt();
}
});
return;
case 'nomi': {
const home = parts[1];
const guest = parts[2];
if (!home) {
console.error(` ${c.red}Uso: nomi <casa> <ospite>${c.reset}`);
rl.prompt();
break;
}
const payload = { type: 'setNomi', home };
if (guest) payload.guest = guest;
sendAction(payload);
break;
}
case 'modalita': {
const m = parts[1];
if (m !== '2/3' && m !== '3/5') {
console.error(` ${c.red}Uso: modalita 2/3 | modalita 3/5${c.reset}`);
rl.prompt();
break;
}
sendAction({ type: 'setModalita', modalita: m });
break;
}
case 'stato':
printState(currentState);
rl.prompt();
break;
case 'help':
printHelp();
rl.prompt();
break;
case 'exit':
case 'q':
ws.close();
break;
default:
console.error(
` ${c.red}Comando non riconosciuto: "${cmd}"${c.reset}` +
` — digita ${c.bold}help${c.reset} per la lista`
);
rl.prompt();
}
}
// ---------------------------------------------------------------------------
// REPL
// ---------------------------------------------------------------------------
const TAB_COMPLETIONS = [
'+', '-', 'pc', 'po',
'punto casa', 'punto ospite',
'undo', 'u',
'set casa', 'set ospite',
'serv',
'reset',
'nomi',
'modalita 2/3', 'modalita 3/5',
'stato', 'help',
'exit', 'q',
];
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: '> ',
historySize: 100,
completer(line) {
const hits = TAB_COMPLETIONS.filter((entry) => entry.startsWith(line));
return [hits.length ? hits : TAB_COMPLETIONS, line];
},
});
rl.on('line', (line) => {
if (!line.trim()) {
rl.prompt();
return;
}
parseCommand(line.trim());
});
rl.on('close', () => {
ws.close();
});

2589
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,6 @@
"preview": "node server.js", "preview": "node server.js",
"start": "node server.js", "start": "node server.js",
"serve": "vite build && node server.js", "serve": "vite build && node server.js",
"cli": "node cli.js",
"cli:dev": "node cli.js 5173",
"test": "vitest", "test": "vitest",
"test:unit": "vitest run tests/unit tests/integration", "test:unit": "vitest run tests/unit tests/integration",
"test:component": "vitest run tests/component", "test:component": "vitest run tests/component",
@@ -28,21 +26,18 @@
"wave-ui": "^3.3.0", "wave-ui": "^3.3.0",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"overrides": {
"serialize-javascript": "^7.0.5"
},
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.1", "@axe-core/playwright": "^4.11.1",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^4.1.0",
"@vitest/ui": "^4.0.18", "@vitest/ui": "^4.0.18",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"vite": "^7.3.1", "vite": "^4.3.9",
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^0.16.0",
"vitest": "^4.0.18" "vitest": "^4.0.18"
} }
} }

View File

@@ -78,6 +78,20 @@
</div> </div>
</div> </div>
<!-- Finestra fine set -->
<div class="overlay" v-if="setFinito">
<div class="dialog">
<div class="dialog-title">SET FINITO</div>
<div class="set-finito-info">
<div class="set-vincitore">{{ state.sp.nomi[setFinito.vincitore] }}</div>
<div class="set-score">{{ state.sp.punt.home }} {{ state.sp.punt.guest }}</div>
</div>
<div class="dialog-buttons">
<button class="btn btn-confirm" @click="sendAction({ type: 'confermaSet' })">CONFERMA</button>
</div>
</div>
</div>
<!-- Finestra configurazione --> <!-- Finestra configurazione -->
<div class="overlay" v-if="showConfig" @click.self="showConfig = false"> <div class="overlay" v-if="showConfig" @click.self="showConfig = false">
<div class="dialog dialog-config"> <div class="dialog dialog-config">
@@ -209,6 +223,8 @@ export default {
visuStriscia: true, visuStriscia: true,
modalitaPartita: "3/5", modalitaPartita: "3/5",
sp: { sp: {
strisce: [],
setFinito: null,
striscia: { home: [0], guest: [0] }, striscia: { home: [0], guest: [0] },
servHome: true, servHome: true,
punt: { home: 0, guest: 0 }, punt: { home: 0, guest: 0 },
@@ -227,6 +243,9 @@ export default {
isPunteggioZeroZero() { isPunteggioZeroZero() {
return this.state.sp.punt.home === 0 && this.state.sp.punt.guest === 0 return this.state.sp.punt.home === 0 && this.state.sp.punt.guest === 0
}, },
setFinito() {
return this.state.sp.setFinito
},
cambiValid() { cambiValid() {
let hasComplete = false let hasComplete = false
let allValid = true let allValid = true
@@ -905,4 +924,21 @@ export default {
padding: 8px 0; padding: 8px 0;
font-weight: 600; font-weight: 600;
} }
/* Fine set */
.set-finito-info {
text-align: center;
padding: 16px 0;
}
.set-vincitore {
font-size: 22px;
font-weight: 800;
color: #fdd835;
text-transform: uppercase;
margin-bottom: 8px;
}
.set-score {
font-size: 36px;
font-weight: 900;
}
</style> </style>

View File

@@ -112,6 +112,15 @@
{{ wsConnected ? '' : 'Disconnesso' }} {{ wsConnected ? '' : 'Disconnesso' }}
</div> </div>
</div> </div>
<!-- Overlay fine partita -->
<div class="partita-finita-overlay" v-if="state.sp.partitaFinita">
<div class="partita-finita-box">
<div class="partita-finita-label">PARTITA FINITA</div>
<div class="partita-finita-vincitore">{{ state.sp.nomi[state.sp.partitaFinita.vincitore] }}</div>
<div class="partita-finita-set">{{ state.sp.set.home }} {{ state.sp.set.guest }}</div>
</div>
</div>
</section> </section>
</template> </template>
@@ -132,6 +141,9 @@ export default {
visuStriscia: true, visuStriscia: true,
modalitaPartita: "3/5", modalitaPartita: "3/5",
sp: { sp: {
strisce: [],
setFinito: null,
partitaFinita: null,
striscia: { home: [0], guest: [0] }, striscia: { home: [0], guest: [0] },
servHome: true, servHome: true,
punt: { home: 0, guest: 0 }, punt: { home: 0, guest: 0 },
@@ -436,4 +448,38 @@ export default {
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
} }
/* Fine partita */
.partita-finita-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
.partita-finita-box {
text-align: center;
color: #fff;
}
.partita-finita-label {
font-size: 5vw;
font-weight: 700;
letter-spacing: 0.1em;
color: #aaa;
margin-bottom: 0.3em;
}
.partita-finita-vincitore {
font-size: 14vw;
font-weight: 900;
color: #fdd835;
text-transform: uppercase;
line-height: 1;
margin-bottom: 0.2em;
}
.partita-finita-set {
font-size: 8vw;
font-weight: 700;
}
</style> </style>

View File

@@ -10,6 +10,9 @@ export function createInitialState() {
visuStriscia: true, visuStriscia: true,
modalitaPartita: "3/5", modalitaPartita: "3/5",
sp: { sp: {
strisce: [],
setFinito: null,
partitaFinita: null,
striscia: { home: [0], guest: [" "] }, striscia: { home: [0], guest: [" "] },
servHome: true, servHome: true,
punt: { home: 0, guest: 0 }, punt: { home: 0, guest: 0 },
@@ -24,6 +27,13 @@ export function createInitialState() {
} }
} }
export function checkVittoriaPartita(state) {
const setsToWin = state.modalitaPartita === "2/3" ? 2 : 3
if (state.sp.set.home >= setsToWin) return "home"
if (state.sp.set.guest >= setsToWin) return "guest"
return null
}
export function checkVittoria(state) { export function checkVittoria(state) {
const puntHome = state.sp.punt.home const puntHome = state.sp.punt.home
const puntGuest = state.sp.punt.guest const puntGuest = state.sp.punt.guest
@@ -58,7 +68,7 @@ export function applyAction(state, action) {
switch (action.type) { switch (action.type) {
case "incPunt": { case "incPunt": {
const team = action.team const team = action.team
if (checkVittoria(s)) break if (s.sp.setFinito !== null || s.sp.partitaFinita !== null) break
s.sp.storicoServizio.push({ s.sp.storicoServizio.push({
servHome: s.sp.servHome, servHome: s.sp.servHome,
@@ -80,10 +90,17 @@ export function applyAction(state, action) {
} }
s.sp.servHome = team === "home" s.sp.servHome = team === "home"
if (checkVittoria(s)) {
s.sp.setFinito = { vincitore: team }
}
break break
} }
case "decPunt": { case "decPunt": {
if (s.sp.setFinito !== null) {
s.sp.setFinito = null
}
if (s.sp.storicoServizio.length > 0) { if (s.sp.storicoServizio.length > 0) {
const tmpHome = s.sp.striscia.home.pop() const tmpHome = s.sp.striscia.home.pop()
s.sp.striscia.guest.pop() s.sp.striscia.guest.pop()
@@ -105,6 +122,35 @@ export function applyAction(state, action) {
break break
} }
case "confermaSet": {
if (!s.sp.setFinito) break
const vincitore = s.sp.setFinito.vincitore
s.sp.strisce.push({
home: [...s.sp.striscia.home],
guest: [...s.sp.striscia.guest],
vincitore,
punt: { ...s.sp.punt },
})
s.sp.set[vincitore]++
s.sp.punt.home = 0
s.sp.punt.guest = 0
s.sp.storicoServizio = []
s.sp.setFinito = null
const vincitorePartita = checkVittoriaPartita(s)
if (vincitorePartita) {
s.sp.partitaFinita = { vincitore: vincitorePartita }
} else {
s.sp.striscia = s.sp.servHome
? { home: [0], guest: [" "] }
: { home: [" "], guest: [0] }
}
break
}
case "incSet": { case "incSet": {
const team = action.team const team = action.team
if (s.sp.set[team] === 2) { if (s.sp.set[team] === 2) {
@@ -139,6 +185,9 @@ export function applyAction(state, action) {
? { home: [0], guest: [" "] } ? { home: [0], guest: [" "] }
: { home: [" "], guest: [0] } : { home: [" "], guest: [0] }
s.sp.storicoServizio = [] s.sp.storicoServizio = []
s.sp.strisce = []
s.sp.setFinito = null
s.sp.partitaFinita = null
break break
} }

View File

@@ -1,303 +0,0 @@
import { vi, describe, it, expect, beforeAll, beforeEach } from 'vitest'
// vi.hoisted garantisce che refs sia disponibile nelle factory dei mock,
// che vengono hoistate prima degli import statici.
const refs = vi.hoisted(() => ({ ws: null, rl: null }))
// ---------------------------------------------------------------------------
// Mock: ws
// ---------------------------------------------------------------------------
vi.mock('ws', async () => {
const { EventEmitter } = await import('events')
class WebSocket extends EventEmitter {
static OPEN = 1
constructor() {
super()
this.readyState = 1
this.send = vi.fn()
this.close = vi.fn()
refs.ws = this
}
}
return { WebSocket }
})
// ---------------------------------------------------------------------------
// Mock: readline
// ---------------------------------------------------------------------------
vi.mock('readline', async () => {
const { EventEmitter } = await import('events')
return {
default: {
createInterface: vi.fn(() => {
const rl = new EventEmitter()
rl.prompt = vi.fn()
rl.question = vi.fn()
rl.close = vi.fn()
refs.rl = rl
return rl
}),
},
}
})
// ---------------------------------------------------------------------------
// Silenzia output e blocca process.exit durante i test
// ---------------------------------------------------------------------------
vi.spyOn(process, 'exit').mockImplementation(() => {})
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
// ---------------------------------------------------------------------------
// Importa cli.js — esegue gli effetti collaterali con le dipendenze mockate
// ---------------------------------------------------------------------------
beforeAll(async () => {
await import('../../cli.js')
refs.ws.emit('open')
})
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function sendLine(line) {
refs.rl.emit('line', line)
}
function lastSent() {
const calls = refs.ws.send.mock.calls
return calls.length ? JSON.parse(calls[calls.length - 1][0]) : null
}
// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
describe('CLI — Registrazione', () => {
it('invia register con ruolo controller all\'apertura del WebSocket', () => {
const call = refs.ws.send.mock.calls.find((c) => {
try { return JSON.parse(c[0]).type === 'register' } catch { return false }
})
expect(call).toBeDefined()
expect(JSON.parse(call[0])).toEqual({ type: 'register', role: 'controller' })
})
})
describe('CLI — Ricezione messaggi', () => {
beforeEach(() => {
refs.rl.prompt.mockClear()
vi.mocked(console.error).mockClear()
})
it('ripristina il prompt dopo un messaggio "state"', () => {
const state = {
sp: { nomi: { home: 'A', guest: 'B' }, punt: { home: 0, guest: 0 }, set: { home: 0, guest: 0 }, servHome: true },
modalitaPartita: '3/5',
}
refs.ws.emit('message', JSON.stringify({ type: 'state', state }))
expect(refs.rl.prompt).toHaveBeenCalled()
})
it('mostra un errore alla ricezione di un messaggio "error"', () => {
refs.ws.emit('message', JSON.stringify({ type: 'error', message: 'azione non valida' }))
expect(console.error).toHaveBeenCalled()
})
})
describe('CLI — Comandi punteggio', () => {
beforeEach(() => refs.ws.send.mockClear())
it('"+" → incPunt home', () => {
sendLine('+')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'incPunt', team: 'home' } })
})
it('"pc" → incPunt home (shortcut)', () => {
sendLine('pc')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'incPunt', team: 'home' } })
})
it('"-" → incPunt guest', () => {
sendLine('-')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'incPunt', team: 'guest' } })
})
it('"po" → incPunt guest (shortcut)', () => {
sendLine('po')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'incPunt', team: 'guest' } })
})
it('"punto casa" → incPunt home', () => {
sendLine('punto casa')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'incPunt', team: 'home' } })
})
it('"punto ospite" → incPunt guest', () => {
sendLine('punto ospite')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'incPunt', team: 'guest' } })
})
it('"punto" senza squadra → errore, nessun invio', () => {
sendLine('punto')
expect(refs.ws.send).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalled()
})
it('"undo" → decPunt', () => {
sendLine('undo')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'decPunt' } })
})
it('"u" → decPunt (shortcut)', () => {
sendLine('u')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'decPunt' } })
})
})
describe('CLI — Comandi set', () => {
beforeEach(() => refs.ws.send.mockClear())
it('"set casa" → incSet home', () => {
sendLine('set casa')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'incSet', team: 'home' } })
})
it('"set ospite" → incSet guest', () => {
sendLine('set ospite')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'incSet', team: 'guest' } })
})
it('"set" senza squadra → errore, nessun invio', () => {
sendLine('set')
expect(refs.ws.send).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalled()
})
})
describe('CLI — Comandi partita', () => {
beforeEach(() => refs.ws.send.mockClear())
it('"serv" → cambiaPalla', () => {
sendLine('serv')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'cambiaPalla' } })
})
it('"nomi <casa> <ospite>" → setNomi con entrambi i nomi', () => {
sendLine('nomi Antoniana Riviera')
expect(lastSent()).toMatchObject({
type: 'action',
action: { type: 'setNomi', home: 'Antoniana', guest: 'Riviera' },
})
})
it('"nomi <casa>" → setNomi con solo nome casa', () => {
sendLine('nomi Antoniana')
const sent = lastSent()
expect(sent).toMatchObject({ type: 'action', action: { type: 'setNomi', home: 'Antoniana' } })
expect(sent.action.guest).toBeUndefined()
})
it('"nomi" senza argomenti → errore, nessun invio', () => {
sendLine('nomi')
expect(refs.ws.send).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalled()
})
it('"modalita 2/3" → setModalita 2/3', () => {
sendLine('modalita 2/3')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'setModalita', modalita: '2/3' } })
})
it('"modalita 3/5" → setModalita 3/5', () => {
sendLine('modalita 3/5')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'setModalita', modalita: '3/5' } })
})
it('"modalita" con valore non valido → errore, nessun invio', () => {
sendLine('modalita 4/7')
expect(refs.ws.send).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalled()
})
})
describe('CLI — Comando reset (con conferma)', () => {
beforeEach(() => {
refs.ws.send.mockClear()
refs.rl.question.mockClear()
})
it('conferma "s" → invia resetta', () => {
refs.rl.question.mockImplementationOnce((_msg, cb) => cb('s'))
sendLine('reset')
expect(lastSent()).toMatchObject({ type: 'action', action: { type: 'resetta' } })
})
it('risposta "n" → non invia nulla', () => {
refs.rl.question.mockImplementationOnce((_msg, cb) => cb('n'))
sendLine('reset')
expect(refs.ws.send).not.toHaveBeenCalled()
})
it('risposta vuota → non invia nulla', () => {
refs.rl.question.mockImplementationOnce((_msg, cb) => cb(''))
sendLine('reset')
expect(refs.ws.send).not.toHaveBeenCalled()
})
})
describe('CLI — Comandi informativi', () => {
beforeEach(() => {
refs.ws.send.mockClear()
vi.mocked(console.log).mockClear()
})
it('"help" → stampa aiuto, nessun invio', () => {
sendLine('help')
expect(console.log).toHaveBeenCalled()
expect(refs.ws.send).not.toHaveBeenCalled()
})
it('"stato" → nessun invio', () => {
sendLine('stato')
expect(refs.ws.send).not.toHaveBeenCalled()
})
it('comando sconosciuto → messaggio di errore, nessun invio', () => {
sendLine('xyzzy')
expect(refs.ws.send).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalled()
})
it('riga vuota → nessun invio', () => {
refs.rl.emit('line', ' ')
expect(refs.ws.send).not.toHaveBeenCalled()
})
})
describe('CLI — Uscita', () => {
it('"exit" → chiude il WebSocket', () => {
refs.ws.close.mockClear()
sendLine('exit')
expect(refs.ws.close).toHaveBeenCalled()
})
it('"q" → chiude il WebSocket (shortcut)', () => {
refs.ws.close.mockClear()
sendLine('q')
expect(refs.ws.close).toHaveBeenCalled()
})
it('chiusura readline → chiude il WebSocket', () => {
refs.ws.close.mockClear()
refs.rl.emit('close')
expect(refs.ws.close).toHaveBeenCalled()
})
})