import test from 'node:test' import assert from 'node:assert/strict' import { update, movesEvery, steer } from '../src/game.js' import { keyToAction } from '../src/input.js' import { speedActions, main, FRAME_MS, MAX_GAP_MS, sessionUpdate, sessions, sessionView, renderSession, play, } from '../src/app.js' import { run, clampTime, interval, keyDowns } from '../src/stream.js' import { trace, loadBest, saveBest, map as mapIO, flatMap as flatMapIO, pure } from '../src/io.js' import { runIO, runApp } from '../src/runtime.js' import { browserPlatform } from '../src/browser.js' import { view } from '../src/view.js' import { newGame } from '../src/spawn.js' import { Action } from '../src/model.js' import { loadFixture, freeze } from './helpers.js' const fixtureTests = (name, f) => { const fx = loadFixture(name) fx.cases.forEach((c) => test(`m10 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected))) } fixtureTests('m10-update', update) fixtureTests('m10-moves-every', movesEvery) fixtureTests('m10-input', keyToAction) fixtureTests('m10-session-update', sessionUpdate) fixtureTests('m10-session-view', sessionView) // ---- the turn queue (Exercise 10.6) test('m10 steer is what update does with a Turn', () => loadFixture('m10-update').cases .filter((c) => c.args[1].tag === 'Turn') .forEach((c) => assert.deepStrictEqual(steer(...freeze([c.args[0], c.args[1].dir])), c.expected, c.name))) test('m10 Turn Up then Turn Left within one step: the snake goes Up on the next step and Left on the one after', () => { const start = newGame(42) // heading Right from (10, 10); the apple is far away const states = [Action.Turn('Up'), Action.Turn('Left'), Action.Tick, Action.Tick] .reduce((acc, a) => [...acc, update(acc[acc.length - 1], a)], [freeze(start)]) assert.deepStrictEqual(states.slice(2).map((s) => s.snake[0]), [{ x: 10, y: 10 }, { x: 10, y: 9 }, { x: 9, y: 9 }]) assert.deepStrictEqual(states.map((s) => s.queued), [null, null, 'Left', null, null]) }) const time = (t) => ({ tag: 'Time', t }) test('m10 the animation clock runs every 25 ms', () => assert.equal(FRAME_MS, 25)) test('m10 speedActions turns the clock into Frame actions every 25 ms', () => assert.deepStrictEqual( run(speedActions, freeze([25, 50, 75, 100, 125, 150, 175, 200].map(time))), Array.from({ length: 8 }, () => Action.Frame))) test('m10 speedActions still turns keys into actions, including pause and restart', () => assert.deepStrictEqual( run(speedActions, freeze([{ tag: 'KeyDown', key: ' ' }, { tag: 'KeyDown', key: 'r' }, { tag: 'KeyDown', key: 'ArrowUp' }])), [Action.TogglePause, Action.Restart, Action.Turn('Up')])) // The drawings are compared by their first shapes, the game's view: Exercise 10.7 adds the best score after it. const startsWithView = (io, state) => { const expected = view(state) assert.deepStrictEqual(trace(io, 0).ops.slice(0, expected.length).map((o) => o.shape), expected) } test('m10 main (seed 42) draws a moved snake after eight 25 ms frames', () => { const { ops, result: stream } = trace(main, 42) assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' }) const [first, afterFirst] = stream.step(time(0)) startsWithView(first[0], newGame(42)) const [drawings] = [25, 50, 75, 100, 125, 150, 175, 200].reduce( ([outs, s], t) => { const [out, next] = s.step(time(t)) return [[...outs, ...out], next] }, [[], afterFirst]) assert.equal(drawings.length, 8) const moved = { ...newGame(42), snake: newGame(42).snake.map((p) => ({ x: p.x + 1, y: p.y })), frame: 0 } startsWithView(drawings[7], moved) }) // ---- clampTime: a long silence from the clock (a background tab) must not turn into hundreds of frames const down = (key) => ({ tag: 'KeyDown', key }) test('m10 clampTime passes small steps of time through unchanged', () => assert.deepStrictEqual(run(clampTime(interval(10), 100), freeze([5, 10, 35].map(time))), [10, 20, 30])) test('m10 clampTime lets a long gap count as maxGap, and carries on from there', () => assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([10, 5000, 5010].map(time))), [10, 20, 30, 40])) test('m10 clampTime clamps the very first step too (the clock starts at 0)', () => assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([time(1000)])), [10, 20])) test('m10 clampTime never moves the clock backwards', () => assert.deepStrictEqual(run(clampTime(interval(10), 100), freeze([30, 10, 40].map(time))), [10, 20, 30, 40, 50, 60])) test('m10 clampTime tells the inner stream exactly the clamped time, and a repeated time adds nothing', () => assert.deepStrictEqual(run(clampTime(interval(1), 100), freeze([5, 5, 6].map(time))), [1, 2, 3, 4, 5, 6])) test('m10 clampTime passes key inputs through, and they do not disturb the clock', () => { assert.deepStrictEqual(run(clampTime(keyDowns, 5), freeze([down('a'), time(50), down('b')])), ['a', 'b']) assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([time(10), down('a'), time(20)])), [10, 20]) }) test('m10 running the same clamped stream twice gives the same outputs', () => { const s = clampTime(interval(10), 25) const xs = freeze([10, 5000, 5010].map(time)) assert.deepStrictEqual(run(s, xs), run(s, xs)) }) test('m10 the game accepts at most 250 ms of time at once', () => assert.equal(MAX_GAP_MS, 250)) test('m10 main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds', () => { const [, afterFirst] = trace(main, 42).result.step(time(0)) const [drawings, next] = afterFirst.step(time(10000)) assert.equal(drawings.length, MAX_GAP_MS / FRAME_MS) assert.equal(next.step(time(10025))[0].length, 1) }) // ---- a persisted high score (Exercise 10.7) const loadOp = { tag: 'LoadBest' } const session = (game, best, improved = false) => ({ game, best, improved }) test('m10 loadBest records a LoadBest op and is answered with the best given to trace', () => assert.deepStrictEqual(trace(loadBest, 0, 17), { result: 17, ops: [loadOp] })) test('m10 trace answers LoadBest with 0 when no best is given', () => assert.equal(trace(loadBest, 9).result, 0)) test('m10 saveBest records a SaveBest op with the score and produces undefined', () => assert.deepStrictEqual(trace(saveBest(7), 0), { result: undefined, ops: [{ tag: 'SaveBest', score: 7 }] })) test('m10 a program can load the best, then save a higher one', () => assert.deepStrictEqual(trace(flatMapIO(loadBest, (b) => saveBest(b + 1)), 0, 4).ops, [loadOp, { tag: 'SaveBest', score: 5 }])) test('m10 sessions starts with the new game and the stored best, then yields one session per action', () => { const out = run(sessions(42, 3, speedActions), freeze([time(25), { tag: 'KeyDown', key: ' ' }])) const s0 = session(newGame(42), 3) const s1 = sessionUpdate(s0, Action.Frame) assert.deepStrictEqual(out, [s0, s1, sessionUpdate(s1, Action.TogglePause)]) }) test('m10 renderSession draws the session view and saves nothing when the best did not improve', () => { const s = freeze(session(newGame(42), 5)) assert.deepStrictEqual(trace(renderSession(s), 0).ops, sessionView(s).map((shape) => ({ tag: 'Draw', shape }))) }) test('m10 renderSession draws the session view, then saves the best, when the best improved', () => { const s = freeze(session({ ...newGame(42), score: 6 }, 6, true)) assert.deepStrictEqual(trace(renderSession(s), 0).ops, [ ...sessionView(s).map((shape) => ({ tag: 'Draw', shape })), { tag: 'SaveBest', score: 6 }, ]) }) test('m10 main asks for the seed, then the stored best, and draws the first session', () => { const { ops, result: stream } = trace(main, 42, 9) assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }, loadOp]) const [first] = stream.step(time(0)) assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), sessionView(session(newGame(42), 9))) }) test('m10 play passes the best to the program: the first scene shows it', () => assert.deepStrictEqual( play(main, 42, freeze([time(0)]), 9), [sessionView(session(newGame(42), 9)).map((shape) => ({ tag: 'Draw', shape }))])) test('m10 play without a best answers LoadBest with 0', () => assert.deepStrictEqual( play(main, 42, freeze([time(0)])), [sessionView(session(newGame(42), 0)).map((shape) => ({ tag: 'Draw', shape }))])) // The golden fixture: a seed, a stored best, a scripted list of inputs, and the scene of the last drawing. loadFixture('m10-golden').cases.forEach((c) => { const [seed, events, best] = freeze(c.args) test(`m10 play(main): ${c.name}`, () => assert.deepStrictEqual(play(main, seed, events, best).at(-1).filter((o) => o.tag === 'Draw').map((o) => o.shape), c.expected)) test(`m10 play(main) saves once per improvement: ${c.name}`, () => { const saves = play(main, seed, events, best).flat().filter((o) => o.tag === 'SaveBest').map((o) => o.score) const finalBest = Number(c.expected.find((s) => s.tag === 'Text' && s.text.startsWith('Best: ')).text.slice(6)) const improvements = Array.from({ length: Math.max(0, finalBest - best) }, (_, i) => best + 1 + i) assert.deepStrictEqual(saves, improvements) }) }) // The runtime with a fake platform that records the two new calls. const fakePlatform = (seed = 42, best = 0) => { const calls = [] const handlers = {} return { calls, fillRect: (...args) => calls.push(['fillRect', ...args]), fillText: (...args) => calls.push(['fillText', ...args]), randomSeed: () => { calls.push(['randomSeed']); return seed }, loadBest: () => { calls.push(['loadBest']); return best }, saveBest: (n) => { calls.push(['saveBest', n]) }, startClock: (cb) => { handlers.time = cb }, onKeyDown: (cb) => { handlers.down = cb }, onKeyUp: (cb) => { handlers.up = cb }, onClick: (cb) => { handlers.click = cb }, // m13; nothing in m10 fires a click fireTime: (t) => handlers.time(t), fireClick: (x, y) => handlers.click(x, y), } } test('m10 runIO answers LoadBest with platform.loadBest() and continues with it', () => { const p = fakePlatform(42, 11) assert.equal(runIO(mapIO(loadBest, (b) => b + 1), p), 12) assert.deepStrictEqual(p.calls, [['loadBest']]) }) test('m10 runIO maps SaveBest to platform.saveBest(score)', () => { const p = fakePlatform() runIO(flatMapIO(saveBest(8), () => pure('done')), p) assert.deepStrictEqual(p.calls, [['saveBest', 8]]) }) test('m10 runApp asks for the seed and loads the best once, at start, and draws nothing before the first input', () => { const p = fakePlatform(42, 3) runApp(main, p) assert.deepStrictEqual(p.calls, [['randomSeed'], ['loadBest']]) p.fireTime(0) p.fireTime(25) assert.equal(p.calls.filter((c) => c[0] === 'loadBest').length, 1) assert.ok(p.calls.some((c) => c[0] === 'fillText' && c[3] === 'Best: 3')) }) // browser.js: only the storage part can be checked without a browser. A fake window with a fake localStorage. const fakeCanvas = { getContext: () => ({}) } const fakeWindow = (storage) => ({ localStorage: storage, addEventListener: () => {}, requestAnimationFrame: () => {} }) const memoryStorage = (entries = {}) => { const data = { ...entries } return { data, getItem: (k) => (k in data ? data[k] : null), setItem: (k, v) => { data[k] = String(v) } } } const throwing = { getItem: () => { throw new Error('blocked') }, setItem: () => { throw new Error('blocked') } } test('m10 browserPlatform loads the best from localStorage key pure-snake-best', () => assert.equal(browserPlatform(fakeCanvas, fakeWindow(memoryStorage({ 'pure-snake-best': '23' }))).loadBest(), 23)) const unreadable = [{}, ...['lots', '-4', '2.5', '1e2', '7.0', '+7', ' 7', '0x10', '2147483648', ''].map((v) => ({ 'pure-snake-best': v }))] test('m10 browserPlatform treats a missing or unreadable best as 0', () => unreadable.forEach((entries) => assert.equal(browserPlatform(fakeCanvas, fakeWindow(memoryStorage(entries))).loadBest(), 0, JSON.stringify(entries)))) test('m10 browserPlatform saves the best under pure-snake-best', () => { const storage = memoryStorage() browserPlatform(fakeCanvas, fakeWindow(storage)).saveBest(31) assert.deepStrictEqual(storage.data, { 'pure-snake-best': '31' }) }) // Some browsers throw on every storage call when storage is blocked, others already when `localStorage` is read. const blockedWindow = { addEventListener: () => {}, requestAnimationFrame: () => {}, get localStorage() { throw new Error('blocked') } } test('m10 browserPlatform never throws when storage is blocked', () => [fakeWindow(throwing), blockedWindow].forEach((win) => { const p = browserPlatform(fakeCanvas, win) assert.equal(p.loadBest(), 0) assert.doesNotThrow(() => p.saveBest(5)) }))