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') })