1import {
2 createGrid,
3 cloneGrid,
4 nextGeneration,
5 countNeighbors,
6 placePattern,
7 PATTERNS,
8} from './life.js';
9
10function eq(a, b) {
11 return JSON.stringify(a) === JSON.stringify(b);
12}
13
14function g(rows) {
15 return rows.map((row) =>
16 row.split('').map((ch) => (ch === 'O' || ch === 'X' || ch === '1' ? 1 : 0)),
17 );
18}
19
20function totalLive(grid) {
21 let n = 0;
22 for (const row of grid) for (const v of row) if (v) n++;
23 return n;
24}
25
26function assert(cond, msg) {
27 if (!cond) throw new Error(msg || 'assertion failed');
28}
29
30const tests = [
31 {
32 name: 'empty grid stays empty',
33 fn: () => {
34 const empty = createGrid(6, 6);
35 assert(eq(nextGeneration(empty), empty), 'empty should not change');
36 },
37 },
38 {
39 name: 'lone live cell dies from underpopulation',
40 fn: () => {
41 const grid = g([
42 '.....',
43 '.....',
44 '..O..',
45 '.....',
46 '.....',
47 ]);
48 assert(totalLive(nextGeneration(grid)) === 0, 'lone cell should die');
49 },
50 },
51 {
52 name: 'block (2x2) is a still life',
53 fn: () => {
54 const grid = g([
55 '....',
56 '.OO.',
57 '.OO.',
58 '....',
59 ]);
60 assert(eq(nextGeneration(grid), grid), 'block should not change');
61 },
62 },
63 {
64 name: 'blinker rotates horizontal to vertical',
65 fn: () => {
66 const grid = g([
67 '.....',
68 '.....',
69 '.OOO.',
70 '.....',
71 '.....',
72 ]);
73 const expected = g([
74 '.....',
75 '..O..',
76 '..O..',
77 '..O..',
78 '.....',
79 ]);
80 assert(eq(nextGeneration(grid), expected), 'blinker did not rotate');
81 },
82 },
83 {
84 name: 'blinker has period 2',
85 fn: () => {
86 const grid = g([
87 '.....',
88 '.....',
89 '.OOO.',
90 '.....',
91 '.....',
92 ]);
93 assert(eq(nextGeneration(nextGeneration(grid)), grid), 'not period 2');
94 },
95 },
96 {
97 name: 'live cell with 4+ neighbors dies from overpopulation',
98 fn: () => {
99 const grid = g([
100 'OOO',
101 'OOO',
102 'OOO',
103 ]);
104 const next = nextGeneration(grid);
105 assert(next[1][1] === 0, 'centre with 8 neighbors should die');
106 },
107 },
108 {
109 name: 'live cell with 2 or 3 neighbors survives',
110 fn: () => {
111 const three = g([
112 'OOO',
113 '...',
114 '...',
115 ]);
116
117 assert(nextGeneration(three)[0][1] === 1, 'B3/S23 survive fail');
118 },
119 },
120 {
121 name: 'dead cell with exactly 3 neighbors becomes alive',
122 fn: () => {
123 const grid = g([
124 '.O.',
125 'O.O',
126 '...',
127 ]);
128 assert(nextGeneration(grid)[1][1] === 1, 'birth rule failed');
129 },
130 },
131 {
132 name: 'dead cell with 2 or 4 neighbors stays dead',
133 fn: () => {
134 const two = g([
135 'O.O',
136 '...',
137 '...',
138 ]);
139 assert(nextGeneration(two)[0][1] === 0, '2 neighbors should not birth');
140 const four = g([
141 'O.O',
142 '...',
143 'O.O',
144 ]);
145 assert(nextGeneration(four)[1][1] === 0, '4 neighbors should not birth');
146 },
147 },
148 {
149 name: 'countNeighbors: interior cell',
150 fn: () => {
151 const grid = g([
152 'OOO',
153 'O.O',
154 'OOO',
155 ]);
156 assert(countNeighbors(grid, 1, 1, false) === 8, 'centre should have 8');
157 },
158 },
159 {
160 name: 'countNeighbors: bounded edge does not wrap',
161 fn: () => {
162 const grid = g([
163 'O..',
164 '...',
165 '..O',
166 ]);
167
168 assert(countNeighbors(grid, 0, 0, false) === 0, 'no wrap: expect 0');
169 },
170 },
171 {
172 name: 'countNeighbors: wrap counts opposite-corner neighbor',
173 fn: () => {
174 const grid = g([
175 'O..',
176 '...',
177 '..O',
178 ]);
179
180 assert(countNeighbors(grid, 0, 0, true) === 1, 'wrap: expect 1');
181 },
182 },
183 {
184 name: 'wrap-around: blinker across the vertical seam',
185 fn: () => {
186
187 const grid = g([
188 '.O.',
189 '...',
190 '.O.',
191 ]);
192
193
194 const one = nextGeneration(grid, true);
195
196 const expected = g([
197 '...',
198 'OOO',
199 '...',
200 ]);
201 assert(eq(one, expected), 'wrap blinker did not rotate as expected');
202 assert(eq(nextGeneration(one, true), grid), 'wrap blinker not period 2');
203 },
204 },
205 {
206 name: 'glider translates by (1,1) after 4 generations',
207 fn: () => {
208 const rows = 12;
209 const cols = 12;
210 let grid = createGrid(rows, cols);
211 placePattern(grid, PATTERNS.glider, 1, 1);
212 for (let i = 0; i < 4; i++) grid = nextGeneration(grid);
213 const expected = createGrid(rows, cols);
214 placePattern(expected, PATTERNS.glider, 2, 2);
215 assert(eq(grid, expected), 'glider did not shift diagonally');
216 },
217 },
218 {
219 name: 'pulsar has period 3',
220 fn: () => {
221 const rows = 21;
222 const cols = 21;
223 const start = createGrid(rows, cols);
224 placePattern(start, PATTERNS.pulsar, 4, 4);
225 let grid = cloneGrid(start);
226 for (let i = 0; i < 3; i++) grid = nextGeneration(grid);
227 assert(eq(grid, start), 'pulsar did not return in 3 steps');
228
229 assert(!eq(nextGeneration(start), start), 'pulsar was static, not oscillating');
230 },
231 },
232 {
233 name: 'glider gun produces new gliders (population grows)',
234 fn: () => {
235 const rows = 40;
236 const cols = 60;
237 let grid = createGrid(rows, cols);
238 placePattern(grid, PATTERNS.gliderGun, 1, 1);
239 const initial = totalLive(grid);
240 assert(initial === 36, `expected 36 live cells in gun, got ${initial}`);
241 for (let i = 0; i < 30; i++) grid = nextGeneration(grid);
242 const after30 = totalLive(grid);
243 assert(
244 after30 === initial + 5,
245 `expected 41 live cells after 30 gens (gun + 1 glider), got ${after30}`,
246 );
247 for (let i = 0; i < 30; i++) grid = nextGeneration(grid);
248 const after60 = totalLive(grid);
249 assert(
250 after60 === initial + 10,
251 `expected 46 live cells after 60 gens (gun + 2 gliders), got ${after60}`,
252 );
253 },
254 },
255 {
256 name: 'nextGeneration does not mutate the input grid',
257 fn: () => {
258 const grid = g([
259 '.....',
260 '.....',
261 '.OOO.',
262 '.....',
263 '.....',
264 ]);
265 const snapshot = JSON.stringify(grid);
266 nextGeneration(grid);
267 assert(JSON.stringify(grid) === snapshot, 'input grid was mutated');
268 },
269 },
270 {
271 name: 'placePattern respects bounds without wrap',
272 fn: () => {
273 const grid = createGrid(4, 4);
274 placePattern(grid, PATTERNS.glider, 3, 3);
275
276
277
278 assert(totalLive(grid) === 0, 'out-of-bounds cells should be skipped');
279 },
280 },
281 {
282 name: 'placePattern wraps live cells with wrap=true',
283 fn: () => {
284 const grid = createGrid(4, 4);
285 placePattern(grid, PATTERNS.glider, 3, 3, true);
286
287 assert(totalLive(grid) === 5, 'wrapped placement should keep 5 cells');
288 },
289 },
290];
291
292async function runTests() {
293 const results = [];
294 for (const t of tests) {
295 try {
296 t.fn();
297 results.push({ name: t.name, passed: true });
298 } catch (e) {
299 results.push({ name: t.name, passed: false, error: e.message || String(e) });
300 }
301 }
302 return results;
303}
304
305function isBrowser() {
306 return typeof document !== 'undefined';
307}
308
309if (isBrowser()) {
310 runTests().then((results) => {
311 const summary = document.getElementById('summary');
312 const container = document.getElementById('results');
313 const passed = results.filter((r) => r.passed).length;
314 const total = results.length;
315 summary.textContent = `${passed} / ${total} passing`;
316 summary.className = 'test-summary ' + (passed === total ? 'ok' : 'fail');
317 for (const r of results) {
318 const div = document.createElement('div');
319 div.className = 'test ' + (r.passed ? 'ok' : 'fail');
320 const mark = r.passed ? '[PASS]' : '[FAIL]';
321 div.textContent = `${mark} ${r.name}${r.error ? ' — ' + r.error : ''}`;
322 container.appendChild(div);
323 }
324 });
325} else {
326 runTests().then((results) => {
327 let failed = 0;
328 for (const r of results) {
329 const mark = r.passed ? '[PASS]' : '[FAIL]';
330 const line = `${mark} ${r.name}${r.error ? ' — ' + r.error : ''}`;
331 console.log(line);
332 if (!r.passed) failed++;
333 }
334 const total = results.length;
335 console.log(`\n${total - failed}/${total} passing`);
336 process.exit(failed > 0 ? 1 : 0);
337 });
338}
339
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.