1
2
3
4'use strict';
5
6(function () {
7 const isBrowser = typeof window !== 'undefined';
8 const B = isBrowser ? window.Breakout : require('./game.js');
9
10 const results = [];
11
12 function test(name, fn) {
13 try {
14 fn();
15 results.push({ name, pass: true });
16 } catch (e) {
17 results.push({ name, pass: false, error: e && e.message ? e.message : String(e) });
18 }
19 }
20
21 function assert(cond, msg) {
22 if (!cond) throw new Error(msg || 'expected truthy');
23 }
24
25 function assertEqual(actual, expected, msg) {
26 if (actual !== expected) {
27 throw new Error(`${msg || 'assertEqual'}: expected ${expected}, got ${actual}`);
28 }
29 }
30
31 function assertClose(actual, expected, eps, msg) {
32 eps = eps == null ? 1e-6 : eps;
33 if (Math.abs(actual - expected) > eps) {
34 throw new Error(`${msg || 'assertClose'}: expected ~${expected}, got ${actual}`);
35 }
36 }
37
38
39
40 test('clamp keeps in-range values', () => {
41 assertEqual(B.clamp(5, 0, 10), 5);
42 });
43
44 test('clamp clamps below and above', () => {
45 assertEqual(B.clamp(-3, 0, 10), 0);
46 assertEqual(B.clamp(42, 0, 10), 10);
47 });
48
49
50
51 test('stepBall advances position by velocity * dt', () => {
52 const ball = { x: 100, y: 200, r: 8, vx: 50, vy: -80 };
53 B.stepBall(ball, 0.5);
54 assertClose(ball.x, 125);
55 assertClose(ball.y, 160);
56 });
57
58
59
60 test('left wall reflects vx and repositions', () => {
61 const ball = { x: 4, y: 300, r: 8, vx: -100, vy: 50 };
62 const events = B.collideWalls(ball, 800, 600);
63 assert(events.includes('left'), 'expected left event');
64 assertEqual(ball.x, 8, 'ball moved to r');
65 assert(ball.vx > 0, 'vx now positive');
66 });
67
68 test('right wall reflects vx and repositions', () => {
69 const ball = { x: 797, y: 300, r: 8, vx: 100, vy: 50 };
70 const events = B.collideWalls(ball, 800, 600);
71 assert(events.includes('right'), 'expected right event');
72 assertEqual(ball.x, 792, 'ball moved to width - r');
73 assert(ball.vx < 0, 'vx now negative');
74 });
75
76 test('top wall reflects vy and repositions', () => {
77 const ball = { x: 400, y: 3, r: 8, vx: 20, vy: -120 };
78 const events = B.collideWalls(ball, 800, 600);
79 assert(events.includes('top'), 'expected top event');
80 assertEqual(ball.y, 8, 'ball moved to r');
81 assert(ball.vy > 0, 'vy now positive');
82 });
83
84 test('falling past the bottom reports bottom without reflecting', () => {
85 const ball = { x: 400, y: 620, r: 8, vx: 20, vy: 120 };
86 const events = B.collideWalls(ball, 800, 600);
87 assert(events.includes('bottom'), 'expected bottom event');
88 assert(ball.vy > 0, 'vy unchanged (ball is lost, not bounced)');
89 });
90
91 test('open space produces no wall events', () => {
92 const ball = { x: 400, y: 300, r: 8, vx: 100, vy: 100 };
93 assertEqual(B.collideWalls(ball, 800, 600).length, 0);
94 });
95
96 test('corner hit reports both walls', () => {
97 const ball = { x: 4, y: 4, r: 8, vx: -50, vy: -50 };
98 const events = B.collideWalls(ball, 800, 600);
99 assert(events.includes('left') && events.includes('top'), 'expected left + top');
100 });
101
102
103
104 test('circleRectOverlap detects edge contact', () => {
105 const rect = { x: 100, y: 100, w: 60, h: 20 };
106 assert(B.circleRectOverlap(95, 110, 6, rect), 'circle touching left edge');
107 });
108
109 test('circleRectOverlap rejects distant circle', () => {
110 const rect = { x: 100, y: 100, w: 60, h: 20 };
111 assert(!B.circleRectOverlap(50, 50, 6, rect), 'far circle should not overlap');
112 });
113
114 test('circleRectOverlap handles corners correctly', () => {
115 const rect = { x: 100, y: 100, w: 60, h: 20 };
116
117 assert(B.circleRectOverlap(97.2, 97.2, 6, rect), 'near corner within radius');
118
119 assert(!B.circleRectOverlap(95, 95, 6, rect), 'corner distance beyond radius');
120 });
121
122
123
124 function makePaddle() {
125 return { x: 350, y: 560, w: 100, h: 14 };
126 }
127
128 test('center paddle hit bounces straight up', () => {
129 const paddle = makePaddle();
130 const ball = { x: 400, y: 558, r: 8, vx: 60, vy: 180 };
131 assert(B.collidePaddle(ball, paddle), 'should collide');
132 assertClose(ball.vx, 0, 1e-9, 'vx should be ~0');
133 assert(ball.vy < 0, 'vy should point up');
134 });
135
136 test('right-side paddle hit deflects right', () => {
137 const paddle = makePaddle();
138 const ball = { x: 425, y: 558, r: 8, vx: 0, vy: 200 };
139 assert(B.collidePaddle(ball, paddle), 'should collide');
140 assert(ball.vx > 0, 'vx should be positive');
141 assertClose(ball.vx, 200 * Math.sin(B.MAX_BOUNCE_ANGLE * 0.5), 1e-9);
142 });
143
144 test('left-side paddle hit deflects left', () => {
145 const paddle = makePaddle();
146 const ball = { x: 375, y: 558, r: 8, vx: 0, vy: 200 };
147 assert(B.collidePaddle(ball, paddle), 'should collide');
148 assert(ball.vx < 0, 'vx should be negative');
149 });
150
151 test('paddle bounce preserves ball speed', () => {
152 const paddle = makePaddle();
153 const ball = { x: 430, y: 558, r: 8, vx: 90, vy: 160 };
154 const before = Math.hypot(ball.vx, ball.vy);
155 B.collidePaddle(ball, paddle);
156 assertClose(Math.hypot(ball.vx, ball.vy), before, 1e-9, 'speed preserved');
157 });
158
159 test('paddle ignores a ball moving upward', () => {
160 const paddle = makePaddle();
161 const ball = { x: 400, y: 558, r: 8, vx: 0, vy: -200 };
162 assert(!B.collidePaddle(ball, paddle), 'no bounce while moving up');
163 });
164
165 test('paddle repositions ball above itself', () => {
166 const paddle = makePaddle();
167 const ball = { x: 400, y: 565, r: 8, vx: 0, vy: 200 };
168 B.collidePaddle(ball, paddle);
169 assertEqual(ball.y, paddle.y - ball.r, 'ball sits on top of paddle');
170 });
171
172 test('paddle misses a ball outside its bounds', () => {
173 const paddle = makePaddle();
174 const ball = { x: 200, y: 558, r: 8, vx: 0, vy: 200 };
175 assert(!B.collidePaddle(ball, paddle), 'no collision far away');
176 });
177
178
179
180 function makeBrick(extra) {
181 return Object.assign(
182 { x: 100, y: 100, w: 60, h: 20, row: 0, col: 0, hp: 1, maxHp: 1, points: 40, alive: true },
183 extra
184 );
185 }
186
187 test('side impact reflects vx', () => {
188 const brick = makeBrick();
189 const ball = { x: 98, y: 110, r: 6, vx: 100, vy: 0 };
190 assert(B.collideBrick(ball, brick), 'should collide');
191 assert(ball.vx < 0, 'vx reflected');
192 assertClose(ball.vy, 0, 1e-9, 'vy untouched');
193 });
194
195 test('top impact reflects vy', () => {
196 const brick = makeBrick();
197 const ball = { x: 130, y: 98, r: 6, vx: 0, vy: 100 };
198 assert(B.collideBrick(ball, brick), 'should collide');
199 assert(ball.vy < 0, 'vy reflected upward');
200 assertClose(ball.vx, 0, 1e-9, 'vx untouched');
201 });
202
203 test('bottom impact reflects vy downward', () => {
204 const brick = makeBrick();
205 const ball = { x: 130, y: 122, r: 6, vx: 0, vy: -100 };
206 assert(B.collideBrick(ball, brick), 'should collide');
207 assert(ball.vy > 0, 'vy reflected downward');
208 });
209
210 test('dead bricks are ignored', () => {
211 const brick = makeBrick({ alive: false });
212 const ball = { x: 130, y: 110, r: 6, vx: 0, vy: 100 };
213 assert(!B.collideBrick(ball, brick), 'dead brick should not collide');
214 });
215
216 test('distant ball misses the brick', () => {
217 const brick = makeBrick();
218 const ball = { x: 400, y: 400, r: 6, vx: 0, vy: 100 };
219 assert(!B.collideBrick(ball, brick), 'no collision');
220 });
221
222
223
224 test('hitBrick destroys a 1hp brick and awards its points', () => {
225 const brick = makeBrick();
226 const res = B.hitBrick(brick);
227 assert(res.destroyed, 'destroyed');
228 assertEqual(res.points, 40);
229 assert(!brick.alive, 'brick dead');
230 });
231
232 test('hitBrick chips a 2hp brick, then destroys it', () => {
233 const brick = makeBrick({ hp: 2, maxHp: 2 });
234 const first = B.hitBrick(brick);
235 assert(!first.destroyed, 'first hit only chips');
236 assertEqual(first.points, 0);
237 assert(brick.alive, 'still alive');
238 const second = B.hitBrick(brick);
239 assert(second.destroyed, 'second hit destroys');
240 assertEqual(second.points, 40);
241 assert(!brick.alive, 'now dead');
242 });
243
244
245
246 test('movePaddle moves by dir * speed * dt', () => {
247 assertClose(B.movePaddle(100, 1, 500, 0.1, 800, 110), 150);
248 assertClose(B.movePaddle(100, -1, 500, 0.1, 800, 110), 50);
249 });
250
251 test('movePaddle clamps to canvas bounds', () => {
252 assertEqual(B.movePaddle(5, -1, 500, 1, 800, 110), 0);
253 assertEqual(B.movePaddle(650, 1, 500, 1, 800, 110), 690);
254 });
255
256
257
258 test('ballSpeedForLevel starts at base and increases monotonically', () => {
259 assertClose(B.ballSpeedForLevel(300, 1), 300);
260 let prev = 0;
261 for (let level = 1; level <= 8; level++) {
262 const s = B.ballSpeedForLevel(300, level);
263 assert(s > prev, `level ${level} faster than ${level - 1}`);
264 prev = s;
265 }
266 });
267
268 test('levelRows grows with level and caps at max', () => {
269 assertEqual(B.levelRows(1), 4);
270 assertEqual(B.levelRows(2), 5);
271 assertEqual(B.levelRows(20), 8);
272 });
273
274 test('buildLevel produces rows * cols live bricks', () => {
275 const bricks = B.buildLevel(1, { width: 800 });
276 assertEqual(bricks.length, B.levelRows(1) * 10);
277 assert(bricks.every((b) => b.alive), 'all alive');
278 assert(bricks.every((b) => b.points > 0), 'all worth points');
279 });
280
281 test('buildLevel bricks stay inside the side margins', () => {
282 const bricks = B.buildLevel(1, { width: 800, margin: 40 });
283 for (const b of bricks) {
284 assert(b.x >= 40 - 1e-9, 'left of brick inside margin');
285 assert(b.x + b.w <= 800 - 40 + 1e-9, 'right of brick inside margin');
286 }
287 });
288
289 test('buildLevel leaves the configured gap between columns', () => {
290 const bricks = B.buildLevel(1, { width: 800, gap: 6 });
291 const row0 = bricks.filter((b) => b.row === 0).sort((a, b2) => a.x - b2.x);
292 for (let i = 1; i < row0.length; i++) {
293 assertClose(row0[i].x - (row0[i - 1].x + row0[i - 1].w), 6, 1e-9, 'column gap');
294 }
295 });
296
297 test('buildLevel awards more points for higher rows', () => {
298 const bricks = B.buildLevel(1);
299 const top = bricks.find((b) => b.row === 0);
300 const bottom = bricks.find((b) => b.row === B.levelRows(1) - 1);
301 assert(top.points > bottom.points, 'top row worth more');
302 });
303
304 test('buildLevel hardens the top row from level 3', () => {
305 const early = B.buildLevel(1);
306 assert(early.every((b) => b.hp === 1), 'level 1 bricks are all 1hp');
307 const late = B.buildLevel(3);
308 assert(late.filter((b) => b.row === 0).every((b) => b.hp === 2), 'level 3 top row is 2hp');
309 assert(late.filter((b) => b.row > 0).every((b) => b.hp === 1), 'other rows stay 1hp');
310 });
311
312
313
314 test('launchVelocity always serves upward at the requested speed', () => {
315 for (const r of [0, 0.25, 0.5, 0.75, 0.999]) {
316 const v = B.launchVelocity(340, () => r);
317 assert(v.vy < 0, `vy negative for rand=${r}`);
318 assertClose(Math.hypot(v.vx, v.vy), 340, 1e-9, `speed preserved for rand=${r}`);
319 }
320 });
321
322 test('launchVelocity is straight up for a centered rand', () => {
323 const v = B.launchVelocity(340, () => 0.5);
324 assertClose(v.vx, 0, 1e-9);
325 assertClose(v.vy, -340, 1e-9);
326 });
327
328
329
330 test('spawnParticles creates the requested count with the given color', () => {
331 const parts = B.spawnParticles(100, 100, '#ff2d95', 10, () => 0.5);
332 assertEqual(parts.length, 10);
333 assert(parts.every((p) => p.color === '#ff2d95'), 'color applied');
334 assert(parts.every((p) => p.life > 0 && p.life === p.maxLife), 'fresh lifetimes');
335 });
336
337 test('spawnParticles is deterministic with an injected rand', () => {
338 const parts = B.spawnParticles(0, 0, '#fff', 1, () => 0.5);
339
340 assertClose(parts[0].vx, -180, 1e-9);
341 });
342
343 test('updateParticles integrates position and applies gravity', () => {
344 const parts = [{ x: 0, y: 0, vx: 100, vy: 0, life: 1, maxLife: 1, size: 2, color: '#fff' }];
345 const out = B.updateParticles(parts, 0.1, 500);
346 assertEqual(out.length, 1);
347 assertClose(out[0].x, 10);
348 assertClose(out[0].vy, 50, 1e-9, 'gravity applied');
349 assertClose(out[0].life, 0.9);
350 });
351
352 test('updateParticles drops expired particles', () => {
353 const parts = [
354 { x: 0, y: 0, vx: 0, vy: 0, life: 0.05, maxLife: 1, size: 2, color: '#fff' },
355 { x: 0, y: 0, vx: 0, vy: 0, life: 1, maxLife: 1, size: 2, color: '#fff' },
356 ];
357 assertEqual(B.updateParticles(parts, 0.1).length, 1);
358 });
359
360
361
362 test('simulated serve clears a brick and returns to the paddle', () => {
363 const W = 800;
364 const H = 600;
365 const paddle = { x: 350, y: 560, w: 100, h: 14 };
366 const bricks = B.buildLevel(1, { width: W });
367 const ball = { x: 400, y: 550, r: 8, vx: 0, vy: 0 };
368 const v = B.launchVelocity(340, () => 0.5);
369 ball.vx = v.vx;
370 ball.vy = v.vy;
371
372 let destroyed = 0;
373 let paddleBounces = 0;
374 const dt = 1 / 240;
375 for (let i = 0; i < 240 * 10 && paddleBounces === 0; i++) {
376 B.stepBall(ball, dt);
377 const walls = B.collideWalls(ball, W, H);
378 assert(!walls.includes('bottom'), 'ball should never drain in this rally');
379 if (B.collidePaddle(ball, paddle)) paddleBounces++;
380 for (const brick of bricks) {
381 if (brick.alive && B.collideBrick(ball, brick)) {
382 if (B.hitBrick(brick).destroyed) destroyed++;
383 break;
384 }
385 }
386 }
387 assert(destroyed >= 1, 'at least one brick destroyed');
388 assertEqual(paddleBounces, 1, 'ball came back to the paddle');
389 });
390
391
392
393 const failed = results.filter((r) => !r.pass);
394 const summary = `${results.length - failed.length}/${results.length} tests passed`;
395
396 if (isBrowser) {
397 const list = document.getElementById('results');
398 for (const r of results) {
399 const li = document.createElement('li');
400 li.className = r.pass ? 'pass' : 'fail';
401 li.textContent = r.pass ? `✔ ${r.name}` : `✘ ${r.name} — ${r.error}`;
402 list.appendChild(li);
403 }
404 const summaryEl = document.getElementById('summary');
405 summaryEl.textContent = summary;
406 summaryEl.className = failed.length ? 'fail' : 'pass';
407 } else {
408 for (const r of results) {
409 console.log(r.pass ? `PASS ${r.name}` : `FAIL ${r.name} — ${r.error}`);
410 }
411 console.log(`\n${summary}`);
412 if (failed.length) process.exitCode = 1;
413 }
414})();
415
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.