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>
328 lines
16 KiB
JavaScript
328 lines
16 KiB
JavaScript
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')
|
|
})
|