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 {
− name: 'wrap-around: blinker across the vertical seam',
184 name: 'wrap-around: horizontal blinker across the vertical seam rotates',
185 fn: () => {
−
186
187
188 const grid = g([
− '.O.',
− '...',
− '.O.',
189 '.....',
190 '.....',
191 'OO..O',
192 '.....',
193 '.....',
194 ]);
−
−
− const one = nextGeneration(grid, true);
−
195 const expected = g([
− '...',
− 'OOO',
− '...',
196 '.....',
197 'O....',
198 'O....',
199 'O....',
200 '.....',
201 ]);
− assert(eq(one, expected), 'wrap blinker did not rotate as expected');
− assert(eq(nextGeneration(one, true), grid), 'wrap blinker not period 2');
202 const one = nextGeneration(grid, true);
203 assert(eq(one, expected), 'wrap seam blinker did not rotate');
204 assert(eq(nextGeneration(one, true), grid), 'wrap seam blinker not period 2');
205 },
206 },
207 {
208 name: 'glider translates by (1,1) after 4 generations',
209 fn: () => {
210 const rows = 12;
211 const cols = 12;
212 let grid = createGrid(rows, cols);
213 placePattern(grid, PATTERNS.glider, 1, 1);
214 for (let i = 0; i < 4; i++) grid = nextGeneration(grid);
215 const expected = createGrid(rows, cols);
216 placePattern(expected, PATTERNS.glider, 2, 2);
217 assert(eq(grid, expected), 'glider did not shift diagonally');
218 },
219 },
220 {
221 name: 'pulsar has period 3',
222 fn: () => {
223 const rows = 21;
224 const cols = 21;
225 const start = createGrid(rows, cols);
226 placePattern(start, PATTERNS.pulsar, 4, 4);
227 let grid = cloneGrid(start);
228 for (let i = 0; i < 3; i++) grid = nextGeneration(grid);
229 assert(eq(grid, start), 'pulsar did not return in 3 steps');
230
231 assert(!eq(nextGeneration(start), start), 'pulsar was static, not oscillating');
232 },
233 },
234 {
235 name: 'glider gun produces new gliders (population grows)',
236 fn: () => {
237 const rows = 40;
238 const cols = 60;
239 let grid = createGrid(rows, cols);
240 placePattern(grid, PATTERNS.gliderGun, 1, 1);
241 const initial = totalLive(grid);
242 assert(initial === 36, `expected 36 live cells in gun, got ${initial}`);
243 for (let i = 0; i < 30; i++) grid = nextGeneration(grid);
244 const after30 = totalLive(grid);
245 assert(
246 after30 === initial + 5,
247 `expected 41 live cells after 30 gens (gun + 1 glider), got ${after30}`,
248 );
249 for (let i = 0; i < 30; i++) grid = nextGeneration(grid);
250 const after60 = totalLive(grid);
251 assert(
252 after60 === initial + 10,
253 `expected 46 live cells after 60 gens (gun + 2 gliders), got ${after60}`,
254 );
255 },
256 },
257 {
258 name: 'nextGeneration does not mutate the input grid',
259 fn: () => {
260 const grid = g([
261 '.....',
262 '.....',
263 '.OOO.',
264 '.....',
265 '.....',
266 ]);
267 const snapshot = JSON.stringify(grid);
268 nextGeneration(grid);
269 assert(JSON.stringify(grid) === snapshot, 'input grid was mutated');
270 },
271 },
272 {
273 name: 'placePattern respects bounds without wrap',
274 fn: () => {
275 const grid = createGrid(4, 4);
276 placePattern(grid, PATTERNS.glider, 3, 3);
277
278
279
280 assert(totalLive(grid) === 0, 'out-of-bounds cells should be skipped');
281 },
282 },
283 {
284 name: 'placePattern wraps live cells with wrap=true',
285 fn: () => {
286 const grid = createGrid(4, 4);
287 placePattern(grid, PATTERNS.glider, 3, 3, true);
288
289 assert(totalLive(grid) === 5, 'wrapped placement should keep 5 cells');
290 },
291 },
292];
293
294async function runTests() {
295 const results = [];
296 for (const t of tests) {
297 try {
298 t.fn();
299 results.push({ name: t.name, passed: true });
300 } catch (e) {
301 results.push({ name: t.name, passed: false, error: e.message || String(e) });
302 }
303 }
304 return results;
305}
306
307function isBrowser() {
308 return typeof document !== 'undefined';
309}
310
311if (isBrowser()) {
312 runTests().then((results) => {
313 const summary = document.getElementById('summary');
314 const container = document.getElementById('results');
315 const passed = results.filter((r) => r.passed).length;
316 const total = results.length;
317 summary.textContent = `${passed} / ${total} passing`;
318 summary.className = 'test-summary ' + (passed === total ? 'ok' : 'fail');
319 for (const r of results) {
320 const div = document.createElement('div');
321 div.className = 'test ' + (r.passed ? 'ok' : 'fail');
322 const mark = r.passed ? '[PASS]' : '[FAIL]';
323 div.textContent = `${mark} ${r.name}${r.error ? ' — ' + r.error : ''}`;
324 container.appendChild(div);
325 }
326 });
327} else {
328 runTests().then((results) => {
329 let failed = 0;
330 for (const r of results) {
331 const mark = r.passed ? '[PASS]' : '[FAIL]';
332 const line = `${mark} ${r.name}${r.error ? ' — ' + r.error : ''}`;
333 console.log(line);
334 if (!r.passed) failed++;
335 }
336 const total = results.length;
337 console.log(`\n${total - failed}/${total} passing`);
338 process.exit(failed > 0 ? 1 : 0);
339 });
340}
341
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.