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>
138 lines
6.9 KiB
JavaScript
138 lines
6.9 KiB
JavaScript
// m12 check: property-based testing built from the course's own parts.
|
|
// A generator of test data is nothing new: it is a Random<A> from random.js (seed -> [A, nextSeed]).
|
|
import { contains, dropLast } from './util.js'
|
|
import { Action, point, pointEquals, opposite } from './model.js'
|
|
import { inBounds, move } from './rules.js'
|
|
import { freeCells } from './spawn.js'
|
|
import { pure, map, flatMap, intBelow, run } from './random.js'
|
|
/** @import { Point, Direction, GameState, Random, CheckResult } from './types' */
|
|
|
|
/** One of the elements of the non-empty array `xs`: the one at index `intBelow(xs.length)`.
|
|
* @type {<A>(xs: A[]) => Random<A>} */
|
|
export const elementOf = (xs) => map(intBelow(xs.length), (i) => xs[i])
|
|
|
|
/** `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next.
|
|
* @type {<A>(gen: Random<A>, n: number) => Random<A[]>} */
|
|
export const listOf = (gen, n) =>
|
|
n <= 0 ? pure([]) : flatMap(gen, (a) => map(listOf(gen, n - 1), (rest) => [a, ...rest]))
|
|
|
|
// Ticks appear three times so that the snake moves more often than it turns.
|
|
const actionPool = [
|
|
Action.Tick, Action.Tick, Action.Tick,
|
|
Action.Turn('Up'), Action.Turn('Down'), Action.Turn('Left'), Action.Turn('Right'),
|
|
]
|
|
|
|
/** A generator of `n` random actions (given).
|
|
* @type {(n: number) => Random<Action[]>} */
|
|
export const genActions = (n) => listOf(elementOf(actionPool), n)
|
|
|
|
/**
|
|
* Check `prop` on `runs` values drawn from `gen`, starting from `seed`; stop at the first value that fails.
|
|
* Recursion carries the seed: no counter, no loop variable.
|
|
* @type {<A>(gen: Random<A>, prop: (a: A) => boolean, runs: number, seed: number) => CheckResult<A>}
|
|
*/
|
|
export const forAll = (gen, prop, runs, seed) => {
|
|
const go = (i, s) => {
|
|
if (i >= runs) return { ok: true, runs }
|
|
const [value, next] = run(gen, s)
|
|
return prop(value) ? go(i + 1, next) : { ok: false, run: i, seed: s, value }
|
|
}
|
|
return go(0, seed)
|
|
}
|
|
|
|
/** Every list that is `xs` with exactly one element removed, in the order of the removed index.
|
|
* @type {<A>(xs: A[]) => A[][]} */
|
|
export const removals = (xs) => xs.map((_, i) => [...xs.slice(0, i), ...xs.slice(i + 1)])
|
|
|
|
/** Keep removing one element for as long as the list still fails; the result is 1-minimal.
|
|
* @type {<A>(xs: A[], fails: (xs: A[]) => boolean) => A[]} */
|
|
export const minimize = (xs, fails) => {
|
|
const smaller = removals(xs).find(fails)
|
|
return smaller === undefined ? xs : minimize(smaller, fails)
|
|
}
|
|
|
|
const neighbours = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1
|
|
|
|
// Each invariant: its name, and a predicate that is true when the state is fine.
|
|
/** @type {[string, (s: GameState) => boolean][]} */
|
|
const invariants = [
|
|
['snake-not-empty', (s) => s.snake.length > 0],
|
|
['snake-in-bounds', (s) => s.snake.every((p) => inBounds(s, p))],
|
|
['snake-no-overlap', (s) => s.snake.every((p, i) => !contains(s.snake.slice(i + 1), p, pointEquals))],
|
|
['snake-connected', (s) => s.snake.slice(1).every((p, i) => neighbours(s.snake[i], p))],
|
|
['apple-in-bounds', (s) => s.apple === null || inBounds(s, s.apple)],
|
|
['apple-not-on-snake', (s) => s.apple === null || !contains(s.snake, s.apple, pointEquals)],
|
|
['turn-not-reversed', (s) => s.nextDir !== opposite(s.dir)],
|
|
['queued-not-reversed', (s) => s.queued === null || s.queued !== opposite(s.nextDir)],
|
|
]
|
|
|
|
/** The names of the invariants that `state` breaks, in the order of the table above.
|
|
* @type {(state: GameState) => string[]} */
|
|
export const violations = (state) => invariants.filter(([, holds]) => !holds(state)).map(([name]) => name)
|
|
|
|
// Total also for an empty snake (an illegal state, but `moveGaps` should not be the one to crash on it).
|
|
const sameHead = (a, b) => (a.length === 0 || b.length === 0 ? a.length === b.length : pointEquals(a[0], b[0]))
|
|
|
|
/** The numbers of states between consecutive moves of the snake's head.
|
|
* @type {(states: GameState[]) => number[]} */
|
|
export const moveGaps = (states) => {
|
|
const moved = states
|
|
.map((s, i) => (i > 0 && !sameHead(s.snake, states[i - 1].snake) ? i : -1))
|
|
.filter((i) => i >= 0)
|
|
return moved.slice(1).map((i, k) => i - moved[k])
|
|
}
|
|
|
|
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
|
|
|
|
/** @type {Direction[]} */
|
|
const directions = ['Up', 'Down', 'Left', 'Right']
|
|
|
|
// Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell.
|
|
const grow = (width, height, snake, left) => {
|
|
if (left <= 0) return pure(snake)
|
|
const last = snake[snake.length - 1]
|
|
const options = directions
|
|
.map((d) => move(last, d))
|
|
.filter((p) => inBounds({ width, height }, p) && !contains(snake, p, pointEquals))
|
|
return options.length === 0 ? pure(snake) : flatMap(elementOf(options), (p) => grow(width, height, [...snake, p], left - 1))
|
|
}
|
|
|
|
/** A random snake, head first: a random head, then a random walk that never leaves the board or crosses itself.
|
|
* @type {(width: number, height: number, maxLen: number) => Random<Point[]>} */
|
|
export const genSnake = (width, height, maxLen) =>
|
|
flatMap(intBelow(width), (x) => flatMap(intBelow(height), (y) => grow(width, height, [point(x, y)], maxLen - 1)))
|
|
|
|
// The direction the head moved in to get from the second cell to the first ('Right' for a one-cell snake).
|
|
const headingOf = (snake) =>
|
|
snake.length < 2 ? 'Right' : /** @type {Direction} */ (directions.find((d) => pointEquals(move(snake[1], d), snake[0])))
|
|
|
|
/** A random sane Playing state on a `width` x `height` board (given).
|
|
* @type {(width: number, height: number) => Random<GameState>} */
|
|
export const genState = (width, height) =>
|
|
flatMap(intBelow(width * height), (n) =>
|
|
flatMap(genSnake(width, height, n + 1), (snake) => {
|
|
const dir = headingOf(snake)
|
|
const free = freeCells({ width, height, snake })
|
|
return flatMap(elementOf(directions.filter((d) => d !== opposite(dir))), (nextDir) =>
|
|
flatMap(intBelow(2), (coin) =>
|
|
map(coin === 0 || free.length === 0 ? pure(null) : elementOf(free), (apple) => ({
|
|
width, height, snake, dir, nextDir, apple, seed: 0, score: 0, status: 'Playing', frame: 0, queued: null,
|
|
}))))
|
|
}))
|
|
|
|
/**
|
|
* The step of Module 3, written from the rules instead of from `advance`: build the snake the step would give,
|
|
* then ask `violations` whether that snake is on the board and does not overlap itself.
|
|
* @type {(state: GameState) => GameState}
|
|
*/
|
|
export const advanceSpec = (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 snake = [head, ...(eating ? state.snake : dropLast(state.snake))]
|
|
const broken = violations({ ...state, snake }).some((v) => v === 'snake-in-bounds' || v === 'snake-no-overlap')
|
|
return broken
|
|
? { ...state, dir: state.nextDir, status: 'GameOver' }
|
|
: { ...state, snake, dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) }
|
|
}
|