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:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -0,0 +1,137 @@
|
||||
// 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) }
|
||||
}
|
||||
Reference in new issue
Block a user