Compare commits
12 Commits
71119da727
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b3d114c108 | |||
| b9aed683c6 | |||
| 606b2c1ee6 | |||
| 27e29a78e7 | |||
| d1e8279608 | |||
| 6bc74ab3e0 | |||
| 668140e5b7 | |||
| aa88e2b7a1 | |||
| e4d212eea3 | |||
| 33e2583b4d | |||
| be286ec069 | |||
| 0b154d9e56 |
18
Dockerfile
Normal 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"]
|
||||
|
||||
324
README.md
@@ -1,255 +1,213 @@
|
||||
# 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** è un'applicazione digitale per il tracciamento dei punteggi durante partite di pallavolo, ottimizzata per l'uso su tablet e smartphone. Sviluppata per il team Antoniana, l'app fornisce un'interfaccia fullscreen touch-friendly con supporto offline e controlli da tastiera.
|
||||
**Segnapunti Anto** è un'applicazione fullstack per il tracciamento del punteggio durante partite di pallavolo, ottimizzata per tablet e smartphone in contesto sportivo.
|
||||
|
||||
### Funzionalità Principali
|
||||
### Architettura
|
||||
|
||||
- **Gestione Completa Partite**
|
||||
- Tracciamento punti in tempo reale per entrambe le squadre
|
||||
- Conteggio automatico dei set (modalità 2/3 o 3/5)
|
||||
- Indicatore visivo del servizio
|
||||
- Blocco incremento punti a set concluso
|
||||
- Cronologia punti con striscia visiva
|
||||
Il sistema è composto da un **backend Node.js/Express** e due interfacce web separate:
|
||||
|
||||
- **Formazioni Squadra**
|
||||
- Visualizzazione interattiva dei 6 giocatori in campo
|
||||
- Rotazione automatica regolamentare al cambio palla
|
||||
- Configurazione manuale dei numeri di maglia
|
||||
- Dialog cambi con uno o due cambi (IN → OUT) e validazioni
|
||||
- Supporto logica pallavolo ufficiale (25 punti + 2 di vantaggio, tie-break a 15 nel set decisivo)
|
||||
| Interfaccia | Porta | Ruolo |
|
||||
|-------------|-------|-------|
|
||||
| **Display** | 3000 | Tabellone pubblico — mostra punteggi, formazioni e storico |
|
||||
| **Controller** | 3001 | Pannello operatore — invia azioni e gestisce la partita |
|
||||
|
||||
- **Controlli Multimodali**
|
||||
- Scorciatoie da tastiera complete (vedi sezione [Shortcuts](#shortcuts))
|
||||
- Sintesi vocale per annunci punteggio in italiano (Web Speech API)
|
||||
Le due interfacce comunicano tramite **WebSocket** (`/ws`): ogni azione del Controller viene elaborata dal server e trasmessa in broadcast a tutti i client connessi.
|
||||
|
||||
- **Personalizzazione**
|
||||
- Configurazione dinamica nomi squadre
|
||||
- Selettore modalità partita: al meglio di 3 o al meglio di 5
|
||||
- Toggle layout orizzontale (inverti home/guest)
|
||||
- Modalità visualizzazione: punteggio semplice o formazioni complete
|
||||
- Nascondi/mostra controlli e cronologia
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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 (WSL2 consigliato)
|
||||
- **Node.js**: v20.2.0 o superiore (LTS consigliato)
|
||||
- **npm**: v9.0.0 o superiore (incluso con Node.js)
|
||||
- **RAM**: Minimo 2GB, consigliato 4GB
|
||||
- **Spazio Disco**: ~500MB per dipendenze e build
|
||||
| Requisito | Versione minima | Consigliata |
|
||||
|-----------|-----------------|-------------|
|
||||
| **Node.js** | `>= 18.19.0` | `24 LTS` |
|
||||
| **npm** | `>= 9` | — |
|
||||
| **RAM** | 2 GB | 4 GB |
|
||||
| **OS** | Linux, macOS, Windows | — |
|
||||
|
||||
#### Per Deployment
|
||||
- **Server Web**: Qualsiasi server statico (nginx, Apache, Vercel, Netlify)
|
||||
- **HTTPS**: Obbligatorio per Service Worker e PWA (eccetto localhost)
|
||||
- **Connessione Internet**: Solo per primo caricamento (poi funziona offline)
|
||||
### Test E2E
|
||||
|
||||
### Requisiti Browser (Utente Finale)
|
||||
I test end-to-end richiedono i browser Playwright. Su Linux potrebbero essere necessarie dipendenze di sistema aggiuntive.
|
||||
|
||||
| Requisito | Dettaglio | Necessità |
|
||||
|-----------|-----------|-----------|
|
||||
| **JavaScript ES6+** | Supporto moduli, arrow functions, async/await | Obbligatorio |
|
||||
| **Service Worker API** | Per funzionalità offline PWA | Obbligatorio |
|
||||
| **Fullscreen API** | Per modalità schermo intero | Consigliato |
|
||||
| **Web Speech API** | Per sintesi vocale punteggi | Opzionale |
|
||||
| **Local Storage** | Per persistenza configurazioni | Consigliato |
|
||||
```bash
|
||||
npx playwright install chromium firefox
|
||||
# Linux (con dipendenze di sistema):
|
||||
# npx playwright install --with-deps chromium firefox
|
||||
```
|
||||
|
||||
### Browser Testati e Supportati
|
||||
### Requisiti browser (utente finale)
|
||||
|
||||
| Browser | Versione Minima | Supporto | Note |
|
||||
|---------|-----------------|----------|------|
|
||||
| Chrome/Chromium | 90+ | ✅ Completo | Consigliato per tutte le features |
|
||||
| Firefox | 88+ | ✅ Completo | Supporto completo PWA e Speech API |
|
||||
| API | Utilizzo | Necessità |
|
||||
|-----|----------|-----------|
|
||||
| JavaScript ES6+ | Moduli, async/await | Obbligatorio |
|
||||
| WebSocket | Sincronizzazione stato live | Obbligatorio |
|
||||
| Service Worker | Supporto PWA offline | Consigliato |
|
||||
| Web Speech API | Annunci vocali punteggio | Opzionale |
|
||||
|
||||
**Browser testati:** Chrome/Chromium, Firefox, Mobile Chrome (Playwright Pixel 5).
|
||||
|
||||
---
|
||||
|
||||
## Installazione e Setup
|
||||
|
||||
### Prerequisiti
|
||||
|
||||
- **Node.js** v20.2.0 (consigliato)
|
||||
- **npm** o **yarn**
|
||||
|
||||
### Installazione con NVM (consigliato)
|
||||
## Installazione
|
||||
|
||||
```bash
|
||||
# Installa la versione corretta di Node.js
|
||||
nvm install v20.2.0
|
||||
nvm use v20.2.0
|
||||
|
||||
# Clona il repository
|
||||
git clone <repository-url>
|
||||
git clone https://santantonio.sytes.net/attilio/segnapunti.git
|
||||
cd segnapunti
|
||||
|
||||
# Installa le dipendenze
|
||||
npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comandi per Sviluppo
|
||||
## Sviluppo
|
||||
|
||||
### Dev Server
|
||||
|
||||
Avvia il server di sviluppo con hot-reload:
|
||||
### Dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
L'applicazione sarà disponibile su [http://localhost:5173](http://localhost:5173)
|
||||
Avvia il server Vite con hot reload:
|
||||
- `http://localhost:5173/` — Display
|
||||
- `http://localhost:5173/controller.html` — Controller
|
||||
|
||||
### Modalità Sviluppo
|
||||
- Hot Module Replacement (HMR) attivo
|
||||
- Source maps per debugging
|
||||
- Vue DevTools supportato
|
||||
- Errori e warnings in console
|
||||
|
||||
---
|
||||
|
||||
## Comandi per Build
|
||||
|
||||
### Build Produzione
|
||||
|
||||
Genera i file ottimizzati per il deployment:
|
||||
### Build di produzione
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Output:**
|
||||
- Cartella `/dist` con file statici ottimizzati
|
||||
- Service Worker generato automaticamente
|
||||
- PWA manifest configurato
|
||||
- Assets minificati e con hash per cache busting
|
||||
- Base path: `/segnap` (modificabile in `vite.config.js`)
|
||||
Genera la cartella `dist/` con asset ottimizzati, manifest e service worker PWA.
|
||||
|
||||
### Preview Build
|
||||
|
||||
Anteprima locale della build di produzione:
|
||||
### Avvio in produzione (locale)
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
npm run serve
|
||||
```
|
||||
|
||||
Serve i file dalla cartella `/dist` per testare la build prima del deploy.
|
||||
Espone i due server:
|
||||
- `http://localhost:3000` — Display
|
||||
- `http://localhost:3001` — Controller
|
||||
|
||||
---
|
||||
|
||||
## Shortcuts
|
||||
## Terminal Controller (CLI)
|
||||
|
||||
### Controlli Tastiera Squadra Home
|
||||
Il CLI è un controller da terminale che si connette al server via WebSocket e permette di gestire la partita senza browser.
|
||||
|
||||
| Scorciatoia | Azione |
|
||||
|-------------|--------|
|
||||
| `Ctrl + ↑` | Incrementa punti |
|
||||
| `Ctrl + ↓` | Decrementa punti |
|
||||
| `Ctrl + →` | Incrementa set |
|
||||
| `Ctrl + C` | Apri dialog cambi |
|
||||
### Avvio
|
||||
|
||||
### Controlli Tastiera Squadra Guest
|
||||
```bash
|
||||
# Modalità produzione (server su porta 3000)
|
||||
npm run cli
|
||||
|
||||
| Scorciatoia | Azione |
|
||||
|-------------|--------|
|
||||
| `Shift + ↑` | Incrementa punti |
|
||||
| `Shift + ↓` | Decrementa punti |
|
||||
| `Shift + →` | Incrementa set |
|
||||
| `Shift + C` | Apri dialog cambi |
|
||||
# Modalità sviluppo (server Vite su porta 5173)
|
||||
npm run cli:dev
|
||||
|
||||
### Comandi Globali
|
||||
# Porta custom
|
||||
node cli.js <porta>
|
||||
```
|
||||
|
||||
| Scorciatoia | Azione |
|
||||
|-------------|--------|
|
||||
| `Ctrl + ←` | Cambio palla (servizio) - **solo a 0-0** |
|
||||
| `Ctrl + M` | Apri configurazione nomi squadre e formazioni |
|
||||
| `Ctrl + B` | Toggle visibilità barra pulsanti |
|
||||
| `Ctrl + F` | Attiva/disattiva fullscreen |
|
||||
| `Ctrl + S` | Annuncio vocale punteggio corrente |
|
||||
| `Ctrl + Z` | Switch tra visualizzazione formazioni e punteggio |
|
||||
Il CLI richiede che il server sia già in esecuzione in un altro terminale.
|
||||
|
||||
### Comandi disponibili
|
||||
|
||||
#### Punteggio
|
||||
|
||||
| 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
|
||||
|
||||
---
|
||||
|
||||
## Configurazione PWA
|
||||
## Test
|
||||
|
||||
L'applicazione è configurata come **Progressive Web App** nel file [vite.config.js](vite.config.js):
|
||||
La suite di test copre tutti i livelli dell'applicazione:
|
||||
|
||||
```javascript
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: "app_segnap",
|
||||
short_name: "segnap",
|
||||
description: "Segnapunti standalone.",
|
||||
background_color: "#eee",
|
||||
theme_color: '#ffffff',
|
||||
display: "fullscreen",
|
||||
orientation: "landscape",
|
||||
icons: [
|
||||
{ src: 'segnap-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'segnap-512x512.png', sizes: '512x512', type: 'image/png' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
| 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) |
|
||||
|
||||
### Caratteristiche PWA
|
||||
|
||||
- **Display**: Fullscreen per massimizzare lo spazio visivo
|
||||
- **Orientamento**: Landscape (orizzontale) ottimizzato per tablet
|
||||
- **Auto-update**: Service Worker con aggiornamento automatico
|
||||
- **Offline**: Funzionamento completo senza connessione internet
|
||||
- **Installabile**: Aggiungibile alla home screen come app nativa
|
||||
|
||||
### Installazione PWA
|
||||
|
||||
**Android/Desktop (Chrome):**
|
||||
- Menu → "Installa app" o icona (⊕) nella barra degli indirizzi
|
||||
|
||||
**iOS (Safari):**
|
||||
- Share (□↑) → "Aggiungi a Home"
|
||||
Per la guida completa ai test, consultare [`tests/README.md`](tests/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Logica Regolamentare Pallavolo
|
||||
## Docker
|
||||
|
||||
### Vittoria Set
|
||||
|
||||
- **Set regolari (1-4)**: Primo a 25 punti con almeno 2 di vantaggio
|
||||
- **Set decisivo**:
|
||||
- Modalità 2/3: 3° set a 15 punti con almeno 2 di vantaggio
|
||||
- Modalità 3/5: 5° set a 15 punti con almeno 2 di vantaggio
|
||||
- **Blocco automatico**: Non consente assegnare punti oltre la vittoria
|
||||
|
||||
### Rotazione Formazione
|
||||
|
||||
La rotazione avviene **automaticamente** quando:
|
||||
1. La squadra **conquista il servizio** (cambio palla)
|
||||
2. Il punteggio è diverso da 0-0
|
||||
|
||||
**Limitazione cambio palla manuale:**
|
||||
- Il cambio manuale del servizio (`Ctrl + ←`) è consentito **solo a 0-0**
|
||||
- Questa limitazione previene errori nella rotazione delle formazioni
|
||||
|
||||
### Formazione in Campo
|
||||
|
||||
Visualizzazione a 6 posizioni standard:
|
||||
|
||||
```
|
||||
Rete
|
||||
┌─────┬─────┬─────┐
|
||||
│ 4 │ 3 │ 2 │ ← Fila anteriore
|
||||
├─────┼─────┼─────┤
|
||||
│ 5 │ 6 │ 1 │ ← Fila posteriore
|
||||
└─────┴─────┴─────┘
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
La rotazione avviene in senso orario: 1→6→5→4→3→2→1
|
||||
Espone le porte `3000` (Display) e `3001` (Controller).
|
||||
|
||||
302
cli.js
Normal 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
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
segnapunti:
|
||||
build: .
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 3001:3001
|
||||
container_name: segnapunti-container
|
||||
|
||||
3095
package-lock.json
generated
25
package.json
@@ -9,12 +9,17 @@
|
||||
"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",
|
||||
"test:stress": "vitest run tests/stress",
|
||||
"test:all": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:codegen": "playwright codegen"
|
||||
"test:e2e": "playwright test --config=playwright.config.cjs",
|
||||
"test:e2e:ui": "playwright test --config=playwright.config.cjs --ui",
|
||||
"test:e2e:codegen": "playwright codegen --config=playwright.config.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
@@ -23,15 +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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
playwright.config.cjs
Normal file
@@ -0,0 +1,38 @@
|
||||
const { defineConfig, devices } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run serve',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
@@ -50,14 +50,10 @@ export default defineConfig({
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
<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" />
|
||||
<img v-show="state.sp.servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
|
||||
</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" />
|
||||
<img v-show="!state.sp.servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -713,7 +713,7 @@ export default {
|
||||
.btn-danger {
|
||||
background: rgba(198, 40, 40, 0.25);
|
||||
border: 1px solid rgba(239, 83, 80, 0.4);
|
||||
color: #ef5350;
|
||||
color: #ff8a80;
|
||||
padding: 14px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<span :style="{ 'float': 'left' }">
|
||||
{{ state.sp.nomi.home }}
|
||||
<span class="serv-slot">
|
||||
<img v-show="state.sp.servHome" src="/serv.png" width="25" />
|
||||
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
|
||||
</span>
|
||||
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
|
||||
</span>
|
||||
@@ -18,7 +18,7 @@
|
||||
<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" />
|
||||
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
|
||||
</span>
|
||||
{{ state.sp.nomi.guest }}
|
||||
</span>
|
||||
@@ -51,7 +51,7 @@
|
||||
<span :style="{ 'float': 'left' }">
|
||||
{{ state.sp.nomi.guest }}
|
||||
<span class="serv-slot">
|
||||
<img v-show="!state.sp.servHome" src="/serv.png" width="25" />
|
||||
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
|
||||
</span>
|
||||
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
|
||||
</span>
|
||||
@@ -62,7 +62,7 @@
|
||||
<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" />
|
||||
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
|
||||
</span>
|
||||
{{ state.sp.nomi.home }}
|
||||
</span>
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
364
tests/README.md
@@ -1,169 +1,281 @@
|
||||
# Guida ai Test per Principianti - Segnapunti
|
||||
# Guida ai Test
|
||||
|
||||
Benvenuto nella guida ai test del progetto!
|
||||
Obiettivo della guida:
|
||||
- capire che tipi di test esistono nel progetto
|
||||
- capire a cosa servono davvero
|
||||
- sapere come lanciarli senza errori
|
||||
- sapere come leggere i risultati
|
||||
- sapere cosa fare quando qualcosa fallisce
|
||||
|
||||
Questo progetto usa tre tipi di test:
|
||||
## 0) Perche facciamo i test?
|
||||
|
||||
Un test è un controllo automatico.
|
||||
|
||||
In pratica:
|
||||
- tu cambi il codice
|
||||
- lanci i test
|
||||
- i test ti dicono se hai rotto qualcosa
|
||||
|
||||
Se i test sono verdi, hai una buona probabilita che il progetto sia ancora stabile.
|
||||
Se i test sono rossi, c'e un problema da capire e sistemare.
|
||||
|
||||
## 1) Struttura delle cartelle 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
|
||||
├── unit/ # Test della logica pura (molto veloci)
|
||||
├── integration/ # Test di moduli che comunicano tra loro
|
||||
├── component/ # Test dei componenti Vue in isolamento
|
||||
├── stress/ # Test sotto carico (molti client/azioni)
|
||||
├── e2e/ # Test end-to-end su browser reale
|
||||
└── 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.
|
||||
## 2) Tecnologie usate
|
||||
|
||||
---
|
||||
- `Vitest`: per `unit`, `integration`, `component`, `stress`
|
||||
- `Playwright`: per `e2e`
|
||||
|
||||
## 1. Come eseguire i Test Veloci (Unit)
|
||||
Tradotto in modo semplice:
|
||||
- Vitest controlla parti interne del progetto
|
||||
- Playwright controlla il comportamento reale dell'app nel browser
|
||||
|
||||
Questi test controllano che la logica interna del server funzioni correttamente.
|
||||
## 3) Prerequisiti (prima di tutto)
|
||||
|
||||
### Cosa viene testato?
|
||||
### 3.1 Installa dipendenze
|
||||
|
||||
#### `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:
|
||||
Dalla root del progetto:
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
npm install
|
||||
```
|
||||
|
||||
### 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:
|
||||
### 3.2 Installa browser Playwright (solo E2E)
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
npx playwright install chromium firefox
|
||||
```
|
||||
|
||||
*(Se vuoi eseguire solo gli integrazione, puoi usare `npx vitest tests/integration`)*
|
||||
Se non fai questo passo, gli E2E possono fallire subito con errore tipo:
|
||||
- `Executable doesn't exist`
|
||||
|
||||
### 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
|
||||
## 4) Comandi principali
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
npm run test # Vitest in watch mode (resta in ascolto)
|
||||
npm run test:all # Tutta la suite Vitest una volta
|
||||
npm run test:unit # Unit + integration
|
||||
npm run test:component # Solo component test
|
||||
npm run test:stress # Solo stress test
|
||||
npm run test:e2e # Tutti gli E2E Playwright
|
||||
npm run test:e2e:ui # Playwright con interfaccia grafica
|
||||
```
|
||||
|
||||
(Oppure `npx playwright test` per maggiori opzioni)
|
||||
Ordine consigliato (quando vuoi verificare tutto):
|
||||
1. `npm run test:all`
|
||||
2. `npm run test:e2e`
|
||||
|
||||
### Cosa succede se va tutto bene?
|
||||
## 5) Cosa testa ogni suite (spiegato semplice)
|
||||
|
||||
Il test impiegherà qualche secondo (è più lento degli unit test). Se tutto va bene, vedrai:
|
||||
### 5.1 Unit (`tests/unit`)
|
||||
|
||||
```
|
||||
Running 1 test using 1 worker
|
||||
✓ 1 [chromium] › tests/e2e/game-simulation.spec.js:3:1 › Game Simulation (5.0s)
|
||||
1 passed (5.5s)
|
||||
Cosa sono:
|
||||
- test piccoli e veloci sulla logica del gioco
|
||||
|
||||
A cosa servono:
|
||||
- trovano subito bug in regole punteggio/set/reset
|
||||
|
||||
Esempi reali nel progetto:
|
||||
- incremento/decremento punti
|
||||
- cambio palla
|
||||
- vittoria set con regola dei 2 punti di scarto
|
||||
- reset stato
|
||||
|
||||
Quando falliscono:
|
||||
- quasi sempre c'e un problema nella logica core
|
||||
|
||||
### 5.2 Integration (`tests/integration`)
|
||||
|
||||
Cosa sono:
|
||||
- test su pezzi che lavorano insieme (es. WebSocket + handler)
|
||||
|
||||
A cosa servono:
|
||||
- verificano che i messaggi si muovano nel modo corretto
|
||||
|
||||
Esempi reali nel progetto:
|
||||
- registrazione client `display`/`controller`
|
||||
- broadcast stato ai client
|
||||
- validazione input malformati
|
||||
- autorizzazioni (solo controller puo inviare certe azioni)
|
||||
|
||||
Quando falliscono:
|
||||
- spesso c'e problema nel protocollo messaggi o nei controlli di ruolo
|
||||
|
||||
### 5.3 Component (`tests/component`)
|
||||
|
||||
Cosa sono:
|
||||
- test dei componenti Vue senza browser completo
|
||||
|
||||
A cosa servono:
|
||||
- controllano rendering e comportamento UI locale
|
||||
|
||||
Esempi reali nel progetto:
|
||||
- punteggio mostrato correttamente
|
||||
- stato connessione
|
||||
- click bottoni controller
|
||||
- dialog reset/config/cambi
|
||||
|
||||
Quando falliscono:
|
||||
- spesso hai rotto template, computed, metodi o binding
|
||||
|
||||
### 5.4 Stress (`tests/stress`)
|
||||
|
||||
Cosa sono:
|
||||
- test per simulare carico elevato
|
||||
|
||||
A cosa servono:
|
||||
- verificano che il sistema regga molti client e molte azioni rapide
|
||||
|
||||
Esempi reali nel progetto:
|
||||
- tanti client display connessi insieme
|
||||
- burst di azioni consecutive
|
||||
|
||||
Quando falliscono:
|
||||
- possono emergere problemi di performance o consistenza stato
|
||||
|
||||
### 5.5 End-to-End (`tests/e2e`)
|
||||
|
||||
Cosa sono:
|
||||
- test realistici nel browser
|
||||
|
||||
A cosa servono:
|
||||
- verificano che Controller e Display funzionino davvero insieme
|
||||
|
||||
File principali:
|
||||
- `basic-flow.spec.cjs`: flusso base Controller <-> Display
|
||||
- `game-operations.spec.cjs`: reset, config, toggle, cambi
|
||||
- `game-simulation.spec.cjs`: simulazione partita
|
||||
- `full-match.spec.cjs`: scenari partita completi
|
||||
- `accessibility.spec.cjs`: controlli accessibilita con axe
|
||||
- `visual-regression.spec.cjs`: confronto screenshot con baseline
|
||||
|
||||
Nota importante:
|
||||
- gli E2E sono configurati in seriale (`workers: 1`) per evitare interferenze sullo stato partita condiviso.
|
||||
|
||||
## 6) Come leggere i risultati
|
||||
|
||||
### 6.1 Risultati Vitest
|
||||
|
||||
Caso OK (verde):
|
||||
|
||||
```text
|
||||
Test Files 6 passed (6)
|
||||
Tests 159 passed (159)
|
||||
```
|
||||
|
||||
Significa che il sito si apre correttamente e il flusso di gioco funziona dall'inizio alla fine!
|
||||
Significa:
|
||||
- tutti i test Vitest sono passati
|
||||
|
||||
### Cosa succede se c'è un errore?
|
||||
Caso KO (rosso):
|
||||
- guarda prima il nome del file test
|
||||
- poi il nome del test (`describe/test`)
|
||||
- poi la riga con `expected` e `received`
|
||||
- infine vai alla riga indicata nello stack trace
|
||||
|
||||
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:
|
||||
### 6.2 Risultati Playwright
|
||||
|
||||
Caso OK:
|
||||
|
||||
```text
|
||||
72 passed
|
||||
```
|
||||
|
||||
Caso KO:
|
||||
- apri il report HTML
|
||||
|
||||
```bash
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
Si aprirà una pagina web dove potrai vedere passo-passo cosa è successo, inclusi screenshot e video dell'errore.
|
||||
Nel report puoi vedere:
|
||||
- step del test
|
||||
- errori precisi
|
||||
- screenshot/diff
|
||||
- trace
|
||||
|
||||
---
|
||||
## 7) Visual Regression (screenshot)
|
||||
|
||||
## Domande Frequenti
|
||||
I test visual confrontano immagini attuali con immagini baseline.
|
||||
|
||||
**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.
|
||||
Cartella baseline:
|
||||
- `tests/e2e/visual-regression.spec.cjs-snapshots/`
|
||||
|
||||
**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.
|
||||
Se cambia la UI in modo intenzionale:
|
||||
- aggiorna snapshot
|
||||
|
||||
**Q: Come creo un nuovo test?**
|
||||
A:
|
||||
```bash
|
||||
npm run test:e2e -- --update-snapshots
|
||||
```
|
||||
|
||||
- **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.
|
||||
Poi rilancia controllo:
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Se la UI non doveva cambiare:
|
||||
- non aggiornare snapshot
|
||||
- correggi prima il codice UI/CSS
|
||||
|
||||
## 8) Errori comuni e soluzione veloce
|
||||
|
||||
- Errore Playwright `Executable doesn't exist`:
|
||||
- esegui `npx playwright install chromium firefox`
|
||||
|
||||
- E2E instabili con punteggi strani:
|
||||
- assicurati che i test restino seriali (`workers: 1`)
|
||||
- assicurati che ogni test parta da stato pulito (reset)
|
||||
|
||||
- Selettore ambiguo (esempio bottone `Cambi`):
|
||||
- usa selector piu specifici, ad esempio `getByRole(..., { exact: true })`
|
||||
|
||||
- Failure accessibilita:
|
||||
- controlla prima `alt` delle immagini e contrasto colori
|
||||
|
||||
## 9) Mini checklist prima di fare push
|
||||
|
||||
Esegui:
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Se hai cambiato UI e i visual falliscono per differenze volute:
|
||||
|
||||
```bash
|
||||
npm run test:e2e -- --update-snapshots
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## 10) Come aggiungere un nuovo test (consigli pratici)
|
||||
|
||||
- metti i test nel posto giusto:
|
||||
- `tests/unit` per logica pura
|
||||
- `tests/integration` per moduli che dialogano
|
||||
- `tests/component` per Vue in isolamento
|
||||
- `tests/stress` per carico
|
||||
- `tests/e2e` per flussi reali
|
||||
|
||||
- mantieni nomi chiari:
|
||||
- il titolo del test deve spiegare cosa verifica
|
||||
|
||||
- evita test dipendenti da ordine:
|
||||
- ogni test deve potersi eseguire da solo
|
||||
|
||||
- negli E2E:
|
||||
- porta sempre il sistema in stato iniziale
|
||||
- usa selector robusti
|
||||
- limita `waitForTimeout` e preferisci attese su condizioni reali
|
||||
|
||||
Se segui questi punti, i test restano stabili e facili da capire anche per chi entra ora nel progetto.
|
||||
|
||||
255
tests/component/ControllerPage.test.js
Normal file
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ControllerPage from '../../src/components/ControllerPage.vue'
|
||||
|
||||
// Mock globale WebSocket per jsdom
|
||||
class MockWebSocket {
|
||||
static OPEN = 1
|
||||
static CONNECTING = 0
|
||||
readyState = 0
|
||||
onopen = null
|
||||
onclose = null
|
||||
onmessage = null
|
||||
onerror = null
|
||||
send = vi.fn()
|
||||
close = vi.fn()
|
||||
constructor() {
|
||||
// Simula connessione immediata
|
||||
setTimeout(() => {
|
||||
this.readyState = 1
|
||||
if (this.onopen) this.onopen()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('WebSocket', MockWebSocket)
|
||||
|
||||
// Helper per creare il componente con stato personalizzato
|
||||
function mountController(stateOverrides = {}) {
|
||||
const wrapper = mount(ControllerPage, {
|
||||
global: {
|
||||
stubs: { 'w-app': true, 'w-button': true }
|
||||
}
|
||||
})
|
||||
if (Object.keys(stateOverrides).length > 0) {
|
||||
wrapper.vm.state = { ...wrapper.vm.state, ...stateOverrides }
|
||||
}
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('ControllerPage.vue', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// RENDERING INIZIALE
|
||||
// =============================================
|
||||
describe('Rendering iniziale', () => {
|
||||
it('dovrebbe mostrare i nomi dei team', () => {
|
||||
const wrapper = mountController()
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Antoniana')
|
||||
expect(text).toContain('Guest')
|
||||
})
|
||||
|
||||
it('dovrebbe mostrare punteggio 0-0', () => {
|
||||
const wrapper = mountController()
|
||||
const pts = wrapper.findAll('.team-pts')
|
||||
expect(pts[0].text()).toBe('0')
|
||||
expect(pts[1].text()).toBe('0')
|
||||
})
|
||||
|
||||
it('dovrebbe mostrare SET 0 per entrambi i team', () => {
|
||||
const wrapper = mountController()
|
||||
const sets = wrapper.findAll('.team-set')
|
||||
expect(sets[0].text()).toContain('SET 0')
|
||||
expect(sets[1].text()).toContain('SET 0')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// CLICK PUNTEGGIO
|
||||
// =============================================
|
||||
describe('Click punteggio', () => {
|
||||
it('dovrebbe chiamare sendAction con incPunt home al click sul team home', async () => {
|
||||
const wrapper = mountController()
|
||||
const spy = vi.spyOn(wrapper.vm, 'sendAction')
|
||||
await wrapper.find('.team-score.home-bg').trigger('click')
|
||||
expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'home' })
|
||||
})
|
||||
|
||||
it('dovrebbe chiamare sendAction con incPunt guest al click sul team guest', async () => {
|
||||
const wrapper = mountController()
|
||||
const spy = vi.spyOn(wrapper.vm, 'sendAction')
|
||||
await wrapper.find('.team-score.guest-bg').trigger('click')
|
||||
expect(spy).toHaveBeenCalledWith({ type: 'incPunt', team: 'guest' })
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// BOTTONE CAMBIO PALLA
|
||||
// =============================================
|
||||
describe('Cambio Palla', () => {
|
||||
it('dovrebbe essere abilitato a 0-0', () => {
|
||||
const wrapper = mountController()
|
||||
const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla'))
|
||||
expect(btn.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dovrebbe essere disabilitato se il punteggio non è 0-0', async () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.state.sp.punt.home = 5
|
||||
await wrapper.vm.$nextTick()
|
||||
const btn = wrapper.findAll('.btn-ctrl').find(b => b.text().includes('Cambio Palla'))
|
||||
expect(btn.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// DIALOG RESET
|
||||
// =============================================
|
||||
describe('Dialog Reset', () => {
|
||||
it('click Reset dovrebbe aprire la conferma', async () => {
|
||||
const wrapper = mountController()
|
||||
expect(wrapper.find('.overlay').exists()).toBe(false)
|
||||
await wrapper.find('.btn-danger').trigger('click')
|
||||
expect(wrapper.vm.confirmReset).toBe(true)
|
||||
expect(wrapper.find('.overlay').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('click NO dovrebbe chiudere la conferma', async () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.confirmReset = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.find('.btn-cancel').trigger('click')
|
||||
expect(wrapper.vm.confirmReset).toBe(false)
|
||||
})
|
||||
|
||||
it('click SI dovrebbe chiamare doReset', async () => {
|
||||
const wrapper = mountController()
|
||||
const spy = vi.spyOn(wrapper.vm, 'sendAction')
|
||||
wrapper.vm.confirmReset = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.find('.btn-confirm').trigger('click')
|
||||
expect(spy).toHaveBeenCalledWith({ type: 'resetta' })
|
||||
expect(wrapper.vm.confirmReset).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// COMPUTED cambiValid
|
||||
// =============================================
|
||||
describe('cambiValid', () => {
|
||||
it('dovrebbe essere false se tutti i campi sono vuoti', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.cambiData = [{ in: '', out: '' }, { in: '', out: '' }]
|
||||
expect(wrapper.vm.cambiValid).toBe(false)
|
||||
})
|
||||
|
||||
it('dovrebbe essere true con un cambio completo', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.cambiData = [{ in: '10', out: '1' }, { in: '', out: '' }]
|
||||
expect(wrapper.vm.cambiValid).toBe(true)
|
||||
})
|
||||
|
||||
it('dovrebbe essere false con un cambio parziale (solo IN)', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.cambiData = [{ in: '10', out: '' }, { in: '', out: '' }]
|
||||
expect(wrapper.vm.cambiValid).toBe(false)
|
||||
})
|
||||
|
||||
it('dovrebbe essere false con un cambio parziale (solo OUT)', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.cambiData = [{ in: '', out: '1' }, { in: '', out: '' }]
|
||||
expect(wrapper.vm.cambiValid).toBe(false)
|
||||
})
|
||||
|
||||
it('dovrebbe essere true con due cambi completi', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.cambiData = [{ in: '10', out: '1' }, { in: '11', out: '2' }]
|
||||
expect(wrapper.vm.cambiValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// SPEAK
|
||||
// =============================================
|
||||
describe('speak', () => {
|
||||
it('dovrebbe generare "zero a zero" a 0-0', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.wsConnected = true
|
||||
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
|
||||
wrapper.vm.speak()
|
||||
const sent = JSON.parse(wrapper.vm.ws.send.mock.calls[0][0])
|
||||
expect(sent.type).toBe('speak')
|
||||
expect(sent.text).toBe('zero a zero')
|
||||
})
|
||||
|
||||
it('dovrebbe generare "N pari" a punteggio uguale', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.state.sp.punt.home = 5
|
||||
wrapper.vm.state.sp.punt.guest = 5
|
||||
wrapper.vm.wsConnected = true
|
||||
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
|
||||
wrapper.vm.speak()
|
||||
const sent = JSON.parse(wrapper.vm.ws.send.mock.calls[0][0])
|
||||
expect(sent.text).toBe('5 pari')
|
||||
})
|
||||
|
||||
it('dovrebbe annunciare prima il punteggio di chi batte (home serve)', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.state.sp.punt.home = 15
|
||||
wrapper.vm.state.sp.punt.guest = 10
|
||||
wrapper.vm.state.sp.servHome = true
|
||||
wrapper.vm.wsConnected = true
|
||||
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
|
||||
wrapper.vm.speak()
|
||||
const sent = JSON.parse(wrapper.vm.ws.send.mock.calls[0][0])
|
||||
expect(sent.text).toBe('15 a 10')
|
||||
})
|
||||
|
||||
it('dovrebbe annunciare prima il punteggio di chi batte (guest serve)', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.state.sp.punt.home = 10
|
||||
wrapper.vm.state.sp.punt.guest = 15
|
||||
wrapper.vm.state.sp.servHome = false
|
||||
wrapper.vm.wsConnected = true
|
||||
wrapper.vm.ws = { readyState: 1, send: vi.fn() }
|
||||
wrapper.vm.speak()
|
||||
const sent = JSON.parse(wrapper.vm.ws.send.mock.calls[0][0])
|
||||
expect(sent.text).toBe('15 a 10')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// BARRA CONNESSIONE
|
||||
// =============================================
|
||||
describe('Barra connessione', () => {
|
||||
it('dovrebbe avere classe "connected" quando connesso', async () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.wsConnected = true
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.conn-bar').classes()).toContain('connected')
|
||||
})
|
||||
|
||||
it('non dovrebbe avere classe "connected" quando disconnesso', () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.wsConnected = false
|
||||
expect(wrapper.find('.conn-bar').classes()).not.toContain('connected')
|
||||
})
|
||||
|
||||
it('dovrebbe mostrare "Connesso" quando connesso', async () => {
|
||||
const wrapper = mountController()
|
||||
wrapper.vm.wsConnected = true
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.conn-bar').text()).toContain('Connesso')
|
||||
})
|
||||
})
|
||||
})
|
||||
195
tests/component/DisplayPage.test.js
Normal file
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DisplayPage from '../../src/components/DisplayPage.vue'
|
||||
|
||||
// Mock globale WebSocket per jsdom
|
||||
class MockWebSocket {
|
||||
static OPEN = 1
|
||||
static CONNECTING = 0
|
||||
readyState = 0
|
||||
onopen = null
|
||||
onclose = null
|
||||
onmessage = null
|
||||
onerror = null
|
||||
send = vi.fn()
|
||||
close = vi.fn()
|
||||
constructor() {
|
||||
setTimeout(() => {
|
||||
this.readyState = 1
|
||||
if (this.onopen) this.onopen()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('WebSocket', MockWebSocket)
|
||||
|
||||
// Mock requestFullscreen e speechSynthesis
|
||||
vi.stubGlobal('speechSynthesis', {
|
||||
speak: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
getVoices: () => []
|
||||
})
|
||||
|
||||
function mountDisplay() {
|
||||
return mount(DisplayPage, {
|
||||
global: {
|
||||
stubs: { 'w-app': true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('DisplayPage.vue', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// RENDERING PUNTEGGIO
|
||||
// =============================================
|
||||
describe('Rendering punteggio', () => {
|
||||
it('dovrebbe mostrare i nomi dei team', () => {
|
||||
const wrapper = mountDisplay()
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Antoniana')
|
||||
expect(text).toContain('Guest')
|
||||
})
|
||||
|
||||
it('dovrebbe mostrare punteggio iniziale 0-0', () => {
|
||||
const wrapper = mountDisplay()
|
||||
const punti = wrapper.findAll('.punt')
|
||||
expect(punti[0].text()).toBe('0')
|
||||
expect(punti[1].text()).toBe('0')
|
||||
})
|
||||
|
||||
it('dovrebbe mostrare i set corretti', () => {
|
||||
const wrapper = mountDisplay()
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('set 0')
|
||||
})
|
||||
|
||||
it('dovrebbe aggiornare il punteggio quando lo stato cambia', async () => {
|
||||
const wrapper = mountDisplay()
|
||||
wrapper.vm.state.sp.punt.home = 15
|
||||
wrapper.vm.state.sp.punt.guest = 12
|
||||
await wrapper.vm.$nextTick()
|
||||
const punti = wrapper.findAll('.punt')
|
||||
expect(punti[0].text()).toBe('15')
|
||||
expect(punti[1].text()).toBe('12')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// ORDINE TEAM
|
||||
// =============================================
|
||||
describe('Ordine team', () => {
|
||||
it('order=true → Home prima di Guest', () => {
|
||||
const wrapper = mountDisplay()
|
||||
const headers = wrapper.findAll('.hea')
|
||||
expect(headers[0].classes()).toContain('home')
|
||||
expect(headers[1].classes()).toContain('guest')
|
||||
})
|
||||
|
||||
it('order=false → Guest prima di Home', async () => {
|
||||
const wrapper = mountDisplay()
|
||||
wrapper.vm.state.order = false
|
||||
await wrapper.vm.$nextTick()
|
||||
const headers = wrapper.findAll('.hea')
|
||||
expect(headers[0].classes()).toContain('guest')
|
||||
expect(headers[1].classes()).toContain('home')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// FORMAZIONE vs PUNTEGGIO
|
||||
// =============================================
|
||||
describe('visuForm toggle', () => {
|
||||
it('visuForm=false → mostra punteggio grande', () => {
|
||||
const wrapper = mountDisplay()
|
||||
expect(wrapper.find('.punteggio-container').exists()).toBe(true)
|
||||
expect(wrapper.find('.form').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('visuForm=true → mostra formazione', async () => {
|
||||
const wrapper = mountDisplay()
|
||||
wrapper.vm.state.visuForm = true
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findAll('.form').length).toBeGreaterThan(0)
|
||||
expect(wrapper.find('.punteggio-container').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('formazione mostra 6 giocatori per team', async () => {
|
||||
const wrapper = mountDisplay()
|
||||
wrapper.vm.state.visuForm = true
|
||||
await wrapper.vm.$nextTick()
|
||||
const formDivs = wrapper.findAll('.formdiv')
|
||||
// 6 per home + 6 per guest = 12
|
||||
expect(formDivs).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// STRISCIA
|
||||
// =============================================
|
||||
describe('visuStriscia toggle', () => {
|
||||
it('visuStriscia=true → mostra la striscia', () => {
|
||||
const wrapper = mountDisplay()
|
||||
expect(wrapper.find('.striscia').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('visuStriscia=false → nasconde la striscia', async () => {
|
||||
const wrapper = mountDisplay()
|
||||
wrapper.vm.state.visuStriscia = false
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.striscia').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// INDICATORE CONNESSIONE
|
||||
// =============================================
|
||||
describe('Indicatore connessione', () => {
|
||||
it('dovrebbe avere classe "disconnected" quando non connesso', () => {
|
||||
const wrapper = mountDisplay()
|
||||
const status = wrapper.find('.connection-status')
|
||||
expect(status.classes()).toContain('disconnected')
|
||||
})
|
||||
|
||||
it('dovrebbe avere classe "connected" quando connesso', async () => {
|
||||
const wrapper = mountDisplay()
|
||||
wrapper.vm.wsConnected = true
|
||||
await wrapper.vm.$nextTick()
|
||||
const status = wrapper.find('.connection-status')
|
||||
expect(status.classes()).toContain('connected')
|
||||
})
|
||||
|
||||
it('dovrebbe mostrare "Disconnesso" quando non connesso', () => {
|
||||
const wrapper = mountDisplay()
|
||||
const status = wrapper.find('.connection-status')
|
||||
expect(status.text()).toContain('Disconnesso')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// ICONA SERVIZIO
|
||||
// =============================================
|
||||
describe('Icona servizio', () => {
|
||||
it('dovrebbe mostrare l\'icona servizio sul team home quando servHome=true', () => {
|
||||
const wrapper = mountDisplay()
|
||||
// v-show imposta display:none. In happy-dom controlliamo lo style.
|
||||
const imgs = wrapper.findAll('.serv-slot img')
|
||||
// Con state.order=true e servHome=true:
|
||||
// - la prima img (home) è visibile (no display:none)
|
||||
// - la seconda img (guest) ha display:none
|
||||
const homeStyle = imgs[0].attributes('style') || ''
|
||||
const guestStyle = imgs[1].attributes('style') || ''
|
||||
expect(homeStyle).not.toContain('display: none')
|
||||
expect(guestStyle).toContain('display: none')
|
||||
})
|
||||
})
|
||||
})
|
||||
72
tests/e2e/accessibility.spec.cjs
Normal file
@@ -0,0 +1,72 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const AxeBuilderImport = require('@axe-core/playwright');
|
||||
const AxeBuilder = AxeBuilderImport.default || AxeBuilderImport;
|
||||
|
||||
test.describe('Accessibility (a11y)', () => {
|
||||
|
||||
test('Display: non dovrebbe avere violazioni critiche a11y', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.disableRules(['color-contrast']) // il display ha sfondo nero con testo grande, valutato separatamente
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('Controller: non dovrebbe avere violazioni critiche a11y', async ({ page }) => {
|
||||
await page.goto('http://localhost:3001');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze();
|
||||
|
||||
// Mostra i dettagli delle violazioni se ci sono
|
||||
if (results.violations.length > 0) {
|
||||
console.log('A11y violations:', JSON.stringify(results.violations.map(v => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
description: v.description,
|
||||
nodes: v.nodes.length
|
||||
})), null, 2));
|
||||
}
|
||||
|
||||
// Accettiamo solo violazioni minor (non critiche o serie)
|
||||
const serious = results.violations.filter(v =>
|
||||
v.impact === 'critical' || v.impact === 'serious'
|
||||
);
|
||||
expect(serious).toEqual([]);
|
||||
});
|
||||
|
||||
test('Controller: i touch target dovrebbero avere dimensione minima', async ({ page }) => {
|
||||
await page.goto('http://localhost:3001');
|
||||
await page.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Controlla che i bottoni principali abbiano dimensione minima 44x44px
|
||||
const buttons = page.locator('.btn-ctrl');
|
||||
const count = await buttons.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const box = await buttons.nth(i).boundingBox();
|
||||
expect(box.width).toBeGreaterThanOrEqual(44);
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
});
|
||||
|
||||
test('Controller: i bottoni punteggio dovrebbero avere dimensione adeguata', async ({ page }) => {
|
||||
await page.goto('http://localhost:3001');
|
||||
await page.waitForSelector('.conn-bar.connected');
|
||||
|
||||
const scoreButtons = page.locator('.team-score');
|
||||
const count = await scoreButtons.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const box = await scoreButtons.nth(i).boundingBox();
|
||||
expect(box.width).toBeGreaterThanOrEqual(100);
|
||||
expect(box.height).toBeGreaterThanOrEqual(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
119
tests/e2e/basic-flow.spec.cjs
Normal file
@@ -0,0 +1,119 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Basic Flow: Controller ↔ Display', () => {
|
||||
|
||||
test('dovrebbe caricare Display e Controller con i titoli corretti', async ({ context }) => {
|
||||
const displayPage = await context.newPage();
|
||||
const controllerPage = await context.newPage();
|
||||
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
|
||||
await expect(displayPage).toHaveTitle(/Segnapunti/);
|
||||
await expect(controllerPage).toHaveTitle(/Controller/);
|
||||
});
|
||||
|
||||
test('il punteggio iniziale dovrebbe essere 0-0', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
|
||||
// Attende la connessione WebSocket
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
const homeScore = controllerPage.locator('.team-score.home-bg .team-pts');
|
||||
const guestScore = controllerPage.locator('.team-score.guest-bg .team-pts');
|
||||
await expect(homeScore).toHaveText('0');
|
||||
await expect(guestScore).toHaveText('0');
|
||||
});
|
||||
|
||||
test('click +1 Home sul Controller dovrebbe aggiornare il Display', async ({ context }) => {
|
||||
const displayPage = await context.newPage();
|
||||
const controllerPage = await context.newPage();
|
||||
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
|
||||
// Attende la connessione WebSocket del controller
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Reset per stato pulito
|
||||
await controllerPage.getByText(/Reset/i).first().click();
|
||||
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
|
||||
if (await btnConfirm.isVisible()) {
|
||||
await btnConfirm.click();
|
||||
}
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Click +1 Home
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Verifica Controller mostra 1
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('1');
|
||||
|
||||
// Verifica Display mostra 1 (il punteggio grande)
|
||||
await expect(displayPage.locator('.punt.home')).toHaveText('1');
|
||||
});
|
||||
|
||||
test('click +1 Guest sul Controller dovrebbe aggiornare il Display', async ({ context }) => {
|
||||
const displayPage = await context.newPage();
|
||||
const controllerPage = await context.newPage();
|
||||
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Reset
|
||||
await controllerPage.getByText(/Reset/i).first().click();
|
||||
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
|
||||
if (await btnConfirm.isVisible()) {
|
||||
await btnConfirm.click();
|
||||
}
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Click +1 Guest
|
||||
await controllerPage.locator('.team-score.guest-bg').click();
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Verifica Controller
|
||||
await expect(controllerPage.locator('.team-score.guest-bg .team-pts')).toHaveText('1');
|
||||
|
||||
// Verifica Display
|
||||
await expect(displayPage.locator('.punt.guest')).toHaveText('1');
|
||||
});
|
||||
|
||||
test('la sincronizzazione dovrebbe funzionare con punti alternati', async ({ context }) => {
|
||||
const displayPage = await context.newPage();
|
||||
const controllerPage = await context.newPage();
|
||||
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Reset
|
||||
await controllerPage.getByText(/Reset/i).first().click();
|
||||
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
|
||||
if (await btnConfirm.isVisible()) {
|
||||
await btnConfirm.click();
|
||||
}
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Home +1, Guest +1, Home +1
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await controllerPage.locator('.team-score.guest-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Controller: Home 2, Guest 1
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('2');
|
||||
await expect(controllerPage.locator('.team-score.guest-bg .team-pts')).toHaveText('1');
|
||||
|
||||
// Display: Home 2, Guest 1
|
||||
await expect(displayPage.locator('.punt.home')).toHaveText('2');
|
||||
await expect(displayPage.locator('.punt.guest')).toHaveText('1');
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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');
|
||||
});
|
||||
131
tests/e2e/full-match.spec.cjs
Normal file
@@ -0,0 +1,131 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// Helper: reset dal controller
|
||||
async function resetGame(controllerPage) {
|
||||
await controllerPage.getByText(/Reset/i).first().click();
|
||||
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
|
||||
if (await btnConfirm.isVisible()) {
|
||||
await btnConfirm.click();
|
||||
}
|
||||
await controllerPage.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Helper: incrementa punti per una squadra N volte
|
||||
async function addPoints(controllerPage, team, count) {
|
||||
const selector = team === 'home' ? '.team-score.home-bg' : '.team-score.guest-bg';
|
||||
for (let i = 0; i < count; i++) {
|
||||
await controllerPage.locator(selector).click();
|
||||
await controllerPage.waitForTimeout(30);
|
||||
}
|
||||
await controllerPage.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// Helper: assegna un set a una squadra (25 punti + click SET)
|
||||
async function winSet(controllerPage, team) {
|
||||
await addPoints(controllerPage, team, 25);
|
||||
// Clicca bottone SET
|
||||
const setSelector = team === 'home' ? '.btn-set.home-bg' : '.btn-set.guest-bg';
|
||||
await controllerPage.locator(setSelector).click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
// Reset punti per il prossimo set
|
||||
// (in questo gioco i punti non si resettano automaticamente, serve reset manuale
|
||||
// o il controller gestisce il prossimo set manualmente)
|
||||
}
|
||||
|
||||
test.describe('Full Match Simulation', () => {
|
||||
|
||||
test('Partita 2/3: Home vince 2 set a 0', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
await resetGame(controllerPage);
|
||||
|
||||
// Cambia modalità a 2/3
|
||||
await controllerPage.getByText('Config').click();
|
||||
await controllerPage.waitForSelector('.dialog-config');
|
||||
await controllerPage.locator('.btn-mode').getByText('2/3').click();
|
||||
await controllerPage.locator('.dialog-config .btn-confirm').click();
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// === SET 1: Home vince 25-0 ===
|
||||
await addPoints(controllerPage, 'home', 25);
|
||||
|
||||
// Verifica punteggio 25
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('25');
|
||||
|
||||
// Incrementa set Home
|
||||
await controllerPage.locator('.btn-set.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
|
||||
// Verifica set 1 per Home
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-set')).toContainText('SET 1');
|
||||
});
|
||||
|
||||
test('Set decisivo 2/3: vittoria a 15 punti', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
await resetGame(controllerPage);
|
||||
|
||||
// Cambia modalità a 2/3
|
||||
await controllerPage.getByText('Config').click();
|
||||
await controllerPage.waitForSelector('.dialog-config');
|
||||
await controllerPage.locator('.btn-mode').getByText('2/3').click();
|
||||
await controllerPage.locator('.dialog-config .btn-confirm').click();
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Imposta set 1-1 manualmente (simula set pareggiati)
|
||||
await controllerPage.locator('.btn-set.home-bg').click();
|
||||
await controllerPage.waitForTimeout(50);
|
||||
await controllerPage.locator('.btn-set.guest-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
|
||||
// Verifica set 1-1
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-set')).toContainText('SET 1');
|
||||
await expect(controllerPage.locator('.team-score.guest-bg .team-set')).toContainText('SET 1');
|
||||
|
||||
// === SET DECISIVO: Home porta a 15 ===
|
||||
await addPoints(controllerPage, 'home', 15);
|
||||
|
||||
// Verifica punteggio 15 (e il set è decisivo: dopo 15 punti il gioco è vinto)
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('15');
|
||||
|
||||
// Verifica che non si possono aggiungere altri punti (vittoria)
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
// Dovrebbe restare 15 (checkVittoria blocca incPunt)
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('15');
|
||||
});
|
||||
|
||||
test('Set normale: punti oltre 25 fino ai vantaggi', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
await resetGame(controllerPage);
|
||||
|
||||
// Porta a 24-24
|
||||
await addPoints(controllerPage, 'home', 24);
|
||||
await addPoints(controllerPage, 'guest', 24);
|
||||
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('24');
|
||||
await expect(controllerPage.locator('.team-score.guest-bg .team-pts')).toHaveText('24');
|
||||
|
||||
// Home va a 25 (non è vittoria perché serve scarto di 2)
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('25');
|
||||
|
||||
// Si possono ancora aggiungere punti (non è vittoria a 25-24)
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('26');
|
||||
|
||||
// 26-24 è vittoria → non si possono più aggiungere punti
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('26');
|
||||
});
|
||||
});
|
||||
182
tests/e2e/game-operations.spec.cjs
Normal file
@@ -0,0 +1,182 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// Helper: reset dal controller
|
||||
async function resetGame(controllerPage) {
|
||||
await controllerPage.getByText(/Reset/i).first().click();
|
||||
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
|
||||
if (await btnConfirm.isVisible()) {
|
||||
await btnConfirm.click();
|
||||
}
|
||||
await controllerPage.waitForTimeout(300);
|
||||
}
|
||||
|
||||
test.describe('Game Operations', () => {
|
||||
|
||||
test('Undo: dovrebbe annullare l\'ultimo punto', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
await resetGame(controllerPage);
|
||||
|
||||
// Incrementa Home a 1
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('1');
|
||||
|
||||
// Annulla
|
||||
await controllerPage.getByText('ANNULLA PUNTO').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('0');
|
||||
});
|
||||
|
||||
test('Reset: dovrebbe azzerare tutto dopo conferma', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Imposta qualche punto
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(50);
|
||||
}
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('5');
|
||||
|
||||
// Reset
|
||||
await resetGame(controllerPage);
|
||||
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-pts')).toHaveText('0');
|
||||
await expect(controllerPage.locator('.team-score.guest-bg .team-pts')).toHaveText('0');
|
||||
});
|
||||
|
||||
test('Config: dovrebbe cambiare i nomi dei team', async ({ context }) => {
|
||||
const displayPage = await context.newPage();
|
||||
const controllerPage = await context.newPage();
|
||||
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Apri config
|
||||
await controllerPage.getByText('Config').click();
|
||||
await controllerPage.waitForSelector('.dialog-config');
|
||||
|
||||
// Modifica nomi
|
||||
const inputs = controllerPage.locator('.dialog-config .input-field');
|
||||
await inputs.first().fill('Padova');
|
||||
await inputs.nth(1).fill('Milano');
|
||||
|
||||
// Salva
|
||||
await controllerPage.locator('.dialog-config .btn-confirm').click();
|
||||
await controllerPage.waitForTimeout(300);
|
||||
|
||||
// Verifica sul Controller
|
||||
await expect(controllerPage.locator('.team-score.home-bg .team-name')).toHaveText('Padova');
|
||||
await expect(controllerPage.locator('.team-score.guest-bg .team-name')).toHaveText('Milano');
|
||||
|
||||
// Verifica sul Display
|
||||
await expect(displayPage.locator('.hea.home')).toContainText('Padova');
|
||||
await expect(displayPage.locator('.hea.guest')).toContainText('Milano');
|
||||
});
|
||||
|
||||
test('Toggle Formazione: dovrebbe mostrare la formazione sul display', async ({ context }) => {
|
||||
const displayPage = await context.newPage();
|
||||
const controllerPage = await context.newPage();
|
||||
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Inizialmente mostra punteggio, non formazione
|
||||
await expect(displayPage.locator('.punteggio-container')).toBeVisible();
|
||||
|
||||
// Click Formazioni
|
||||
await controllerPage.getByText('Formazioni').click();
|
||||
await controllerPage.waitForTimeout(300);
|
||||
|
||||
// Il display mostra le formazioni
|
||||
await expect(displayPage.locator('.form').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Toggle Striscia: dovrebbe nascondere/mostrare la striscia', async ({ context }) => {
|
||||
const displayPage = await context.newPage();
|
||||
const controllerPage = await context.newPage();
|
||||
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Inizialmente la striscia è visibile
|
||||
await expect(displayPage.locator('.striscia')).toBeVisible();
|
||||
|
||||
// Toggle off
|
||||
await controllerPage.getByText('Striscia').click();
|
||||
await controllerPage.waitForTimeout(300);
|
||||
await expect(displayPage.locator('.striscia')).not.toBeVisible();
|
||||
|
||||
// Toggle on
|
||||
await controllerPage.getByText('Striscia').click();
|
||||
await controllerPage.waitForTimeout(300);
|
||||
await expect(displayPage.locator('.striscia')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Cambi: dovrebbe effettuare una sostituzione giocatore', async ({ context }) => {
|
||||
const displayPage = await context.newPage();
|
||||
const controllerPage = await context.newPage();
|
||||
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
await resetGame(controllerPage);
|
||||
|
||||
// Attiva formazione sul display per verificare
|
||||
await controllerPage.getByText('Formazioni').click();
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Apri cambi → scegli Home
|
||||
await controllerPage.getByRole('button', { name: 'Cambi', exact: true }).click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await controllerPage.locator('.dialog .btn-set.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
|
||||
// Inserisci sostituzione: IN=10, OUT=1
|
||||
const inField = controllerPage.locator('.cambi-in-field').first();
|
||||
const outField = controllerPage.locator('.cambi-out-field').first();
|
||||
await inField.fill('10');
|
||||
await outField.fill('1');
|
||||
|
||||
// Conferma
|
||||
await controllerPage.locator('.dialog .btn-confirm').click();
|
||||
await controllerPage.waitForTimeout(300);
|
||||
|
||||
// Verifica formazione aggiornata sul display
|
||||
const formText = await displayPage.locator('.form.home').textContent();
|
||||
expect(formText).toContain('10');
|
||||
});
|
||||
|
||||
test('Cambi: dovrebbe mostrare errore per giocatore non in formazione', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
await resetGame(controllerPage);
|
||||
|
||||
// Apri cambi → scegli Home
|
||||
await controllerPage.getByRole('button', { name: 'Cambi', exact: true }).click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
await controllerPage.locator('.dialog .btn-set.home-bg').click();
|
||||
await controllerPage.waitForTimeout(100);
|
||||
|
||||
// Inserisci sostituzione invalida: OUT=99 (non in formazione)
|
||||
await controllerPage.locator('.cambi-in-field').first().fill('10');
|
||||
await controllerPage.locator('.cambi-out-field').first().fill('99');
|
||||
|
||||
// Conferma
|
||||
await controllerPage.locator('.dialog .btn-confirm').click();
|
||||
await controllerPage.waitForTimeout(200);
|
||||
|
||||
// Dovrebbe mostrare errore
|
||||
await expect(controllerPage.locator('.cambi-error')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Game Simulation', () => {
|
||||
test('Simulazione Partita: Controller aggiunge punti finché non cambia il set', async ({ context }) => {
|
||||
86
tests/e2e/visual-regression.spec.cjs
Normal file
@@ -0,0 +1,86 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// Helper: reset dal controller
|
||||
async function resetGame(controllerPage) {
|
||||
await controllerPage.getByText(/Reset/i).first().click();
|
||||
const btnConfirm = controllerPage.locator('.dialog .btn-confirm');
|
||||
if (await btnConfirm.isVisible()) {
|
||||
await btnConfirm.click();
|
||||
}
|
||||
await controllerPage.waitForTimeout(300);
|
||||
}
|
||||
|
||||
test.describe('Visual Regression', () => {
|
||||
|
||||
test('Display: screenshot a 0-0', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
const displayPage = await context.newPage();
|
||||
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Reset per stato pulito
|
||||
await resetGame(controllerPage);
|
||||
|
||||
// Attende che il display riceva lo stato
|
||||
await displayPage.waitForTimeout(500);
|
||||
|
||||
await expect(displayPage).toHaveScreenshot('display-0-0.png', {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
});
|
||||
});
|
||||
|
||||
test('Display: screenshot durante partita (15-12)', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
const displayPage = await context.newPage();
|
||||
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await displayPage.goto('http://localhost:3000');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
await resetGame(controllerPage);
|
||||
|
||||
// Porta il punteggio a 15-12
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await controllerPage.locator('.team-score.home-bg').click();
|
||||
await controllerPage.waitForTimeout(20);
|
||||
}
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await controllerPage.locator('.team-score.guest-bg').click();
|
||||
await controllerPage.waitForTimeout(20);
|
||||
}
|
||||
await displayPage.waitForTimeout(500);
|
||||
|
||||
await expect(displayPage).toHaveScreenshot('display-15-12.png', {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
});
|
||||
});
|
||||
|
||||
test('Controller: screenshot stato iniziale', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
await resetGame(controllerPage);
|
||||
|
||||
await expect(controllerPage).toHaveScreenshot('controller-initial.png', {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
});
|
||||
});
|
||||
|
||||
test('Controller: screenshot con modal config aperta', async ({ context }) => {
|
||||
const controllerPage = await context.newPage();
|
||||
await controllerPage.goto('http://localhost:3001');
|
||||
await controllerPage.waitForSelector('.conn-bar.connected');
|
||||
|
||||
// Apri config
|
||||
await controllerPage.getByText('Config').click();
|
||||
await controllerPage.waitForSelector('.dialog-config');
|
||||
await controllerPage.waitForTimeout(300);
|
||||
|
||||
await expect(controllerPage).toHaveScreenshot('controller-config-modal.png', {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -16,68 +16,388 @@ class MockWebSocketServer extends EventEmitter {
|
||||
clients = new Set()
|
||||
}
|
||||
|
||||
// Helper: connette e registra un client
|
||||
function connectAndRegister(wss, role) {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
wss.clients.add(ws)
|
||||
ws.emit('message', JSON.stringify({ type: 'register', role }))
|
||||
ws.send.mockClear()
|
||||
return ws
|
||||
}
|
||||
|
||||
// Helper: ultimo messaggio inviato a un ws
|
||||
function lastSent(ws) {
|
||||
const calls = ws.send.mock.calls
|
||||
return JSON.parse(calls[calls.length - 1][0])
|
||||
}
|
||||
|
||||
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' }))
|
||||
// =============================================
|
||||
// REGISTRAZIONE
|
||||
// =============================================
|
||||
describe('Registrazione', () => {
|
||||
it('dovrebbe registrare un client come "display" e inviare lo stato', () => {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
wss.clients.add(ws)
|
||||
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()
|
||||
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 registrare un client come "controller"', () => {
|
||||
connectAndRegister(wss, 'controller')
|
||||
expect(handler.getClients().size).toBe(1)
|
||||
})
|
||||
|
||||
it('dovrebbe rifiutare ruolo non valido', () => {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
wss.clients.add(ws)
|
||||
ws.emit('message', JSON.stringify({ type: 'register', role: 'hacker' }))
|
||||
|
||||
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
|
||||
expect(sentMsg.type).toBe('error')
|
||||
expect(sentMsg.message).toContain('Invalid role')
|
||||
})
|
||||
|
||||
it('dovrebbe usare "display" come ruolo default se mancante', () => {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
wss.clients.add(ws)
|
||||
ws.emit('message', JSON.stringify({ type: 'register' }))
|
||||
|
||||
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
|
||||
expect(sentMsg.type).toBe('state')
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
// =============================================
|
||||
// AZIONI
|
||||
// =============================================
|
||||
describe('Azioni', () => {
|
||||
it('dovrebbe permettere al controller di cambiare il punteggio', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
// 2. Invia Azione
|
||||
ws.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
controller.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)
|
||||
expect(controller.send).toHaveBeenCalled()
|
||||
const sentMsg = lastSent(controller)
|
||||
expect(sentMsg.type).toBe('state')
|
||||
expect(sentMsg.state.sp.punt.home).toBe(1)
|
||||
})
|
||||
|
||||
it('dovrebbe impedire al display di inviare azioni', () => {
|
||||
const display = connectAndRegister(wss, 'display')
|
||||
|
||||
display.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
|
||||
const sentMsg = lastSent(display)
|
||||
expect(sentMsg.type).toBe('error')
|
||||
expect(sentMsg.message).toContain('Only controllers')
|
||||
})
|
||||
|
||||
it('dovrebbe impedire azioni da client non registrati', () => {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
wss.clients.add(ws)
|
||||
|
||||
ws.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
|
||||
const sentMsg = JSON.parse(ws.send.mock.calls[0][0])
|
||||
expect(sentMsg.type).toBe('error')
|
||||
expect(sentMsg.message).toContain('Only controllers')
|
||||
})
|
||||
|
||||
it('dovrebbe rifiutare azione con formato invalido (missing action)', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action'
|
||||
}))
|
||||
|
||||
const sentMsg = lastSent(controller)
|
||||
expect(sentMsg.type).toBe('error')
|
||||
expect(sentMsg.message).toContain('Invalid action format')
|
||||
})
|
||||
|
||||
it('dovrebbe rifiutare azione con formato invalido (missing action.type)', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { team: 'home' }
|
||||
}))
|
||||
|
||||
const sentMsg = lastSent(controller)
|
||||
expect(sentMsg.type).toBe('error')
|
||||
expect(sentMsg.message).toContain('Invalid action format')
|
||||
})
|
||||
})
|
||||
|
||||
it('dovrebbe impedire al display di inviare azioni', () => {
|
||||
// 1. Registra Display
|
||||
ws.emit('message', JSON.stringify({ type: 'register', role: 'display' }))
|
||||
ws.send.mockClear()
|
||||
// =============================================
|
||||
// BROADCAST MULTI-CLIENT
|
||||
// =============================================
|
||||
describe('Broadcast', () => {
|
||||
it('dovrebbe inviare lo stato a tutti i client dopo un\'azione', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
const display1 = connectAndRegister(wss, 'display')
|
||||
const display2 = connectAndRegister(wss, 'display')
|
||||
|
||||
// 2. Tenta Azione
|
||||
ws.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
controller.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')
|
||||
// Tutti i client nel set dovrebbero aver ricevuto lo stato
|
||||
expect(controller.send).toHaveBeenCalled()
|
||||
expect(display1.send).toHaveBeenCalled()
|
||||
expect(display2.send).toHaveBeenCalled()
|
||||
|
||||
const msg1 = lastSent(display1)
|
||||
const msg2 = lastSent(display2)
|
||||
expect(msg1.type).toBe('state')
|
||||
expect(msg1.state.sp.punt.home).toBe(1)
|
||||
expect(msg2.state.sp.punt.home).toBe(1)
|
||||
})
|
||||
|
||||
it('non dovrebbe inviare a client con readyState != OPEN', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
const closedClient = connectAndRegister(wss, 'display')
|
||||
closedClient.readyState = 3 // CLOSED
|
||||
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
|
||||
// closedClient non dovrebbe aver ricevuto il broadcast
|
||||
expect(closedClient.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// SPEAK
|
||||
// =============================================
|
||||
describe('Speak', () => {
|
||||
it('dovrebbe inoltrare il messaggio speak solo ai display', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
const display = connectAndRegister(wss, 'display')
|
||||
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'speak',
|
||||
text: 'quindici a dieci'
|
||||
}))
|
||||
|
||||
// Il display riceve il messaggio speak
|
||||
expect(display.send).toHaveBeenCalled()
|
||||
const msg = lastSent(display)
|
||||
expect(msg.type).toBe('speak')
|
||||
expect(msg.text).toBe('quindici a dieci')
|
||||
})
|
||||
|
||||
it('non dovrebbe permettere al display di inviare speak', () => {
|
||||
const display = connectAndRegister(wss, 'display')
|
||||
|
||||
display.emit('message', JSON.stringify({
|
||||
type: 'speak',
|
||||
text: 'test'
|
||||
}))
|
||||
|
||||
const msg = lastSent(display)
|
||||
expect(msg.type).toBe('error')
|
||||
expect(msg.message).toContain('Only controllers')
|
||||
})
|
||||
|
||||
it('dovrebbe rifiutare speak con testo vuoto', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'speak',
|
||||
text: ' '
|
||||
}))
|
||||
|
||||
const msg = lastSent(controller)
|
||||
expect(msg.type).toBe('error')
|
||||
expect(msg.message).toContain('Invalid speak payload')
|
||||
})
|
||||
|
||||
it('dovrebbe rifiutare speak senza testo', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'speak'
|
||||
}))
|
||||
|
||||
const msg = lastSent(controller)
|
||||
expect(msg.type).toBe('error')
|
||||
})
|
||||
|
||||
it('dovrebbe fare trim del testo speak', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
const display = connectAndRegister(wss, 'display')
|
||||
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'speak',
|
||||
text: ' dieci a otto '
|
||||
}))
|
||||
|
||||
const msg = lastSent(display)
|
||||
expect(msg.text).toBe('dieci a otto')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// MESSAGGI MALFORMATI
|
||||
// =============================================
|
||||
describe('Messaggi malformati', () => {
|
||||
it('dovrebbe gestire JSON non valido senza crash', () => {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
wss.clients.add(ws)
|
||||
|
||||
expect(() => {
|
||||
ws.emit('message', 'questo non è JSON {{{')
|
||||
}).not.toThrow()
|
||||
|
||||
const msg = lastSent(ws)
|
||||
expect(msg.type).toBe('error')
|
||||
expect(msg.message).toContain('Invalid message format')
|
||||
})
|
||||
|
||||
it('dovrebbe gestire Buffer come input', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
const buf = Buffer.from(JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
|
||||
controller.emit('message', buf)
|
||||
|
||||
const msg = lastSent(controller)
|
||||
expect(msg.type).toBe('state')
|
||||
expect(msg.state.sp.punt.home).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// DISCONNESSIONE
|
||||
// =============================================
|
||||
describe('Disconnessione', () => {
|
||||
it('dovrebbe rimuovere il client dalla mappa alla disconnessione', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
expect(handler.getClients().size).toBe(1)
|
||||
|
||||
controller.emit('close')
|
||||
|
||||
expect(handler.getClients().size).toBe(0)
|
||||
})
|
||||
|
||||
it('i client rimanenti non dovrebbero essere affetti dalla disconnessione', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
const display = connectAndRegister(wss, 'display')
|
||||
expect(handler.getClients().size).toBe(2)
|
||||
|
||||
controller.emit('close')
|
||||
expect(handler.getClients().size).toBe(1)
|
||||
|
||||
expect(handler.getClients().has(display)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// ERRORI WEBSOCKET
|
||||
// =============================================
|
||||
describe('Errori WebSocket', () => {
|
||||
it('dovrebbe terminare la connessione per errore UTF8 invalido', () => {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
|
||||
const err = new Error('Invalid UTF8')
|
||||
err.code = 'WS_ERR_INVALID_UTF8'
|
||||
ws.emit('error', err)
|
||||
|
||||
expect(ws.terminate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dovrebbe terminare la connessione per close code invalido', () => {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
|
||||
const err = new Error('Invalid close code')
|
||||
err.code = 'WS_ERR_INVALID_CLOSE_CODE'
|
||||
ws.emit('error', err)
|
||||
|
||||
expect(ws.terminate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('non dovrebbe terminare per altri errori', () => {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
|
||||
const err = new Error('Generic error')
|
||||
ws.emit('error', err)
|
||||
|
||||
expect(ws.terminate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// API PUBBLICA
|
||||
// =============================================
|
||||
describe('API pubblica', () => {
|
||||
it('getState dovrebbe restituire lo stato corrente', () => {
|
||||
const state = handler.getState()
|
||||
expect(state.sp.punt.home).toBe(0)
|
||||
expect(state.sp.punt.guest).toBe(0)
|
||||
})
|
||||
|
||||
it('setState dovrebbe sovrascrivere lo stato', () => {
|
||||
const newState = handler.getState()
|
||||
newState.sp.punt.home = 99
|
||||
handler.setState(newState)
|
||||
expect(handler.getState().sp.punt.home).toBe(99)
|
||||
})
|
||||
|
||||
it('broadcastState dovrebbe inviare a tutti i client', () => {
|
||||
const display = connectAndRegister(wss, 'display')
|
||||
handler.broadcastState()
|
||||
expect(display.send).toHaveBeenCalled()
|
||||
const msg = lastSent(display)
|
||||
expect(msg.type).toBe('state')
|
||||
})
|
||||
|
||||
it('getClients dovrebbe restituire la mappa dei client', () => {
|
||||
expect(handler.getClients()).toBeInstanceOf(Map)
|
||||
expect(handler.getClients().size).toBe(0)
|
||||
connectAndRegister(wss, 'display')
|
||||
expect(handler.getClients().size).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
118
tests/stress/websocket-load.test.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setupWebSocketHandler } from '../../src/websocket-handler.js'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
class MockWebSocket extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.readyState = 1
|
||||
}
|
||||
send = vi.fn()
|
||||
terminate = vi.fn()
|
||||
}
|
||||
|
||||
class MockWebSocketServer extends EventEmitter {
|
||||
clients = new Set()
|
||||
}
|
||||
|
||||
function connectAndRegister(wss, role) {
|
||||
const ws = new MockWebSocket()
|
||||
wss.emit('connection', ws)
|
||||
wss.clients.add(ws)
|
||||
ws.emit('message', JSON.stringify({ type: 'register', role }))
|
||||
ws.send.mockClear()
|
||||
return ws
|
||||
}
|
||||
|
||||
describe('Stress Test WebSocket', () => {
|
||||
let wss
|
||||
let handler
|
||||
|
||||
beforeEach(() => {
|
||||
wss = new MockWebSocketServer()
|
||||
handler = setupWebSocketHandler(wss)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('dovrebbe gestire 50 client display connessi simultaneamente', () => {
|
||||
const displays = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
displays.push(connectAndRegister(wss, 'display'))
|
||||
}
|
||||
|
||||
expect(handler.getClients().size).toBe(50)
|
||||
|
||||
// Un controller invia un'azione
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
|
||||
// Tutti i display devono aver ricevuto il broadcast
|
||||
for (const display of displays) {
|
||||
expect(display.send).toHaveBeenCalled()
|
||||
const msg = JSON.parse(display.send.mock.calls[display.send.mock.calls.length - 1][0])
|
||||
expect(msg.type).toBe('state')
|
||||
expect(msg.state.sp.punt.home).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('dovrebbe gestire 100 azioni rapide in sequenza con stato finale corretto', () => {
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
// 60 punti home, 40 punti guest
|
||||
for (let i = 0; i < 60; i++) {
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
}
|
||||
for (let i = 0; i < 40; i++) {
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'guest' }
|
||||
}))
|
||||
}
|
||||
|
||||
// Lo stato finale dipende da checkVittoria che blocca a 25+2
|
||||
// Home arriva a 25-0 → vittoria → blocca. Quindi punti home = 25
|
||||
const state = handler.getState()
|
||||
expect(state.sp.punt.home).toBe(25)
|
||||
// Guest: non può segnare dopo vittoria? No, checkVittoria blocca solo il team che ha vinto?
|
||||
// Controlliamo: checkVittoria controlla ENTRAMBI i team.
|
||||
// A 25-0 → vittoria=true → incPunt per guest è anche bloccato
|
||||
expect(state.sp.punt.guest).toBe(0)
|
||||
})
|
||||
|
||||
it('dovrebbe garantire che tutti i display ricevano ogni update sotto carico', () => {
|
||||
const displays = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
displays.push(connectAndRegister(wss, 'display'))
|
||||
}
|
||||
|
||||
const controller = connectAndRegister(wss, 'controller')
|
||||
|
||||
// 5 azioni rapide
|
||||
for (let i = 0; i < 5; i++) {
|
||||
controller.emit('message', JSON.stringify({
|
||||
type: 'action',
|
||||
action: { type: 'incPunt', team: 'home' }
|
||||
}))
|
||||
}
|
||||
|
||||
// Ogni display deve aver ricevuto esattamente 5 broadcast
|
||||
for (const display of displays) {
|
||||
expect(display.send).toHaveBeenCalledTimes(5)
|
||||
}
|
||||
|
||||
// Verifica stato finale su tutti i display
|
||||
for (const display of displays) {
|
||||
const lastMsg = JSON.parse(display.send.mock.calls[4][0])
|
||||
expect(lastMsg.state.sp.punt.home).toBe(5)
|
||||
}
|
||||
})
|
||||
})
|
||||
303
tests/unit/cli.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,10 @@ describe('Game Logic (gameState.js)', () => {
|
||||
state = createInitialState()
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
// =============================================
|
||||
// STATO INIZIALE
|
||||
// =============================================
|
||||
describe('Stato iniziale', () => {
|
||||
it('dovrebbe iniziare con 0-0', () => {
|
||||
expect(state.sp.punt.home).toBe(0)
|
||||
expect(state.sp.punt.guest).toBe(0)
|
||||
@@ -18,40 +21,451 @@ describe('Game Logic (gameState.js)', () => {
|
||||
expect(state.sp.set.home).toBe(0)
|
||||
expect(state.sp.set.guest).toBe(0)
|
||||
})
|
||||
|
||||
it('dovrebbe avere servizio Home', () => {
|
||||
expect(state.sp.servHome).toBe(true)
|
||||
})
|
||||
|
||||
it('dovrebbe avere formazione di default [1-6]', () => {
|
||||
expect(state.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
expect(state.sp.form.guest).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('dovrebbe avere la striscia iniziale a [0]', () => {
|
||||
expect(state.sp.striscia.home).toEqual([0])
|
||||
expect(state.sp.striscia.guest).toEqual([0])
|
||||
})
|
||||
|
||||
it('dovrebbe avere storico servizio vuoto', () => {
|
||||
expect(state.sp.storicoServizio).toEqual([])
|
||||
})
|
||||
|
||||
it('dovrebbe avere modalità 3/5 di default', () => {
|
||||
expect(state.modalitaPartita).toBe("3/5")
|
||||
})
|
||||
|
||||
it('dovrebbe avere visuForm false e visuStriscia true', () => {
|
||||
expect(state.visuForm).toBe(false)
|
||||
expect(state.visuStriscia).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Punteggio', () => {
|
||||
it('dovrebbe incrementare i punti (Home)', () => {
|
||||
// =============================================
|
||||
// IMMUTABILITÀ
|
||||
// =============================================
|
||||
describe('Immutabilità', () => {
|
||||
it('applyAction non dovrebbe mutare lo stato originale', () => {
|
||||
const original = JSON.stringify(state)
|
||||
applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
expect(JSON.stringify(state)).toBe(original)
|
||||
})
|
||||
|
||||
it('dovrebbe restituire un nuovo oggetto', () => {
|
||||
const newState = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
expect(newState).not.toBe(state)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// INCREMENTO PUNTI (incPunt)
|
||||
// =============================================
|
||||
describe('incPunt', () => {
|
||||
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 incrementare i punti Guest', () => {
|
||||
const newState = applyAction(state, { type: 'incPunt', team: 'guest' })
|
||||
expect(newState.sp.punt.guest).toBe(1)
|
||||
expect(newState.sp.punt.home).toBe(0)
|
||||
})
|
||||
|
||||
it('dovrebbe gestire la rotazione formazione al cambio palla', () => {
|
||||
state.sp.servHome = true // Batte Home
|
||||
it('dovrebbe gestire il cambio palla (Guest segna, batteva Home)', () => {
|
||||
state.sp.servHome = true
|
||||
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
|
||||
expect(s1.sp.servHome).toBe(false)
|
||||
})
|
||||
|
||||
it('dovrebbe gestire il cambio palla (Home segna, batteva Guest)', () => {
|
||||
state.sp.servHome = false
|
||||
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
expect(s1.sp.servHome).toBe(true)
|
||||
})
|
||||
|
||||
it('non dovrebbe cambiare palla se segna chi batte', () => {
|
||||
state.sp.servHome = true
|
||||
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
expect(s1.sp.servHome).toBe(true)
|
||||
})
|
||||
|
||||
it('dovrebbe ruotare la formazione al cambio palla', () => {
|
||||
state.sp.servHome = true
|
||||
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"])
|
||||
})
|
||||
|
||||
it('non dovrebbe ruotare la formazione se non c\'è cambio palla', () => {
|
||||
state.sp.servHome = true
|
||||
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
|
||||
const newState = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
expect(newState.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('dovrebbe aggiornare la striscia per punto Home', () => {
|
||||
const s = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
expect(s.sp.striscia.home).toEqual([0, 1])
|
||||
expect(s.sp.striscia.guest).toEqual([0, " "])
|
||||
})
|
||||
|
||||
it('dovrebbe aggiornare la striscia per punto Guest', () => {
|
||||
const s = applyAction(state, { type: 'incPunt', team: 'guest' })
|
||||
expect(s.sp.striscia.guest).toEqual([0, 1])
|
||||
expect(s.sp.striscia.home).toEqual([0, " "])
|
||||
})
|
||||
|
||||
it('dovrebbe registrare lo storico servizio', () => {
|
||||
const s = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
expect(s.sp.storicoServizio).toHaveLength(1)
|
||||
expect(s.sp.storicoServizio[0]).toHaveProperty('servHome')
|
||||
expect(s.sp.storicoServizio[0]).toHaveProperty('cambioPalla')
|
||||
})
|
||||
|
||||
it('non dovrebbe incrementare i punti dopo vittoria', () => {
|
||||
state.sp.punt.home = 25
|
||||
state.sp.punt.guest = 23
|
||||
const s = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
expect(s.sp.punt.home).toBe(25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Vittoria Set', () => {
|
||||
// =============================================
|
||||
// DECREMENTO PUNTI (decPunt)
|
||||
// =============================================
|
||||
describe('decPunt', () => {
|
||||
it('dovrebbe annullare l\'ultimo punto Home', () => {
|
||||
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
const s2 = applyAction(s1, { type: 'decPunt' })
|
||||
expect(s2.sp.punt.home).toBe(0)
|
||||
expect(s2.sp.punt.guest).toBe(0)
|
||||
})
|
||||
|
||||
it('dovrebbe annullare l\'ultimo punto Guest', () => {
|
||||
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
|
||||
const s2 = applyAction(s1, { type: 'decPunt' })
|
||||
expect(s2.sp.punt.home).toBe(0)
|
||||
expect(s2.sp.punt.guest).toBe(0)
|
||||
})
|
||||
|
||||
it('non dovrebbe fare nulla sullo stato iniziale', () => {
|
||||
const s = applyAction(state, { type: 'decPunt' })
|
||||
expect(s.sp.punt.home).toBe(0)
|
||||
expect(s.sp.punt.guest).toBe(0)
|
||||
})
|
||||
|
||||
it('dovrebbe ripristinare il servizio dopo undo con cambio palla', () => {
|
||||
state.sp.servHome = true
|
||||
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
|
||||
expect(s1.sp.servHome).toBe(false)
|
||||
const s2 = applyAction(s1, { type: 'decPunt' })
|
||||
expect(s2.sp.servHome).toBe(true)
|
||||
})
|
||||
|
||||
it('dovrebbe invertire la rotazione dopo undo con cambio palla', () => {
|
||||
state.sp.servHome = true
|
||||
state.sp.form.guest = ["1", "2", "3", "4", "5", "6"]
|
||||
const s1 = applyAction(state, { type: 'incPunt', team: 'guest' })
|
||||
expect(s1.sp.form.guest).toEqual(["2", "3", "4", "5", "6", "1"])
|
||||
const s2 = applyAction(s1, { type: 'decPunt' })
|
||||
expect(s2.sp.form.guest).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('dovrebbe ripristinare la striscia', () => {
|
||||
const s1 = applyAction(state, { type: 'incPunt', team: 'home' })
|
||||
const s2 = applyAction(s1, { type: 'decPunt' })
|
||||
expect(s2.sp.striscia.home).toEqual([0])
|
||||
})
|
||||
|
||||
it('dovrebbe gestire undo multipli in sequenza', () => {
|
||||
let s = state
|
||||
s = applyAction(s, { type: 'incPunt', team: 'home' })
|
||||
s = applyAction(s, { type: 'incPunt', team: 'guest' })
|
||||
s = applyAction(s, { type: 'incPunt', team: 'home' })
|
||||
expect(s.sp.punt.home).toBe(2)
|
||||
expect(s.sp.punt.guest).toBe(1)
|
||||
s = applyAction(s, { type: 'decPunt' })
|
||||
expect(s.sp.punt.home).toBe(1)
|
||||
s = applyAction(s, { type: 'decPunt' })
|
||||
expect(s.sp.punt.guest).toBe(0)
|
||||
s = applyAction(s, { type: 'decPunt' })
|
||||
expect(s.sp.punt.home).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// INCREMENTO SET (incSet)
|
||||
// =============================================
|
||||
describe('incSet', () => {
|
||||
it('dovrebbe incrementare il set Home', () => {
|
||||
const s = applyAction(state, { type: 'incSet', team: 'home' })
|
||||
expect(s.sp.set.home).toBe(1)
|
||||
})
|
||||
|
||||
it('dovrebbe incrementare il set Guest', () => {
|
||||
const s = applyAction(state, { type: 'incSet', team: 'guest' })
|
||||
expect(s.sp.set.guest).toBe(1)
|
||||
})
|
||||
|
||||
it('dovrebbe fare wrap da 2 a 0', () => {
|
||||
state.sp.set.home = 2
|
||||
const s = applyAction(state, { type: 'incSet', team: 'home' })
|
||||
expect(s.sp.set.home).toBe(0)
|
||||
})
|
||||
|
||||
it('dovrebbe incrementare da 1 a 2', () => {
|
||||
state.sp.set.home = 1
|
||||
const s = applyAction(state, { type: 'incSet', team: 'home' })
|
||||
expect(s.sp.set.home).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// CAMBIO PALLA (cambiaPalla)
|
||||
// =============================================
|
||||
describe('cambiaPalla', () => {
|
||||
it('dovrebbe invertire il servizio a 0-0', () => {
|
||||
expect(state.sp.servHome).toBe(true)
|
||||
const s = applyAction(state, { type: 'cambiaPalla' })
|
||||
expect(s.sp.servHome).toBe(false)
|
||||
})
|
||||
|
||||
it('dovrebbe tornare a Home con doppio toggle', () => {
|
||||
let s = applyAction(state, { type: 'cambiaPalla' })
|
||||
s = applyAction(s, { type: 'cambiaPalla' })
|
||||
expect(s.sp.servHome).toBe(true)
|
||||
})
|
||||
|
||||
it('non dovrebbe cambiare palla se il punteggio non è 0-0', () => {
|
||||
state.sp.punt.home = 1
|
||||
const s = applyAction(state, { type: 'cambiaPalla' })
|
||||
expect(s.sp.servHome).toBe(true)
|
||||
})
|
||||
|
||||
it('non dovrebbe cambiare palla se Guest ha punti', () => {
|
||||
state.sp.punt.guest = 3
|
||||
const s = applyAction(state, { type: 'cambiaPalla' })
|
||||
expect(s.sp.servHome).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// TOGGLE (toggleFormazione, toggleStriscia, toggleOrder)
|
||||
// =============================================
|
||||
describe('Toggle', () => {
|
||||
it('toggleFormazione: false → true', () => {
|
||||
expect(state.visuForm).toBe(false)
|
||||
const s = applyAction(state, { type: 'toggleFormazione' })
|
||||
expect(s.visuForm).toBe(true)
|
||||
})
|
||||
|
||||
it('toggleFormazione: true → false', () => {
|
||||
state.visuForm = true
|
||||
const s = applyAction(state, { type: 'toggleFormazione' })
|
||||
expect(s.visuForm).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleStriscia: true → false', () => {
|
||||
expect(state.visuStriscia).toBe(true)
|
||||
const s = applyAction(state, { type: 'toggleStriscia' })
|
||||
expect(s.visuStriscia).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleOrder: true → false', () => {
|
||||
expect(state.order).toBe(true)
|
||||
const s = applyAction(state, { type: 'toggleOrder' })
|
||||
expect(s.order).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// NOMI (setNomi)
|
||||
// =============================================
|
||||
describe('setNomi', () => {
|
||||
it('dovrebbe aggiornare entrambi i nomi', () => {
|
||||
const s = applyAction(state, { type: 'setNomi', home: 'Volley A', guest: 'Volley B' })
|
||||
expect(s.sp.nomi.home).toBe('Volley A')
|
||||
expect(s.sp.nomi.guest).toBe('Volley B')
|
||||
})
|
||||
|
||||
it('dovrebbe aggiornare solo il nome Home se guest è undefined', () => {
|
||||
const s = applyAction(state, { type: 'setNomi', home: 'Volley A' })
|
||||
expect(s.sp.nomi.home).toBe('Volley A')
|
||||
expect(s.sp.nomi.guest).toBe('Guest')
|
||||
})
|
||||
|
||||
it('dovrebbe aggiornare solo il nome Guest se home è undefined', () => {
|
||||
const s = applyAction(state, { type: 'setNomi', guest: 'Volley B' })
|
||||
expect(s.sp.nomi.home).toBe('Antoniana')
|
||||
expect(s.sp.nomi.guest).toBe('Volley B')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// MODALITÀ (setModalita)
|
||||
// =============================================
|
||||
describe('setModalita', () => {
|
||||
it('dovrebbe cambiare in 2/3', () => {
|
||||
const s = applyAction(state, { type: 'setModalita', modalita: '2/3' })
|
||||
expect(s.modalitaPartita).toBe('2/3')
|
||||
})
|
||||
|
||||
it('dovrebbe cambiare in 3/5', () => {
|
||||
state.modalitaPartita = '2/3'
|
||||
const s = applyAction(state, { type: 'setModalita', modalita: '3/5' })
|
||||
expect(s.modalitaPartita).toBe('3/5')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// FORMAZIONE (setFormazione)
|
||||
// =============================================
|
||||
describe('setFormazione', () => {
|
||||
it('dovrebbe sostituire la formazione Home', () => {
|
||||
const nuova = ["10", "11", "12", "13", "14", "15"]
|
||||
const s = applyAction(state, { type: 'setFormazione', team: 'home', form: nuova })
|
||||
expect(s.sp.form.home).toEqual(nuova)
|
||||
})
|
||||
|
||||
it('dovrebbe sostituire la formazione Guest', () => {
|
||||
const nuova = ["7", "8", "9", "10", "11", "12"]
|
||||
const s = applyAction(state, { type: 'setFormazione', team: 'guest', form: nuova })
|
||||
expect(s.sp.form.guest).toEqual(nuova)
|
||||
})
|
||||
|
||||
it('non dovrebbe modificare se manca team', () => {
|
||||
const s = applyAction(state, { type: 'setFormazione', form: ["7", "8", "9", "10", "11", "12"] })
|
||||
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('non dovrebbe modificare se manca form', () => {
|
||||
const s = applyAction(state, { type: 'setFormazione', team: 'home' })
|
||||
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// CAMBI GIOCATORI (confermaCambi)
|
||||
// =============================================
|
||||
describe('confermaCambi', () => {
|
||||
it('dovrebbe effettuare una sostituzione valida', () => {
|
||||
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [{ in: "10", out: "3" }]
|
||||
})
|
||||
expect(s.sp.form.home).toContain("10")
|
||||
expect(s.sp.form.home).not.toContain("3")
|
||||
})
|
||||
|
||||
it('dovrebbe gestire doppia sostituzione', () => {
|
||||
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [
|
||||
{ in: "10", out: "1" },
|
||||
{ in: "11", out: "2" }
|
||||
]
|
||||
})
|
||||
expect(s.sp.form.home).toContain("10")
|
||||
expect(s.sp.form.home).toContain("11")
|
||||
expect(s.sp.form.home).not.toContain("1")
|
||||
expect(s.sp.form.home).not.toContain("2")
|
||||
})
|
||||
|
||||
it('non dovrebbe accettare input non numerico', () => {
|
||||
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [{ in: "abc", out: "1" }]
|
||||
})
|
||||
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('non dovrebbe accettare in == out', () => {
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [{ in: "1", out: "1" }]
|
||||
})
|
||||
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('non dovrebbe accettare giocatore IN già in formazione', () => {
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [{ in: "2", out: "1" }]
|
||||
})
|
||||
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('non dovrebbe accettare giocatore OUT non in formazione', () => {
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [{ in: "10", out: "99" }]
|
||||
})
|
||||
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('dovrebbe saltare cambi con campo vuoto', () => {
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [
|
||||
{ in: "", out: "" },
|
||||
{ in: "10", out: "1" }
|
||||
]
|
||||
})
|
||||
expect(s.sp.form.home).toContain("10")
|
||||
})
|
||||
|
||||
it('dovrebbe mantenere la posizione del giocatore sostituito', () => {
|
||||
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [{ in: "10", out: "3" }]
|
||||
})
|
||||
expect(s.sp.form.home[2]).toBe("10")
|
||||
})
|
||||
|
||||
it('dovrebbe gestire cambi sequenziali che dipendono l\'uno dall\'altro', () => {
|
||||
// Sostituisci 1→10, poi 10→20 (il secondo dipende dal risultato del primo)
|
||||
state.sp.form.home = ["1", "2", "3", "4", "5", "6"]
|
||||
const s = applyAction(state, {
|
||||
type: 'confermaCambi',
|
||||
team: 'home',
|
||||
cambi: [
|
||||
{ in: "10", out: "1" },
|
||||
{ in: "20", out: "10" }
|
||||
]
|
||||
})
|
||||
expect(s.sp.form.home).toContain("20")
|
||||
expect(s.sp.form.home).not.toContain("1")
|
||||
expect(s.sp.form.home).not.toContain("10")
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// VITTORIA SET (checkVittoria)
|
||||
// =============================================
|
||||
describe('checkVittoria', () => {
|
||||
it('non dovrebbe dare vittoria a 24-24', () => {
|
||||
state.sp.punt.home = 24
|
||||
state.sp.punt.guest = 24
|
||||
@@ -64,28 +478,182 @@ describe('Game Logic (gameState.js)', () => {
|
||||
expect(checkVittoria(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('dovrebbe richiedere 2 punti di scarto (26-24)', () => {
|
||||
it('non dovrebbe dare vittoria a 25-24 (serve 2 punti di scarto)', () => {
|
||||
state.sp.punt.home = 25
|
||||
state.sp.punt.guest = 24
|
||||
expect(checkVittoria(state)).toBe(false)
|
||||
})
|
||||
|
||||
it('dovrebbe dare vittoria a 26-24', () => {
|
||||
state.sp.punt.home = 26
|
||||
state.sp.punt.guest = 24
|
||||
expect(checkVittoria(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('dovrebbe dare vittoria Guest a 25-20', () => {
|
||||
state.sp.punt.home = 20
|
||||
state.sp.punt.guest = 25
|
||||
expect(checkVittoria(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('dovrebbe dare vittoria ai vantaggi (30-28)', () => {
|
||||
state.sp.punt.home = 30
|
||||
state.sp.punt.guest = 28
|
||||
expect(checkVittoria(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('non dovrebbe dare vittoria ai vantaggi senza scarto (28-27)', () => {
|
||||
state.sp.punt.home = 28
|
||||
state.sp.punt.guest = 27
|
||||
expect(checkVittoria(state)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reset', () => {
|
||||
it('dovrebbe resettare tutto a zero', () => {
|
||||
state.sp.punt.home = 10
|
||||
// =============================================
|
||||
// SET DECISIVO (15 punti)
|
||||
// =============================================
|
||||
describe('Set decisivo', () => {
|
||||
it('modalità 3/5: set decisivo dopo 4 set totali → vittoria a 15', () => {
|
||||
state.modalitaPartita = "3/5"
|
||||
state.sp.set.home = 2
|
||||
state.sp.set.guest = 2
|
||||
state.sp.punt.home = 15
|
||||
state.sp.punt.guest = 10
|
||||
expect(checkVittoria(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('modalità 3/5: non vittoria a 14-10 nel set decisivo', () => {
|
||||
state.modalitaPartita = "3/5"
|
||||
state.sp.set.home = 2
|
||||
state.sp.set.guest = 2
|
||||
state.sp.punt.home = 14
|
||||
state.sp.punt.guest = 10
|
||||
expect(checkVittoria(state)).toBe(false)
|
||||
})
|
||||
|
||||
it('modalità 3/5: vittoria a 15-13 nel set decisivo', () => {
|
||||
state.modalitaPartita = "3/5"
|
||||
state.sp.set.home = 2
|
||||
state.sp.set.guest = 2
|
||||
state.sp.punt.home = 15
|
||||
state.sp.punt.guest = 13
|
||||
expect(checkVittoria(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('modalità 3/5: non vittoria a 15-14 nel set decisivo (serve scarto)', () => {
|
||||
state.modalitaPartita = "3/5"
|
||||
state.sp.set.home = 2
|
||||
state.sp.set.guest = 2
|
||||
state.sp.punt.home = 15
|
||||
state.sp.punt.guest = 14
|
||||
expect(checkVittoria(state)).toBe(false)
|
||||
})
|
||||
|
||||
it('modalità 3/5: vittoria a 16-14 nel set decisivo', () => {
|
||||
state.modalitaPartita = "3/5"
|
||||
state.sp.set.home = 2
|
||||
state.sp.set.guest = 2
|
||||
state.sp.punt.home = 16
|
||||
state.sp.punt.guest = 14
|
||||
expect(checkVittoria(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('modalità 2/3: set decisivo dopo 2 set totali → vittoria a 15', () => {
|
||||
state.modalitaPartita = "2/3"
|
||||
state.sp.set.home = 1
|
||||
state.sp.set.guest = 1
|
||||
state.sp.punt.home = 15
|
||||
state.sp.punt.guest = 10
|
||||
expect(checkVittoria(state)).toBe(true)
|
||||
})
|
||||
|
||||
const newState = applyAction(state, { type: 'resetta' })
|
||||
it('modalità 2/3: non vittoria a 24-20 nel set decisivo (soglia 15)', () => {
|
||||
state.modalitaPartita = "2/3"
|
||||
state.sp.set.home = 1
|
||||
state.sp.set.guest = 1
|
||||
state.sp.punt.home = 14
|
||||
state.sp.punt.guest = 10
|
||||
expect(checkVittoria(state)).toBe(false)
|
||||
})
|
||||
|
||||
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.
|
||||
it('modalità 2/3: set non decisivo (1-0) → soglia 25', () => {
|
||||
state.modalitaPartita = "2/3"
|
||||
state.sp.set.home = 1
|
||||
state.sp.set.guest = 0
|
||||
state.sp.punt.home = 15
|
||||
state.sp.punt.guest = 10
|
||||
expect(checkVittoria(state)).toBe(false)
|
||||
})
|
||||
|
||||
it('modalità 3/5: set non decisivo (2-1) → soglia 25', () => {
|
||||
state.modalitaPartita = "3/5"
|
||||
state.sp.set.home = 2
|
||||
state.sp.set.guest = 1
|
||||
state.sp.punt.home = 15
|
||||
state.sp.punt.guest = 10
|
||||
expect(checkVittoria(state)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// RESET
|
||||
// =============================================
|
||||
describe('Reset', () => {
|
||||
it('dovrebbe resettare punti e set a zero', () => {
|
||||
state.sp.punt.home = 10
|
||||
state.sp.punt.guest = 8
|
||||
state.sp.set.home = 1
|
||||
state.sp.set.guest = 1
|
||||
const s = applyAction(state, { type: 'resetta' })
|
||||
expect(s.sp.punt.home).toBe(0)
|
||||
expect(s.sp.punt.guest).toBe(0)
|
||||
expect(s.sp.set.home).toBe(0)
|
||||
expect(s.sp.set.guest).toBe(0)
|
||||
})
|
||||
|
||||
it('dovrebbe resettare formazioni a default', () => {
|
||||
state.sp.form.home = ["10", "11", "12", "13", "14", "15"]
|
||||
const s = applyAction(state, { type: 'resetta' })
|
||||
expect(s.sp.form.home).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
expect(s.sp.form.guest).toEqual(["1", "2", "3", "4", "5", "6"])
|
||||
})
|
||||
|
||||
it('dovrebbe resettare la striscia', () => {
|
||||
state.sp.striscia = { home: [0, 1, 2, 3], guest: [0, " ", " ", 1] }
|
||||
const s = applyAction(state, { type: 'resetta' })
|
||||
expect(s.sp.striscia.home).toEqual([0])
|
||||
expect(s.sp.striscia.guest).toEqual([0])
|
||||
})
|
||||
|
||||
it('dovrebbe resettare lo storico servizio', () => {
|
||||
state.sp.storicoServizio = [{ servHome: true, cambioPalla: false }]
|
||||
const s = applyAction(state, { type: 'resetta' })
|
||||
expect(s.sp.storicoServizio).toEqual([])
|
||||
})
|
||||
|
||||
it('dovrebbe impostare visuForm a false', () => {
|
||||
state.visuForm = true
|
||||
const s = applyAction(state, { type: 'resetta' })
|
||||
expect(s.visuForm).toBe(false)
|
||||
})
|
||||
|
||||
it('dovrebbe mantenere nomi e modalità', () => {
|
||||
state.sp.nomi.home = "Squadra A"
|
||||
state.modalitaPartita = "2/3"
|
||||
const s = applyAction(state, { type: 'resetta' })
|
||||
expect(s.sp.nomi.home).toBe("Squadra A")
|
||||
expect(s.modalitaPartita).toBe("2/3")
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// AZIONE SCONOSCIUTA
|
||||
// =============================================
|
||||
describe('Azione sconosciuta', () => {
|
||||
it('dovrebbe restituire lo stato invariato con azione non riconosciuta', () => {
|
||||
const s = applyAction(state, { type: 'azioneInesistente' })
|
||||
expect(s.sp.punt.home).toBe(0)
|
||||
expect(s.sp.punt.guest).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,148 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { printServerInfo } from '../../src/server-utils.js'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import * as os from 'os'
|
||||
|
||||
// Mocking console.log per evitare output sporchi durante i test
|
||||
import { vi } from 'vitest'
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
return {
|
||||
...await importOriginal(),
|
||||
networkInterfaces: vi.fn(() => ({}))
|
||||
}
|
||||
})
|
||||
|
||||
import { getNetworkIPs, printServerInfo } from '../../src/server-utils.js'
|
||||
|
||||
describe('Server Utils', () => {
|
||||
it('printServerInfo dovrebbe stampare le porte corrette', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log')
|
||||
printServerInfo(3000, 3001)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Unisce tutti i messaggi loggati in un'unica stringa per facilitare la ricerca
|
||||
const allLogs = consoleSpy.mock.calls.map(args => args[0]).join('\n')
|
||||
// =============================================
|
||||
// getNetworkIPs
|
||||
// =============================================
|
||||
describe('getNetworkIPs', () => {
|
||||
it('dovrebbe restituire indirizzi IPv4 non-loopback', () => {
|
||||
os.networkInterfaces.mockReturnValue({
|
||||
eth0: [
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
|
||||
]
|
||||
})
|
||||
expect(getNetworkIPs()).toEqual(['192.168.1.100'])
|
||||
})
|
||||
|
||||
expect(allLogs).toContain('3000')
|
||||
expect(allLogs).toContain('3001')
|
||||
it('dovrebbe escludere indirizzi loopback (internal)', () => {
|
||||
os.networkInterfaces.mockReturnValue({
|
||||
lo: [
|
||||
{ family: 'IPv4', internal: true, address: '127.0.0.1' }
|
||||
],
|
||||
eth0: [
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
|
||||
]
|
||||
})
|
||||
const ips = getNetworkIPs()
|
||||
expect(ips).not.toContain('127.0.0.1')
|
||||
expect(ips).toContain('192.168.1.100')
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
it('dovrebbe escludere indirizzi IPv6', () => {
|
||||
os.networkInterfaces.mockReturnValue({
|
||||
eth0: [
|
||||
{ family: 'IPv6', internal: false, address: 'fe80::1' },
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
|
||||
]
|
||||
})
|
||||
const ips = getNetworkIPs()
|
||||
expect(ips).toEqual(['192.168.1.100'])
|
||||
})
|
||||
|
||||
it('dovrebbe escludere bridge Docker 172.17.x.x', () => {
|
||||
os.networkInterfaces.mockReturnValue({
|
||||
docker0: [
|
||||
{ family: 'IPv4', internal: false, address: '172.17.0.1' }
|
||||
],
|
||||
eth0: [
|
||||
{ family: 'IPv4', internal: false, address: '10.0.0.5' }
|
||||
]
|
||||
})
|
||||
const ips = getNetworkIPs()
|
||||
expect(ips).not.toContain('172.17.0.1')
|
||||
expect(ips).toContain('10.0.0.5')
|
||||
})
|
||||
|
||||
it('dovrebbe escludere bridge Docker 172.18.x.x', () => {
|
||||
os.networkInterfaces.mockReturnValue({
|
||||
br0: [
|
||||
{ family: 'IPv4', internal: false, address: '172.18.0.1' }
|
||||
]
|
||||
})
|
||||
expect(getNetworkIPs()).toEqual([])
|
||||
})
|
||||
|
||||
it('dovrebbe restituire array vuoto se nessuna interfaccia disponibile', () => {
|
||||
os.networkInterfaces.mockReturnValue({})
|
||||
expect(getNetworkIPs()).toEqual([])
|
||||
})
|
||||
|
||||
it('dovrebbe restituire più indirizzi da interfacce diverse', () => {
|
||||
os.networkInterfaces.mockReturnValue({
|
||||
eth0: [
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.100' }
|
||||
],
|
||||
wlan0: [
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.101' }
|
||||
]
|
||||
})
|
||||
const ips = getNetworkIPs()
|
||||
expect(ips).toHaveLength(2)
|
||||
expect(ips).toContain('192.168.1.100')
|
||||
expect(ips).toContain('192.168.1.101')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
// printServerInfo
|
||||
// =============================================
|
||||
describe('printServerInfo', () => {
|
||||
it('dovrebbe stampare le porte corrette (default)', () => {
|
||||
os.networkInterfaces.mockReturnValue({})
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
printServerInfo()
|
||||
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
|
||||
expect(allLogs).toContain('5173')
|
||||
expect(allLogs).toContain('3001')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('dovrebbe stampare le porte personalizzate', () => {
|
||||
os.networkInterfaces.mockReturnValue({})
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
printServerInfo(3000, 4000)
|
||||
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
|
||||
expect(allLogs).toContain('3000')
|
||||
expect(allLogs).toContain('4000')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('dovrebbe mostrare gli URL remoti se ci sono IP di rete', () => {
|
||||
os.networkInterfaces.mockReturnValue({
|
||||
eth0: [
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.50' }
|
||||
]
|
||||
})
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
printServerInfo(3000, 3001)
|
||||
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
|
||||
expect(allLogs).toContain('192.168.1.50')
|
||||
expect(allLogs).toContain('remoti')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('non dovrebbe mostrare sezione remoti se nessun IP di rete', () => {
|
||||
os.networkInterfaces.mockReturnValue({})
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
printServerInfo(3000, 3001)
|
||||
const allLogs = consoleSpy.mock.calls.map(c => c[0]).join('\n')
|
||||
expect(allLogs).not.toContain('remoti')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
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'
|
||||
include: [
|
||||
'tests/unit/**/*.{test,spec}.js',
|
||||
'tests/integration/**/*.{test,spec}.js',
|
||||
'tests/component/**/*.{test,spec}.js',
|
||||
'tests/stress/**/*.{test,spec}.js',
|
||||
],
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
environmentMatchGlobs: [
|
||||
['tests/component/**', 'happy-dom'],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||