Files
martijn.diersmann@pm.me 4bdac1e6e8 laatste versie
2026-09-26 23:25:04 +02:00

289 lines
11 KiB
JavaScript

'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);
})();