feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)

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>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+219 -8
View File
@@ -1,10 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { update, movesEvery } from '../src/game.js'
import { update, movesEvery, steer } from '../src/game.js'
import { keyToAction } from '../src/input.js'
import { speedActions, main, FRAME_MS } from '../src/app.js'
import { run } from '../src/stream.js'
import { trace } from '../src/io.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'
@@ -19,6 +23,23 @@ const fixtureTests = (name, f) => {
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 })
@@ -34,11 +55,17 @@ test('m10 speedActions still turns keys into actions, including pause and restar
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, [{ tag: 'RandomSeed' }])
assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' })
const [first, afterFirst] = stream.step(time(0))
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42)))
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))
@@ -46,6 +73,190 @@ test('m10 main (seed 42) draws a moved snake after eight 25 ms frames', () => {
},
[[], afterFirst])
assert.equal(drawings.length, 8)
const moved = { ...newGame(42), snake: newGame(42).snake.map((p) => ({ x: p.x + 1, y: p.y })), frame: 8 }
assert.deepStrictEqual(trace(drawings[7], 0).ops.map((o) => o.shape), view(moved))
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))
}))