Audit pass: - Frame rule counts frames since the last move, so every step lasts exactly movesEvery(score) frames, also right after a speed-up - clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap - Pong Restart keeps held keys - Fact corrections (mulberry32 output coverage, Haskell IO, uniform) - New fixture cases from a mutation analysis Extensions (all three tracks, shared fixtures, course text): - Turn queue (Exercise 10.6), persisted high score (10.7), replay viewer (10.8), play() end-to-end helper (9.5) - Optional State (4.7) and optional Module 7b, stack-safe IO - Module 12, property-based testing with generators, shrinking, invariants and a model-based oracle - Module 13, Conway's Life with a Click event - JS type checking via jsconfig, JSDoc and types.d.ts - Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh, Playwright end-to-end suite (e2e/, scripts/e2e.sh) Docs: docs/contract.md is the canonical contract; specs and build log under docs/superpowers. Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
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))
|
|
}))
|