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

+327
View File
@@ -0,0 +1,327 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { isDeepStrictEqual } from 'node:util'
import {
elementOf, listOf, genActions, forAll, removals, minimize, violations, moveGaps, genSnake, genState, advanceSpec,
} from '../src/check.js'
import { nextUint32, intBelow, pure, run } from '../src/random.js'
import { update, history, movesEvery } from '../src/game.js'
import { newGame, spawnApple, tick } from '../src/spawn.js'
import { move, advance, inBounds } from '../src/rules.js'
import { Action, opposite, pointEquals } from '../src/model.js'
import { contains, dropLast } from '../src/util.js'
import { loadFixture, freeze } from './helpers.js'
// ---- generators
test('m12 elementOf picks the element at the index drawn by intBelow', () => {
const xs = freeze(['a', 'b', 'c', 'd', 'e'])
const [i, seed] = run(intBelow(5), 0)
assert.deepStrictEqual(run(elementOf(xs), 0), [xs[i], seed])
})
test('m12 elementOf of a one-element array always gives that element, and still uses up a draw', () =>
assert.deepStrictEqual(run(elementOf(freeze(['only'])), 0), ['only', 1831565813]))
test('m12 listOf(gen, 0) is the empty list and leaves the seed alone', () =>
assert.deepStrictEqual(run(listOf(nextUint32, 0), 99), [[], 99]))
test('m12 listOf threads the seed from each draw to the next', () =>
assert.deepStrictEqual(run(listOf(nextUint32, 3), 0), [[1144304738, 1416247, 958946056], 1199730143]))
test('m12 listOf of a constant generator repeats the value and leaves the seed alone', () =>
assert.deepStrictEqual(run(listOf(pure('x'), 4), 7), [['x', 'x', 'x', 'x'], 7]))
test('m12 genActions(n) gives n actions, the same ones for the same seed', () => {
const [actions] = run(genActions(30), 5)
assert.equal(actions.length, 30)
assert.ok(actions.every((a) => a.tag === 'Tick' || a.tag === 'Turn'))
assert.deepStrictEqual(run(genActions(30), 5), run(genActions(30), 5))
})
// ---- forAll
test('m12 forAll reports success with the number of runs', () =>
assert.deepStrictEqual(forAll(intBelow(10), (x) => x >= 0 && x < 10, 200, 1), { ok: true, runs: 200 }))
test('m12 forAll with zero runs succeeds without drawing anything', () =>
assert.deepStrictEqual(forAll(nextUint32, () => false, 0, 1), { ok: true, runs: 0 }))
test('m12 forAll reports the first failing draw, its value and the seed it started from', () => {
const gen = intBelow(10)
const prop = (x) => x !== 7
const [draws] = run(listOf(gen, 200), 3)
const first = draws.findIndex((x) => !prop(x))
assert.ok(first > 0, 'the test needs a failure that is not the very first draw')
const result = forAll(gen, prop, 200, 3)
assert.deepStrictEqual({ ok: result.ok, run: result.run, value: result.value }, { ok: false, run: first, value: 7 })
assert.equal(run(gen, result.seed)[0], 7)
})
test('m12 forAll stops at the first failure: the property is not asked about later values', () => {
const gen = intBelow(10)
const [draws] = run(listOf(gen, 200), 3)
const first = draws.findIndex((x) => x === 7)
let calls = 0 // test code may count; the code under test may not
const result = forAll(gen, (x) => { calls = calls + 1; return x !== 7 }, 200, 3)
assert.equal(result.run, first)
assert.equal(calls, first + 1)
})
test('m12 forAll is reproducible: same generator, property, runs and seed give the same report', () =>
assert.deepStrictEqual(forAll(intBelow(1000), (x) => x % 97 !== 0, 500, 11), forAll(intBelow(1000), (x) => x % 97 !== 0, 500, 11)))
// ---- shrinking
test('m12 removals lists every way to drop one element, in order', () =>
assert.deepStrictEqual(removals(freeze([1, 2, 3])), [[2, 3], [1, 3], [1, 2]]))
test('m12 removals of an empty list is empty, and of a single element is one empty list', () => {
assert.deepStrictEqual(removals([]), [])
assert.deepStrictEqual(removals(['a']), [[]])
})
test('m12 minimize keeps exactly the elements the failure needs', () =>
assert.deepStrictEqual(
minimize(freeze([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), (xs) => xs.includes(3) && xs.includes(7)),
[3, 7]))
test('m12 minimize keeps the order and the duplicates the failure needs', () =>
assert.deepStrictEqual(
minimize(freeze(['b', 'a', 'c', 'a', 'd', 'a']), (xs) => xs.filter((x) => x === 'a').length >= 2 && xs.includes('c')),
['c', 'a', 'a']))
test('m12 minimize of a list that fails whatever you remove is the empty list', () =>
assert.deepStrictEqual(minimize(freeze([1, 2, 3]), () => true), []))
test('m12 minimize returns a list that still fails and has no failing removal (1-minimal)', () => {
const fails = (xs) => xs.reduce((a, b) => a + b, 0) >= 10
const small = minimize(freeze([5, 1, 4, 2, 6, 3]), fails)
assert.ok(fails(small))
assert.ok(removals(small).every((ys) => !fails(ys)))
})
// ---- invariants
const fx = loadFixture('m12-violations')
fx.cases.forEach((c) =>
test(`m12 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(violations(...freeze(c.args)), c.expected)))
// ---- moveGaps
const at = (x, y) => ({ snake: [{ x, y }, { x: x - 1, y }] })
test('m12 moveGaps measures the distance between consecutive moves of the head', () =>
assert.deepStrictEqual(
moveGaps(freeze([at(1, 0), at(1, 0), at(2, 0), at(2, 0), at(2, 0), at(3, 0), at(3, 1)])),
[3, 1]))
test('m12 moveGaps of fewer than two moves is empty', () => {
assert.deepStrictEqual(moveGaps([]), [])
assert.deepStrictEqual(moveGaps(freeze([at(1, 0)])), [])
assert.deepStrictEqual(moveGaps(freeze([at(1, 0), at(1, 0), at(2, 0), at(2, 0)])), [])
})
test('m12 moveGaps counts a move at index 1', () =>
assert.deepStrictEqual(moveGaps(freeze([at(1, 0), at(2, 0), at(2, 0), at(3, 0)])), [2]))
test('m12 moveGaps treats an empty snake as a head of its own', () => {
const snake = (...cells) => ({ snake: cells })
assert.deepStrictEqual(
moveGaps(freeze([snake(), snake(), snake({ x: 1, y: 0 }), snake({ x: 1, y: 0 }), snake({ x: 2, y: 0 })])), [2])
})
// ---- the payoff: properties of the game, and deliberately broken games ("mutants") that they must catch
// Every state of the game played with `upd` from newGame(1). (For the real `update` this is `history`.)
const statesWith = (upd, start, actions) => actions.reduce((acc, a) => [...acc, upd(acc[acc.length - 1], a)], [start])
const sane = (upd) => (actions) => statesWith(upd, newGame(1), actions).every((s) => violations(s).length === 0)
test('m12 statesWith(update, ...) in these tests is the history of Module 5', () => {
const [actions] = run(genActions(25), 9)
assert.deepStrictEqual(statesWith(update, newGame(1), actions), history(newGame(1), actions))
})
test('m12 the real game keeps every invariant on 300 random games of 40 actions', () =>
assert.deepStrictEqual(forAll(genActions(40), sane(update), 300, 2024), { ok: true, runs: 300 }))
// Mutant 1: `turn` checks against nextDir instead of dir (version A of Puzzle 2 in Module 3).
const turnAgainstNextDir = (state, action) =>
action.tag !== 'Turn' ? update(state, action)
: state.status === 'Playing' && action.dir !== opposite(state.nextDir) ? { ...state, nextDir: action.dir }
: state
test('m12 the quick double turn bug is found and shrunk to its two key presses', () => {
const prop = sane(turnAgainstNextDir)
const result = forAll(genActions(40), prop, 300, 2024)
assert.equal(result.ok, false)
const small = minimize(result.value, (actions) => !prop(actions))
assert.equal(small.length, 2)
assert.ok(small.every((a) => a.tag === 'Turn'))
assert.ok(['Up', 'Down'].includes(small[0].dir))
assert.equal(small[1].dir, 'Left')
})
// Mutant 2: `advance` forgets to check the walls.
const advanceWithoutWalls = (state) => {
if (state.status !== 'Playing') return state
const head = move(state.snake[0], state.nextDir)
const eating = state.apple !== null && pointEquals(head, state.apple)
const body = eating ? state.snake : dropLast(state.snake)
return contains(body, head, pointEquals)
? { ...state, dir: state.nextDir, status: 'GameOver' }
: { ...state, snake: [head, ...body], dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) }
}
// What `update` does after a step since Exercise 10.6: take the queued turn. The mutants below that replace `Tick`
// do the same, so that each one differs from the real game in exactly one thing.
const takeQueued = (s) => (s.status === 'Playing' && s.queued !== null ? { ...s, nextDir: s.queued, queued: null } : s)
const noWalls = (state, action) =>
action.tag === 'Tick' ? takeQueued(spawnApple(advanceWithoutWalls(state))) : update(state, action)
test('m12 a game without walls is found and shrunk to the shortest way off the board: ten ticks', () => {
const prop = sane(noWalls)
const result = forAll(genActions(40), prop, 300, 2024)
assert.equal(result.ok, false)
assert.deepStrictEqual(minimize(result.value, (actions) => !prop(actions)), Array.from({ length: 10 }, () => Action.Tick))
})
// Mutant 4: a `Tick` that forgets the turn queue of Exercise 10.6 (the `Frame` step still takes it).
const tickForgetsQueue = (state, action) => (action.tag === 'Tick' ? tick(state) : update(state, action))
test('m12 a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions', () => {
const prop = sane(tickForgetsQueue)
const result = forAll(genActions(40), prop, 300, 2024)
assert.equal(result.ok, false)
assert.deepStrictEqual(
minimize(result.value, (actions) => !prop(actions)),
[Action.Turn('Up'), Action.Turn('Right'), Action.Tick, Action.Turn('Left')])
})
// Mutant 5: the queue accepts any key that differs from the pending turn, even the one straight back from it.
const queueAnyKey = (state, action) =>
action.tag === 'Turn' && state.status === 'Playing' && state.nextDir !== state.dir && action.dir !== state.nextDir
? { ...state, queued: action.dir }
: update(state, action)
test('m12 a queue that accepts a reversal is found and shrunk to its two key presses', () => {
const prop = sane(queueAnyKey)
const result = forAll(genActions(40), prop, 300, 2024)
assert.equal(result.ok, false)
assert.deepStrictEqual(minimize(result.value, (actions) => !prop(actions)), [Action.Turn('Up'), Action.Turn('Down')])
})
// Mutant 3: the frame rule this course used at first: step whenever the *total* frame count is a multiple of movesEvery.
const totalFrameCount = (state, action) => {
if (action.tag !== 'Frame') return update(state, action)
if (state.status !== 'Playing') return state
const counted = { ...state, frame: state.frame + 1 }
return counted.frame % movesEvery(counted.score) === 0 ? tick(counted) : counted
}
// A long corridor: the snake is one step from its third apple (score 2 -> 3, so the speed changes), `frame` frames into the game.
const corridor = (frame) => ({
width: 60, height: 1, snake: [{ x: 1, y: 0 }, { x: 0, y: 0 }], dir: 'Right', nextDir: 'Right',
apple: { x: 2, y: 0 }, seed: 0, score: 2, status: 'Playing', frame, queued: null,
})
const frames = Array.from({ length: 40 }, () => Action.Frame)
const steady = (upd) => (frame) => {
const states = statesWith(upd, corridor(frame), frames)
const scoreAtMove = states.filter((s, i) => i > 0 && !pointEquals(s.snake[0], states[i - 1].snake[0])).map((s) => s.score)
return moveGaps(states).every((gap, k) => gap === movesEvery(scoreAtMove[k]))
}
test('m12 the real game moves exactly every movesEvery(score) frames, also across a speed-up', () =>
assert.deepStrictEqual(forAll(intBelow(8), steady(update), 100, 7), { ok: true, runs: 100 }))
test('m12 counting frames from the start of the game makes some steps too short, and the property finds one', () => {
const result = forAll(intBelow(56), steady(totalFrameCount), 100, 7)
assert.equal(result.ok, false)
assert.equal(steady(totalFrameCount)(result.value), false)
})
// ---- random states (Exercise 12.6)
const connected = (snake) => snake.slice(1).every((p, i) => Math.abs(p.x - snake[i].x) + Math.abs(p.y - snake[i].y) === 1)
test('m12 genSnake draws the head first, then walks from the tail (seed 0, 5x5, at most 6 cells)', () =>
assert.deepStrictEqual(run(genSnake(5, 5, 6), 0), [
[{ x: 3, y: 2 }, { x: 3, y: 1 }, { x: 3, y: 0 }, { x: 2, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 0 }], 4231026099]))
test('m12 genSnake is pinned for a few more seeds', () => {
assert.deepStrictEqual(run(genSnake(5, 5, 6), 1)[0],
[{ x: 2, y: 3 }, { x: 3, y: 3 }, { x: 4, y: 3 }, { x: 4, y: 4 }, { x: 3, y: 4 }, { x: 2, y: 4 }])
assert.deepStrictEqual(run(genSnake(5, 5, 6), 42)[0],
[{ x: 1, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 2, y: 0 }, { x: 3, y: 0 }, { x: 3, y: 1 }])
})
test('m12 genSnake with maxLen 1 is just the head, drawn with two draws', () => {
const [x, s1] = run(intBelow(5), 0)
const [y, s2] = run(intBelow(5), s1)
assert.deepStrictEqual(run(genSnake(5, 5, 1), 0), [[{ x, y }], s2])
})
test('m12 genSnake stops early when the walk is stuck: a 1x1 board holds one cell', () =>
assert.deepStrictEqual(run(genSnake(1, 1, 5), 3)[0], [{ x: 0, y: 0 }]))
test('m12 genSnake always gives a snake on the board, connected, without overlap, at most maxLen long', () =>
assert.deepStrictEqual(
forAll(genSnake(4, 3, 8), (snake) =>
snake.length >= 1 && snake.length <= 8 && connected(snake)
&& snake.every((p) => inBounds({ width: 4, height: 3 }, p))
&& snake.every((p, i) => !contains(snake.slice(i + 1), p, pointEquals)), 300, 5),
{ ok: true, runs: 300 }))
test('m12 every generated state is sane: 500 states of a 5x5 board have no violations', () =>
assert.deepStrictEqual(forAll(genState(5, 5), (s) => violations(s).length === 0, 500, 2024), { ok: true, runs: 500 }))
test('m12 generated states start with seed 0, score 0 and frame 0', () => {
const [states] = run(listOf(genState(5, 5), 50), 2024)
assert.ok(states.every((s) => s.seed === 0 && s.score === 0 && s.frame === 0))
})
test('m12 generated states are Playing, have nothing queued, and vary in length and apple', () => {
const [states] = run(listOf(genState(5, 5), 500), 2024)
assert.ok(states.every((s) => s.status === 'Playing' && s.queued === null && s.width === 5 && s.height === 5))
assert.ok(states.every((s) => s.snake.length === 1 ? s.dir === 'Right' : pointEquals(move(s.snake[1], s.dir), s.snake[0])))
assert.ok(states.some((s) => s.snake.length === 1) && states.some((s) => s.snake.length >= 10))
assert.ok(states.some((s) => s.apple === null) && states.some((s) => s.apple !== null))
assert.ok(states.some((s) => s.nextDir !== s.dir))
})
// ---- a second implementation as an oracle (Exercise 12.7)
const m03 = loadFixture('m03-advance')
m03.cases.forEach((c) =>
test(`m12 advanceSpec agrees with Module 3's advance fixture: ${c.name}`, () =>
assert.deepStrictEqual(advanceSpec(...freeze(c.args)), c.expected)))
test('m12 advance and advanceSpec agree on 500 generated states of a 5x5 board', () =>
assert.deepStrictEqual(
forAll(genState(5, 5), (s) => isDeepStrictEqual(advance(freeze(s)), advanceSpec(s)), 500, 2024),
{ ok: true, runs: 500 }))
// Mutant 6: Puzzle 1 of Module 3, "the new head must not be on the old snake" (it forgets that the tail moves away).
const advanceOldSnakeCheck = (state) => {
if (state.status !== 'Playing') return state
const head = move(state.snake[0], state.nextDir)
const eating = state.apple !== null && pointEquals(head, state.apple)
const body = eating ? state.snake : dropLast(state.snake)
return !inBounds(state, head) || contains(state.snake, head, pointEquals)
? { ...state, dir: state.nextDir, status: 'GameOver' }
: { ...state, snake: [head, ...body], dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) }
}
test('m12 no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane', () =>
assert.deepStrictEqual(
forAll(genState(5, 5), (s) => violations(advanceOldSnakeCheck(s)).length === 0, 500, 2024),
{ ok: true, runs: 500 }))
test('m12 comparing with advanceSpec catches the wrong collision check: the head was moving onto the tail', () => {
const result = forAll(genState(5, 5), (s) => isDeepStrictEqual(advanceOldSnakeCheck(s), advanceSpec(s)), 500, 2024)
assert.equal(result.ok, false)
assert.equal(result.run, 21)
const s = result.value
assert.deepStrictEqual(move(s.snake[0], s.nextDir), s.snake[s.snake.length - 1])
assert.equal(advanceOldSnakeCheck(s).status, 'GameOver')
assert.equal(advanceSpec(s).status, 'Playing')
})