From b9aed683c6e8c89a9272dcc0497fed6945d00d0a Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 1 Apr 2026 19:19:16 +0200 Subject: [PATCH] test(cli): aggiunge suite unit per il terminal controller 32 test che coprono registrazione WebSocket, parsing di tutti i comandi (shortcut inclusi), validazioni input, conferma reset e gestione uscita. --- tests/unit/cli.test.js | 303 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 tests/unit/cli.test.js diff --git a/tests/unit/cli.test.js b/tests/unit/cli.test.js new file mode 100644 index 0000000..5aa06f0 --- /dev/null +++ b/tests/unit/cli.test.js @@ -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 " → setNomi con entrambi i nomi', () => { + sendLine('nomi Antoniana Riviera') + expect(lastSent()).toMatchObject({ + type: 'action', + action: { type: 'setNomi', home: 'Antoniana', guest: 'Riviera' }, + }) + }) + + it('"nomi " → 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() + }) +})