285 lines
10 KiB
JavaScript
285 lines
10 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 + 20 websocket-clients + host 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 });
|
|
await hostC.next('joined');
|
|
test('host is de 20e speler', () => assert.strictEqual(clients.length + 1, 20));
|
|
|
|
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 20 spelers', () => {
|
|
assert.ok(httpHealth.includes('"players":20'), '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: 20 gerangschikte spelers', () => {
|
|
assert.strictEqual(resultsSeen.players.length, 20);
|
|
const ranks = resultsSeen.players.map((p) => p.rank).sort((a, b) => a - b);
|
|
assert.deepStrictEqual(ranks, [...Array(20).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);
|
|
})();
|
|
|