'use strict'; /* * Turbo Race - headless test/simulatie * 1) engine-unit-test (fases, vol, host-token, botsingen, power-ups, ranking) * 2) integratietest: echte server + 19 spelers + host (toeschouwer) die de race start * Run: node test/simulate.js */ const assert = require('assert'); const { spawn } = require('child_process'); const path = require('path'); const http = require('http'); const net = require('net'); const WebSocket = require('ws'); let failures = 0; let testChild = null; function test(name, fn) { try { fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.error(` ✗ ${name}\n ${e.message}`); } } function freePort() { return new Promise((resolve) => { const srv = net.createServer(); srv.listen(0, () => { const port = srv.address().port; srv.close(() => resolve(port)); }); }); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } /* ---------------- DEEL 1: engine-unit-test ---------------- */ async function engineUnit() { console.log('\n[1] Engine-unit-test'); const Game = require('../server/engine'); const g = new Game({ maxPlayers: 20, countdownS: 1, raceDurationS: 12 }); const host = g.addPlayer('Host', '#ff0000', 'tok', 'tok'); test('host wordt herkend met geldig token', () => assert.strictEqual(host.host, true)); const speler = g.addPlayer('Piet', '#00ff00', 'verkeerd', 'tok'); test('speler met fout token is geen host', () => assert.strictEqual(speler.host, false)); test('join tijdens race wordt geweigerd (LATE)', () => { g.phase = 'race'; assert.throws(() => g.addPlayer('Laat', '#fff', '', 'tok'), (e) => e.code === 'LATE'); g.phase = 'lobby'; }); for (let i = 2; i < 20; i++) g.addPlayer('R' + i, '#00f0ff', '', 'tok'); test('20 spelers kunnen aanmelden', () => assert.strictEqual(g.players.size, 20)); test('21e speler wordt geweigerd (FULL)', () => { assert.throws(() => g.addPlayer('X', '#fff', '', 'tok'), (e) => e.code === 'FULL'); }); test('hostStart zet fases lobby->countdown', () => { assert.strictEqual(g.hostStart(), true); assert.strictEqual(g.phase, 'countdown'); assert.strictEqual(g.hostStart(), false); // niet opnieuw tijdens countdown }); const loopEnd = Date.now() + 20000; while (g.phase !== 'results' && Date.now() < loopEnd) { g.update(25); await sleep(20); } test('race eindigt in results', () => assert.strictEqual(g.phase, 'results')); test('baan is gegenereerd met obstakels + power-ups', () => { assert.ok(g.track, 'track bestaat'); assert.ok(g.track.obstacles.length > 20, 'meer dan 20 obstakels'); const kinds = new Set(g.track.obstacles.map((o) => o.type)); assert.ok(kinds.has('pylon') && kinds.has('oil'), 'zowel pylon als olie'); assert.ok(g.track.powerups.some((p) => p.kind === 'boost'), 'boost-power-up aanwezig'); assert.ok(g.track.powerups.some((p) => p.kind === 'shield'), 'shield-power-up aanwezig'); }); test('rankings zijn compleet en opeenvolgend', () => { const ranks = [...g.players.values()].map((p) => p.rank).sort((a, b) => a - b); assert.deepStrictEqual(ranks, [...Array(20).keys()].map((i) => i + 1)); }); test('snapshot bevat alle spelers + fase', () => { const s = g.snapshot(); assert.strictEqual(s.players.length, 20); assert.strictEqual(s.phase, 'results'); assert.ok(s.trackLength > 0); }); test('readyAgain reset naar lobby en houdt spelers', () => { assert.strictEqual(g.readyAgain(), true); assert.strictEqual(g.phase, 'lobby'); assert.strictEqual(g.players.size, 20); }); test('pylon-aanrijding vertraagt (slowUntil) en power-up activeert boost', () => { g.hostStart(); const t0 = Date.now(); while (g.phase !== 'race' && Date.now() < t0 + 3000) { g.update(25); } assert.strictEqual(g.phase, 'race'); const p = [...g.players.values()][0]; const pylon = g.track.obstacles.find((o) => o.type === 'pylon'); const pu = g.track.powerups.find((x) => x.kind === 'boost' && x.respawnAt <= Date.now()); assert.ok(pylon && pu, 'pylon en boost-power-up aanwezig'); p.d = pylon.d - 5; p.x = pylon.x; g.update(25); const slowHit = p.slowUntil > Date.now(); p.d = pu.d; p.x = pu.x; g.update(25); assert.ok(slowHit, 'pylon vertraagt zoals verwacht'); assert.ok(p.boostUntil > Date.now(), 'boost na power-up actief'); }); console.log(' [engine] OK'); } /* ---------------- DEEL 2: websocket-integratietest ---------------- */ async function integration() { console.log('\n[2] Websocket-integratietest (server + 20 clients + host)'); const PORT = await freePort(); const ADMIN = 'testhost123'; const child = spawn(process.execPath, ['server/index.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, PORT: String(PORT), ADMIN_TOKEN: ADMIN, COUNTDOWN_S: '2', RACE_DURATION_S: '8', MAX_PLAYERS: '20', }, stdio: ['ignore', 'pipe', 'pipe'], }); testChild = child; let out = ''; child.stdout.on('data', (d) => { out += d.toString(); }); child.stderr.on('data', (d) => { out += d.toString(); }); let up = false; for (let i = 0; i < 100 && !up; i++) { await sleep(100); up = out.includes('Turbo Race draait'); } test('server start', () => assert.ok(up, 'server log niet gezien: ' + out)); const url = `ws://127.0.0.1:${PORT}/ws`; function mkClient() { const w = new WebSocket(url); const q = []; const waiters = []; w.on('message', (b) => { const m = JSON.parse(b.toString()); const idx = currentWaiters.findIndex((wl) => wl.type === m.type); if (idx >= 0) { const wl = currentWaiters.splice(idx, 1)[0]; wl.resolve(m); } else { q.push(m); } }); const currentWaiters = []; return { w, send: (o) => w.send(JSON.stringify(o)), next: (type, timeout = 8000) => new Promise((resolve, reject) => { const t = setTimeout(() => { const i = currentWaiters.findIndex((wl) => wl.type === type); if (i >= 0) currentWaiters.splice(i, 1); reject(new Error('timeout wachten op ' + type)); }, timeout); const idx = q.findIndex((m) => m.type === type); if (idx >= 0) { const m = q.splice(idx, 1)[0]; clearTimeout(t); resolve(m); return; } currentWaiters.push({ type, resolve: (m) => { clearTimeout(t); resolve(m); } }); }), close: () => { try { w.close(); } catch {} }, }; } const clients = []; for (let i = 0; i < 19; i++) { const c = mkClient(); await new Promise((res, rej) => { c.w.once('open', res); c.w.once('error', rej); }); c.send({ type: 'join', name: 'Speler' + i, color: '#00f0ff' }); clients.push(c); } for (let i = 0; i < clients.length; i++) { try { await clients[i].next('joined'); } catch (e) { console.error(` [debug] joined-timeout op client ${i}: ${e.message}`); throw e; } } test('19 clients ontvangen joined', () => true); const hostC = mkClient(); await new Promise((res, rej) => { hostC.w.once('open', res); hostC.w.once('error', rej); }); hostC.send({ type: 'join', name: 'Boss', color: '#ff3fa4', hostToken: ADMIN }); const hostJoined = await hostC.next('joined'); test('host wordt toeschouwer (telt niet als speler, geen eigen auto)', () => { assert.strictEqual(hostJoined.host, true); assert.strictEqual(hostJoined.spectator, true); assert.strictEqual(hostJoined.id, undefined); }); const httpHealth = await new Promise((res) => { http.get(`http://127.0.0.1:${PORT}/healthz`, (r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => res(b)); }).on('error', () => res('')); }); test('healthz rapporteert 19 spelers (host telt niet mee)', () => { assert.ok(httpHealth.includes('"players":19'), 'healthz: ' + httpHealth); }); // niet-host mag niet kunnen starten (inline, synchrone volgorde) clients[0].send({ type: 'host_start' }); await sleep(200); const samples = []; const grab = (b) => samples.push(JSON.parse(b.toString()).type); for (const c of clients.slice(0, 5)) c.w.on('message', grab); await sleep(400); for (const c of clients.slice(0, 5)) c.w.removeListener('message', grab); try { assert.ok(!samples.includes('phase'), 'fase mag niet gewijzigd zijn door niet-host: ' + samples.join(',')); console.log(' ✓ niet-host kan geen race starten (server negeert)'); } catch (e) { failures++; console.error(' ✗ niet-host kan geen race starten (server negeert)\n ' + e.message); } hostC.send({ type: 'host_start' }); const trackMsg = await hostC.next('track', 8000).catch(() => null); test('host start -> server stuurt track-layout', () => assert.ok(trackMsg && trackMsg.track && trackMsg.track.obstacles.length > 0)); clients.forEach((c, i) => c.send({ type: 'input', steer: (i % 3) - 1 })); // wacht op resultaten via een directe watcher (ontwijkt de bericht-queue) function watchSnapshot(client, phaseWanted, ms) { return new Promise((resolve) => { const t = setTimeout(() => resolve(null), ms); const h = (b) => { const m = JSON.parse(b.toString()); if (m.type === 'snapshot' && m.phase === phaseWanted) { clearTimeout(t); client.w.removeListener('message', h); resolve(m); } }; client.w.on('message', h); }); } // countdown 2s + race 8s + ruime marge const resultsSeen = await watchSnapshot(hostC, 'results', 26000).catch(() => null); test('race loopt door naar results-fase', () => assert.ok(resultsSeen, 'geen results-fase gezien')); if (resultsSeen) { test('uitslag: 19 gerangschikte spelers', () => { assert.strictEqual(resultsSeen.players.length, 19); const ranks = resultsSeen.players.map((p) => p.rank).sort((a, b) => a - b); assert.deepStrictEqual(ranks, [...Array(19).keys()].map((i) => i + 1)); }); test('er is een duidelijke koploper/winnaar', () => { const first = resultsSeen.players.find((p) => p.rank === 1); assert.ok(first, 'rank 1 bestaat'); assert.ok(first.name.length > 0); }); } hostC.send({ type: 'ready_again' }); await sleep(300); const snap2 = await watchSnapshot(hostC, 'lobby', 4000); test('ready_again -> terug naar lobby', () => assert.ok(snap2, 'geen lobby-fase gezien na ready_again')); clients.forEach((c) => c.close()); hostC.close(); } (async () => { try { await engineUnit(); await integration(); } catch (e) { failures++; console.error(' ✗ run-fatale fout: ' + e.message); } finally { if (testChild) { try { testChild.kill('SIGKILL'); } catch {} } } console.log(failures === 0 ? '\n✅ ALLE TESTS GESLAAGD' : `\n❌ ${failures} test(s) mislukt`); process.exit(failures === 0 ? 0 : 1); })();