import test from 'node:test' import assert from 'node:assert/strict' import { LW, LH, LCELL, STRIP, initialLife, LifeAction } from '../src/life/model.js' import { lifeStep, lifeUpdate } from '../src/life/rules.js' import { lifeView } from '../src/life/view.js' import { GEN_MS, lifeKey, lifeClick, lifeActions, lifeStates, lifeMain } from '../src/life/app.js' import { clicks, keyDowns, keyUps, interval, clampTime, run } from '../src/stream.js' import { trace, pure, draw } from '../src/io.js' import { intBelow } from '../src/random.js' import { runApp } from '../src/runtime.js' import { browserPlatform } from '../src/browser.js' import { map as mapStream } from '../src/stream.js' import { speedActions, main, play } from '../src/app.js' import { pongActions } from '../src/pong/app.js' import { loadFixture, freeze } from './helpers.js' const fixtureTests = (name, f) => { const fx = loadFixture(name) fx.cases.forEach((c) => test(`m13 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected))) } fixtureTests('m13-life-step', lifeStep) fixtureTests('m13-life-update', lifeUpdate) fixtureTests('m13-life-view', lifeView) fixtureTests('m13-life-key', lifeKey) fixtureTests('m13-life-click', lifeClick) const time = (t) => ({ tag: 'Time', t }) const down = (key) => ({ tag: 'KeyDown', key }) const click = (x, y) => ({ tag: 'Click', x, y }) const cell = (x, y) => ({ x, y }) const rowMajor = (cells) => cells.every((c, i) => i === 0 || cells[i - 1].y < c.y || (cells[i - 1].y === c.y && cells[i - 1].x < c.x)) // ---- the model and the rules test('m13 the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below', () => assert.deepStrictEqual([LW, LH, LCELL, STRIP], [40, 30, 16, 24])) test('m13 initialLife is an empty, paused world at generation 0 with the given seed', () => assert.deepStrictEqual(initialLife(9), { cells: [], running: false, seed: 9, generation: 0 })) test('m13 LifeAction builds Toggle with a cell, and has the other actions as plain values', () => assert.deepStrictEqual( [LifeAction.Frame, LifeAction.Toggle(cell(1, 2)), LifeAction.TogglePause, LifeAction.Step, LifeAction.Clear, LifeAction.Randomize], [{ tag: 'Frame' }, { tag: 'Toggle', cell: { x: 1, y: 2 } }, { tag: 'TogglePause' }, { tag: 'Step' }, { tag: 'Clear' }, { tag: 'Randomize' }])) test('m13 lifeUpdate does not modify the state it is given', () => { const s = freeze({ cells: [cell(4, 5), cell(5, 5), cell(6, 5)], running: true, seed: 3, generation: 0 }) const before = structuredClone(s) ;[LifeAction.Frame, LifeAction.Toggle(cell(0, 0)), LifeAction.Toggle(cell(5, 5)), LifeAction.Randomize, LifeAction.Clear] .forEach((a) => assert.doesNotThrow(() => lifeUpdate(s, a))) assert.deepStrictEqual(s, before) }) test('m13 Toggle of a cell in the row just below the board changes nothing', () => { const s = freeze({ cells: [cell(4, 5)], running: false, seed: 3, generation: 2 }) assert.deepStrictEqual(lifeUpdate(s, LifeAction.Toggle(cell(3, LH))), s) }) test('m13 a glider moves one cell diagonally every four generations', () => { const glider = [cell(1, 0), cell(2, 1), cell(0, 2), cell(1, 2), cell(2, 2)] const after4 = [1, 2, 3, 4].reduce((cells) => lifeStep(cells), freeze(glider)) assert.deepStrictEqual(after4, [cell(2, 1), cell(3, 2), cell(1, 3), cell(2, 3), cell(3, 3)]) }) test('m13 the next generation is computed from the whole old board: a blinker blinks forever', () => { const horizontal = [cell(9, 10), cell(10, 10), cell(11, 10)] const gens = Array.from({ length: 6 }).reduce((acc) => [...acc, lifeStep(acc.at(-1))], [freeze(horizontal)]) assert.deepStrictEqual(gens[2], horizontal) assert.deepStrictEqual(gens[6], horizontal) assert.deepStrictEqual(gens[1], [cell(10, 9), cell(10, 10), cell(10, 11)]) }) test('m13 Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0', () => { const seed = 2024 // The same draws, written the long way round with the Module 4 generator. const [draws, next] = Array.from({ length: LW * LH }).reduce(([ds, s]) => { const [d, s2] = intBelow(4)(s) return [[...ds, d], s2] }, [[], seed]) const expected = draws.flatMap((d, i) => (d === 0 ? [cell(i % LW, Math.floor(i / LW))] : [])) const result = lifeUpdate(freeze(initialLife(seed)), LifeAction.Randomize) assert.deepStrictEqual(result.cells, expected) assert.equal(result.seed, next) assert.ok(result.cells.length > 200 && result.cells.length < 400, `${result.cells.length} live cells`) }) test('m13 every generation of a random board stays sorted row-major, on the board and without duplicates', () => { const start = lifeUpdate(initialLife(5), LifeAction.Randomize).cells const gens = Array.from({ length: 20 }).reduce((acc) => [...acc, lifeStep(acc.at(-1))], [freeze(start)]) gens.forEach((cells) => { assert.ok(rowMajor(cells)) assert.ok(cells.every((c) => c.x >= 0 && c.x < LW && c.y >= 0 && c.y < LH)) }) }) // Generous on purpose: a straightforward lifeStep takes a few milliseconds per generation. This only catches a // generation that is far slower than the 150 ms between two of them. test('m13 lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms', () => { const start = lifeUpdate(initialLife(11), LifeAction.Randomize).cells const t0 = Date.now() Array.from({ length: 50 }).reduce((cells) => lifeStep(cells), start) assert.ok(Date.now() - t0 < 2500, `${Date.now() - t0} ms`) }) // ---- the fourth kind of input test('m13 clicks emits the position of every Click input and ignores everything else', () => assert.deepStrictEqual( run(clicks, freeze([time(10), click(3, 4), down('a'), click(0, 479), { tag: 'KeyUp', key: 'a' }])), [{ x: 3, y: 4 }, { x: 0, y: 479 }])) test('m13 the other sources ignore clicks', () => { const inputs = freeze([click(5, 5), time(100), click(6, 6)]) assert.deepStrictEqual(run(keyDowns, inputs), []) assert.deepStrictEqual(run(keyUps, inputs), []) assert.deepStrictEqual(run(interval(50), inputs), [50, 100]) assert.deepStrictEqual(run(clampTime(clicks, 250), inputs), [{ x: 5, y: 5 }, { x: 6, y: 6 }]) }) test('m13 a generation every 150 ms', () => assert.equal(GEN_MS, 150)) test('m13 lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles', () => assert.deepStrictEqual( run(lifeActions, freeze([time(150), down(' '), click(20, 40), down('x'), click(700, 10), down('n'), time(450)])), [LifeAction.Frame, LifeAction.TogglePause, LifeAction.Toggle(cell(1, 2)), LifeAction.Step, LifeAction.Frame, LifeAction.Frame])) test('m13 lifeStates starts with the new world, then yields one state per action', () => { const out = run(lifeStates(7, lifeActions), freeze([time(0), click(16, 16), click(32, 16), click(48, 16), down(' '), time(150)])) assert.deepStrictEqual(out[0], initialLife(7)) assert.deepStrictEqual(out.map((s) => s.cells.length), [0, 1, 2, 3, 3, 3]) assert.deepStrictEqual(out.at(-1), { cells: [cell(2, 0), cell(2, 1), cell(2, 2)], running: true, seed: 7, generation: 1 }) }) test('m13 lifeMain asks for a seed, draws the empty world first, and then a click as a live cell', () => { const { ops, result: stream } = trace(lifeMain, 42) assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }]) const [first, next] = stream.step(time(0)) const [second] = next.step(click(17, 33)) assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), lifeView(initialLife(42))) assert.deepStrictEqual( trace(second[0], 0).ops.map((o) => o.shape), lifeView({ ...initialLife(42), cells: [cell(1, 2)] })) }) test('m13 lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds', () => { const [, afterFirst] = trace(lifeMain, 42).result.step(time(0)) assert.equal(afterFirst.step(time(10000))[0].length, 1) }) // ---- the runtime and the browser platform (step 6 and 7 of the module) const fakePlatform = () => { const calls = [] const handlers = {} return { calls, fillRect: (...args) => calls.push(['fillRect', ...args]), fillText: (...args) => calls.push(['fillText', ...args]), randomSeed: () => 42, loadBest: () => 0, saveBest: () => {}, startClock: (cb) => { handlers.time = cb }, onKeyDown: (cb) => { handlers.down = cb }, onKeyUp: (cb) => { handlers.up = cb }, onClick: (cb) => { handlers.click = cb }, fireTime: (t) => handlers.time(t), fireClick: (x, y) => handlers.click(x, y), registered: () => Object.keys(handlers).sort(), } } test('m13 runApp registers a click handler with the platform', () => { const p = fakePlatform() runApp(pure(mapStream(clicks, () => pure(undefined))), p) assert.deepStrictEqual(p.registered(), ['click', 'down', 'time', 'up']) }) test('m13 runApp feeds clicks to the stream', () => { const p = fakePlatform() const program = pure(mapStream(clicks, ({ x, y }) => draw({ tag: 'Rect', x, y, w: 1, h: 1, color: '#ffffff' }))) runApp(program, p) p.fireClick(33, 47) p.fireClick(0, 2) assert.deepStrictEqual(p.calls, [['fillRect', 33, 47, 1, 1, '#ffffff'], ['fillRect', 0, 2, 1, 1, '#ffffff']]) }) test('m13 runApp runs Life: a click on the canvas draws the world with that cell alive', () => { const p = fakePlatform() runApp(lifeMain, p) p.fireTime(0) p.calls.length = 0 p.fireClick(100, 250) assert.ok(p.calls.some((c) => c[0] === 'fillRect' && c[1] === 6 * 16 + 1 && c[2] === 15 * 16 + 1 && c[5] === '#7cb342')) }) test('m13 Snake and Pong ignore clicks: their actions and drawings are the same with and without them', () => { const withClicks = freeze([time(0), click(5, 5), time(25), down('ArrowUp'), click(300, 300), time(200)]) const without = withClicks.filter((i) => i.tag !== 'Click') assert.deepStrictEqual(run(speedActions, withClicks), run(speedActions, without)) assert.deepStrictEqual(run(pongActions, withClicks), run(pongActions, without)) assert.deepStrictEqual(play(main, 42, withClicks), play(main, 42, without)) }) // A fake canvas shown at half its size (CSS), with a 2-pixel border, 100 px from the left and 50 px from the top. const fakeCanvas = () => { const listeners = [] return { listeners, width: 640, height: 504, clientWidth: 320, clientHeight: 252, clientLeft: 2, clientTop: 2, getContext: () => ({}), getBoundingClientRect: () => ({ left: 100, top: 50, width: 324, height: 256 }), addEventListener: (type, f) => listeners.push([type, f]), } } const fakeWindow = { addEventListener: () => {}, requestAnimationFrame: () => {}, localStorage: null } test('m13 browserPlatform listens for clicks on the canvas, not before onClick is called', () => { const canvas = fakeCanvas() const platform = browserPlatform(canvas, fakeWindow) assert.deepStrictEqual(canvas.listeners, []) platform.onClick(() => {}) assert.deepStrictEqual(canvas.listeners.map(([type]) => type), ['click']) }) test('m13 browserPlatform converts a click to canvas pixels, through the border and the CSS scaling', () => { const canvas = fakeCanvas() const got = [] browserPlatform(canvas, fakeWindow).onClick((x, y) => got.push([x, y])) const [, listener] = canvas.listeners[0] listener({ clientX: 100 + 2 + 10, clientY: 50 + 2 + 20 }) listener({ clientX: 100 + 2 + 319.75, clientY: 50 + 2 + 0.4 }) assert.deepStrictEqual(got, [[20, 40], [639, 0]]) })