7 Commits

Author SHA1 Message Date
b3d114c108 chore(deps): risolve tutte le vulnerabilità npm
- vite 4 → 7.3.1 (fix esbuild/rollup CVE)
- @vitejs/plugin-vue 4 → 6.0.5 (compatibilità vite 7)
- vite-plugin-pwa 0.16 → 1.2.0 (compatibilità vite 7)
- override serialize-javascript → ^7.0.5 (fix CVE via workbox-build)

0 vulnerabilità rimanenti (erano 24).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:25:46 +02:00
b9aed683c6 test(cli): aggiunge suite unit per il terminal controller
32 test che coprono registrazione WebSocket, parsing di tutti i comandi
(shortcut inclusi), validazioni input, conferma reset e gestione uscita.
2026-04-01 19:19:21 +02:00
606b2c1ee6 feat(cli): aggiunge terminal controller da riga di comando
Nuovo script cli.js che si connette al server via WebSocket come
controller e permette di gestire la partita da terminale con comandi
testuali, colori ANSI, tab-completion e history dei comandi.

Aggiunge script npm "cli" / "cli:dev" e documenta tutti i comandi nel README
2026-04-01 19:12:09 +02:00
27e29a78e7 aggiorna README 2026-04-01 18:58:02 +02:00
d1e8279608 feat(striscia): nomi fissi a sinistra, punti scorrono verso destra
- Layout ora usa CSS Grid (max-content 1fr) per allineare le colonne
  dei nomi indipendentemente dalla loro lunghezza
- I punti crescono da sinistra verso destra; un watcher Vue imposta
  scrollLeft al massimo ad ogni aggiornamento, mantenendo visibili
  gli ultimi punti a destra quando la striscia va oltre lo schermo
- Le celle vuote (spazio al posto del punto) non mostrano più
  lo sfondo giallo-verde (classe item-vuoto)
2026-02-21 11:19:38 +01:00
6bc74ab3e0 fix(striscia): mostra lo zero iniziale solo per la squadra che serve
- All'inizio del set lo 0 compare solo nella riga della squadra che batte;
  la squadra non servente mostra uno spazio per mantenere l'allineamento dei nomi
- cambiaPalla aggiorna la striscia dopo il flip del servizio (a 0-0)
- resetta inizializza la striscia in base al servente corrente anziché mostrare 0 per entrambe
- Corretto il guard dell'undo: usa storicoServizio.length > 0 invece di
  striscia.home.length > 1, che avrebbe bloccato l'annulla quando la riga
  della squadra non servente contiene un solo elemento
2026-02-20 18:25:44 +01:00
668140e5b7 Aggiunge Dockerfile e compose.yaml
Vengono usate 3000 dispplay e 3001 controller.
2026-02-15 18:58:29 +01:00
10 changed files with 1842 additions and 1715 deletions

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine AS builder
WORKDIR /usr/src/app
# Copia tutto
COPY . .
# Aggiunge GIT ma serve solo se si vuole evidenziare un hash del commit
RUN apk add git
# aggiunge l'ultima versione di node
RUN npm install -g npm@latest
# Installa tutte le dipendenze del progetto
RUN npm install
# Qui fa partire il comando...
# Per adesso è dev perchè non ho capito bene il tutto... (Attilio)
CMD ["npm", "run", "serve"]

255
README.md
View File

@@ -1,98 +1,90 @@
# Segnapunti Anto
Applicazione web **Progressive Web App (PWA)** per tracciare i punteggi di partite di pallavolo in tempo reale.
Applicazione web **fullstack real-time** per il tracciamento dei punteggi di partite di pallavolo, installabile come PWA.
---
## Panoramica
**Segnapunti Anto** e un'applicazione digitale per il tracciamento del punteggio durante partite di pallavolo, ottimizzata per tablet e smartphone.
**Segnapunti Anto** è un'applicazione fullstack per il tracciamento del punteggio durante partite di pallavolo, ottimizzata per tablet e smartphone in contesto sportivo.
L'app e composta da due interfacce:
- **Display** (tabellone pubblico)
- **Controller** (pannello operatore)
### Architettura
Le due interfacce condividono lo stato in tempo reale tramite WebSocket.
Il sistema è composto da un **backend Node.js/Express** e due interfacce web separate:
### Funzionalita Principali
| Interfaccia | Porta | Ruolo |
|-------------|-------|-------|
| **Display** | 3000 | Tabellone pubblico — mostra punteggi, formazioni e storico |
| **Controller** | 3001 | Pannello operatore — invia azioni e gestisce la partita |
- **Gestione partita in tempo reale**
- Tracciamento punti home/guest
- Gestione set
- Indicatore servizio
- Storico punti (striscia)
- Blocchi logici quando il set e gia vinto
Le due interfacce comunicano tramite **WebSocket** (`/ws`): ogni azione del Controller viene elaborata dal server e trasmessa in broadcast a tutti i client connessi.
- **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`
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.
- **Formazioni e cambi**
- Gestione formazione a 6 giocatori
- Rotazione automatica al cambio palla
- Dialog cambi con validazioni (`IN -> OUT`)
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.
- **Controlli e personalizzazione**
- Configurazione nomi squadre
- Toggle ordine squadre (inverti)
- Toggle visualizzazione punteggio/formazioni
- Toggle striscia storico
- Sintesi vocale punteggio (Web Speech API)
---
## Funzionalità
### Gestione partita in tempo reale
- Tracciamento punti home/guest con indicatore di servizio
- 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 di Sistema
### Ambiente di sviluppo
#### Per Sviluppo
- **Sistema Operativo**: Linux, macOS, Windows
- **Node.js**: `>= 18.19.0` (consigliato `20 LTS`)
- **npm**: `>= 9`
- **RAM**: minimo 2GB (consigliato 4GB)
| Requisito | Versione minima | Consigliata |
|-----------|-----------------|-------------|
| **Node.js** | `>= 18.19.0` | `24 LTS` |
| **npm** | `>= 9` | — |
| **RAM** | 2 GB | 4 GB |
| **OS** | Linux, macOS, Windows | — |
#### Per Esecuzione Test E2E
- Browser Playwright installati (`chromium`, `firefox`)
- Su Linux, eventuali dipendenze sistema per browser headless
### Test E2E
Comandi utili:
I test end-to-end richiedono i browser Playwright. Su Linux potrebbero essere necessarie dipendenze di sistema aggiuntive.
```bash
node -v
npm -v
npx playwright install chromium firefox
# Linux, se necessario:
# Linux (con dipendenze di sistema):
# npx playwright install --with-deps chromium firefox
```
### Requisiti Browser (Utente Finale)
### Requisiti browser (utente finale)
| Requisito | Dettaglio | Necessita |
|-----------|-----------|-----------|
| API | Utilizzo | Necessità |
|-----|----------|-----------|
| JavaScript ES6+ | Moduli, async/await | Obbligatorio |
| WebSocket | Sincronizzazione stato live | Obbligatorio |
| Service Worker API | Supporto PWA offline | Consigliato |
| Web Speech API | Annunci vocali | Opzionale |
| Service Worker | Supporto PWA offline | Consigliato |
| Web Speech API | Annunci vocali punteggio | Opzionale |
### Browser Testati e Supportati
| Browser | Supporto | Note |
|---------|----------|------|
| Chrome/Chromium | ✅ | Completo |
| Firefox | ✅ | Completo |
| Mobile Chrome (Playwright Pixel 5) | ✅ | Copertura E2E mobile |
**Browser testati:** Chrome/Chromium, Firefox, Mobile Chrome (Playwright Pixel 5).
---
## Installazione e Setup
### Prerequisiti
- Node.js `>= 18.19.0`
- npm `>= 9`
### Installazione
## Installazione
```bash
git clone https://santantonio.sytes.net/attilio/segnapunti.git
@@ -102,107 +94,120 @@ npm install
---
## Comandi per Sviluppo
## Sviluppo
### Dev Server
Avvia il server di sviluppo Vite:
### Dev server
```bash
npm run dev
```
Accesso tipico in sviluppo:
- `http://localhost:5173/` -> Display
- `http://localhost:5173/controller.html` -> Controller
Avvia il server Vite con hot reload:
- `http://localhost:5173/` Display
- `http://localhost:5173/controller.html` Controller
### Modalita Sviluppo
- Hot reload attivo
- Build veloce lato Vite
- Buona per sviluppo UI/UX
---
## Comandi per Build
### Build Produzione
### Build di produzione
```bash
npm run build
```
Output:
- cartella `dist/`
- asset ottimizzati
- file PWA (manifest + service worker)
Genera la cartella `dist/` con asset ottimizzati, manifest e service worker PWA.
### Avvio Server Applicativo Locale (Display + Controller)
### Avvio in produzione (locale)
```bash
npm run serve
```
Espone:
- `http://localhost:3000` -> Display
- `http://localhost:3001` -> Controller
Espone i due server:
- `http://localhost:3000` Display
- `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
npm run preview
npm run start
# Modalità produzione (server su porta 3000)
npm run cli
# 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.
## Configurazione PWA
### Comandi disponibili
L'app usa `vite-plugin-pwa` (vedi `vite.config.js`) con:
- `registerType: 'autoUpdate'`
- manifest installabile
- orientamento landscape
- modalita fullscreen
#### Punteggio
Caratteristiche principali:
- installabile su dispositivi supportati
- aggiornamento automatico del service worker
- supporto utilizzo offline (in base alle risorse cache)
| Comando | Alias | Effetto |
|---------|-------|---------|
| `punto casa` | `+`, `pc` | Assegna un punto alla squadra di casa |
| `punto ospite` | `-`, `po` | Assegna un punto alla squadra ospite |
| `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
---
## Logica Regolamentare Pallavolo
## Test
### Vittoria Set
La suite di test copre tutti i livelli dell'applicazione:
- Set normali: vittoria a 25 con almeno 2 punti di scarto
- Set decisivo: vittoria a 15 con almeno 2 punti di scarto
- Modalita partita supportate: `2/3` e `3/5`
| Suite | Comando | Descrizione |
|-------|---------|-------------|
| Tutti | `npm run test:all` | Unit + integration + component + stress |
| 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) |
### 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.
Per la guida completa ai test, consultare [`tests/README.md`](tests/README.md).
---
## Test (stato attuale)
Suite presenti:
- Unit
- Integration
- Component
- Stress
- E2E (Playwright)
Comandi principali:
## Docker
```bash
npm run test:all
npm run test:e2e
docker-compose up --build
```
Guida completa test:
- `tests/README.md`
Espone le porte `3000` (Display) e `3001` (Controller).

302
cli.js Normal file
View File

@@ -0,0 +1,302 @@
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();
});

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
segnapunti:
build: .
ports:
- 3000:3000
- 3001:3001
container_name: segnapunti-container

2592
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -90,15 +90,17 @@
</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">
<span class="striscia-nome text-bold">{{ state.sp.nomi.home }}</span>
<div class="striscia-items" ref="homeItems">
<div v-for="(h, i) in state.sp.striscia.home" :key="'sh'+i"
class="item" :class="{ 'item-vuoto': String(h).trim() === '' }">
{{ 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">
<span class="striscia-nome text-bold guest-striscia">{{ state.sp.nomi.guest }}</span>
<div class="striscia-items guest-striscia" ref="guestItems">
<div v-for="(h, i) in state.sp.striscia.guest" :key="'sg'+i"
class="item" :class="{ 'item-vuoto': String(h).trim() === '' }">
{{ String(h) }}
</div>
</div>
@@ -190,6 +192,24 @@ export default {
this.ws = null
}
},
watch: {
'state.sp.striscia.home': {
deep: true,
handler() {
this.$nextTick(() => {
if (this.$refs.homeItems) this.$refs.homeItems.scrollLeft = this.$refs.homeItems.scrollWidth
})
}
},
'state.sp.striscia.guest': {
deep: true,
handler() {
this.$nextTick(() => {
if (this.$refs.guestItems) this.$refs.guestItems.scrollLeft = this.$refs.guestItems.scrollWidth
})
}
}
},
methods: {
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)

View File

@@ -10,7 +10,7 @@ export function createInitialState() {
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: { home: [0], guest: [0] },
striscia: { home: [0], guest: [" "] },
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
@@ -84,7 +84,7 @@ export function applyAction(state, action) {
}
case "decPunt": {
if (s.sp.striscia.home.length > 1 && s.sp.storicoServizio.length > 0) {
if (s.sp.storicoServizio.length > 0) {
const tmpHome = s.sp.striscia.home.pop()
s.sp.striscia.guest.pop()
const statoServizio = s.sp.storicoServizio.pop()
@@ -118,6 +118,9 @@ export function applyAction(state, action) {
case "cambiaPalla": {
if (s.sp.punt.home === 0 && s.sp.punt.guest === 0) {
s.sp.servHome = !s.sp.servHome
s.sp.striscia = s.sp.servHome
? { home: [0], guest: [" "] }
: { home: [" "], guest: [0] }
}
break
}
@@ -132,7 +135,9 @@ export function applyAction(state, action) {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
}
s.sp.striscia = { home: [0], guest: [0] }
s.sp.striscia = s.sp.servHome
? { home: [0], guest: [" "] }
: { home: [" "], guest: [0] }
s.sp.storicoServizio = []
break
}

View File

@@ -135,20 +135,35 @@ button:focus-visible {
color: white
}
.striscia {
position:fixed;
text-align: right;
position: fixed;
bottom: 50px;
left: 10px;
right: 10px;
margin-left: -10000px;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 2px;
align-items: center;
}
.striscia-nome {
white-space: nowrap;
padding-right: 6px;
}
.striscia-items {
display: flex;
flex-wrap: nowrap;
overflow: hidden;
}
.striscia .item {
width: 25px;
min-width: 25px;
text-align: center;
font-weight: bold;
display: inline-block;
flex-shrink: 0;
border-radius: 5px;
}
.striscia .item:not(.item-vuoto) {
background-color: rgb(206, 247, 3);
color: blue;
border-radius: 5px;
}
.campo-config {

303
tests/unit/cli.test.js Normal file
View File

@@ -0,0 +1,303 @@
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()
})
})