Files
functional-programming-snake/js/solutions/check.js
T
Lucas WintherandClaude Fable 5.2 edd57f0148 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>
2026-11-11 12:05:29 +01:00

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