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
+136
@@ -0,0 +1,136 @@
|
||||
import { todo } from './todo.js'
|
||||
import { Action, pointEquals, opposite } from './model.js'
|
||||
import { move } from './rules.js'
|
||||
import { freeCells } from './spawn.js'
|
||||
import { pure, map, flatMap, intBelow } from './random.js'
|
||||
/** @import { Point, Direction, GameState, Random, CheckResult } from './types' */
|
||||
|
||||
/**
|
||||
* 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]`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Random that gives one of the elements of the non-empty array `xs`: the element at index `i`,
|
||||
* where `i` is drawn with `intBelow(xs.length)`.
|
||||
* @type {<A>(xs: A[]) => Random<A>}
|
||||
*/
|
||||
export const elementOf = (xs) => todo('m12')
|
||||
|
||||
/**
|
||||
* A Random that gives an array of `n` values drawn from `gen`, one after another: the first draw starts from the
|
||||
* incoming seed, each later draw from the seed the previous one left behind, and the last draw's seed is the result's.
|
||||
* `listOf(gen, 0)` gives `[]` and leaves the seed alone. No loop: recursion on `n`, with `flatMap` and `map`.
|
||||
* @type {<A>(gen: Random<A>, n: number) => Random<A[]>}
|
||||
*/
|
||||
export const listOf = (gen, n) => todo('m12')
|
||||
|
||||
// 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; it works once `elementOf` and `listOf` do).
|
||||
* @type {(n: number) => Random<Action[]>} */
|
||||
export const genActions = (n) => listOf(elementOf(actionPool), n)
|
||||
|
||||
/**
|
||||
* Check the property `prop` (a function value -> boolean) on `runs` values drawn from `gen`.
|
||||
* The first value is drawn from `seed`, each later one from the seed the previous draw left behind.
|
||||
* Stop at the first value for which `prop` is false.
|
||||
* Result: `{ ok: true, runs }` when every value passed, otherwise `{ ok: false, run, seed, value }`, where
|
||||
* `run` is the 0-based number of the failing draw, `value` is the counterexample and `seed` is the seed that
|
||||
* draw started from (so `run(gen, seed)[0]` gives `value` again).
|
||||
* @type {<A>(gen: Random<A>, prop: (a: A) => boolean, runs: number, seed: number) => CheckResult<A>}
|
||||
*/
|
||||
export const forAll = (gen, prop, runs, seed) => todo('m12')
|
||||
|
||||
/**
|
||||
* Every list that is `xs` with exactly one element removed, in the order of the removed index:
|
||||
* `removals([1, 2, 3])` is `[[2, 3], [1, 3], [1, 2]]`; `removals([])` is `[]`.
|
||||
* @type {<A>(xs: A[]) => A[][]}
|
||||
*/
|
||||
export const removals = (xs) => todo('m12')
|
||||
|
||||
/**
|
||||
* Shrink a failing list. `fails` is a function list -> boolean and `fails(xs)` is true.
|
||||
* If some list in `removals(xs)` still fails, continue with the first one that does; when none fails, return `xs`.
|
||||
* The result is "1-minimal": it fails, and removing any single element makes it pass.
|
||||
* @type {<A>(xs: A[], fails: (xs: A[]) => boolean) => A[]}
|
||||
*/
|
||||
export const minimize = (xs, fails) => todo('m12')
|
||||
|
||||
/**
|
||||
* The names of the invariants that `state` breaks, in this order (an empty array means the state is sane):
|
||||
* 'snake-not-empty' the snake has at least one cell
|
||||
* 'snake-in-bounds' every cell of the snake is on the board
|
||||
* 'snake-no-overlap' no two cells of the snake are equal
|
||||
* 'snake-connected' each cell of the snake is exactly one step (up, down, left or right) from the one before it
|
||||
* 'apple-in-bounds' the apple, if there is one, is on the board
|
||||
* 'apple-not-on-snake' the apple, if there is one, is not on a cell of the snake
|
||||
* 'turn-not-reversed' `nextDir` is not the opposite of `dir`
|
||||
* 'queued-not-reversed' `queued` is null, or is not the opposite of `nextDir`
|
||||
* @type {(state: GameState) => string[]}
|
||||
*/
|
||||
export const violations = (state) => todo('m12')
|
||||
|
||||
/**
|
||||
* Given the states of a game in order, the snake "moves at index i" (i >= 1) when the head of `states[i]`
|
||||
* differs from the head of `states[i - 1]`. `moveGaps(states)` is the list of differences between consecutive
|
||||
* such indices: moves at 2, 5 and 12 give `[3, 7]`. Fewer than two moves give `[]`.
|
||||
* @type {(states: GameState[]) => number[]}
|
||||
*/
|
||||
export const moveGaps = (states) => todo('m12')
|
||||
|
||||
/**
|
||||
* (Exercise 12.6) A Random that gives a snake, head first, on a `width` x `height` board, with at most `maxLen` cells
|
||||
* (`maxLen >= 1`). The draws, in this exact order (so that every track gets the same snake from the same seed):
|
||||
* 1. the head: `x` with `intBelow(width)`, then `y` with `intBelow(height)`;
|
||||
* 2. then, while the snake has fewer than `maxLen` cells: look at the neighbours of its *last* cell, in the order
|
||||
* Up, Down, Left, Right (`move(last, dir)`), and keep those that are on the board and not yet in the snake.
|
||||
* If none is left, stop early. Otherwise append the one picked with `elementOf(neighbours)` and go on.
|
||||
* So the result is always on the board, connected and free of overlap. No loop: recursion, with `flatMap`.
|
||||
* @type {(width: number, height: number, maxLen: number) => Random<Point[]>}
|
||||
*/
|
||||
export const genSnake = (width, height, maxLen) => todo('m12')
|
||||
|
||||
/** @type {Direction[]} */
|
||||
const directions = ['Up', 'Down', 'Left', 'Right']
|
||||
|
||||
// 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; it works once `genSnake` does). Draws in order:
|
||||
* the snake's length limit `n + 1` with `n` from `intBelow(width * height)`; the snake with `genSnake`; `nextDir` with
|
||||
* `elementOf` among Up, Down, Left, Right without the opposite of `dir` (so: `dir` or a perpendicular direction);
|
||||
* a coin `intBelow(2)`: 0 means no apple, 1 means an apple at `elementOf(freeCells)` (no apple if no cell is free).
|
||||
* `dir` is the direction from the snake's second cell to its head ('Right' for a one-cell snake); seed, score and
|
||||
* frame are 0, `queued` is null.
|
||||
* @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,
|
||||
}))))
|
||||
}))
|
||||
|
||||
/**
|
||||
* (Exercise 12.7) The step of Module 3, written straight from the rules, as a second opinion on `advance`.
|
||||
* Not Playing: the state unchanged. Otherwise build the snake the step would produce: the new head
|
||||
* (`move(head, nextDir)`) in front, followed by the old snake, without its last cell unless the new head is on the
|
||||
* apple. Then ask `violations` about the state with that snake: if the list contains 'snake-in-bounds' or
|
||||
* 'snake-no-overlap', the result is Module 3's game over (`dir: nextDir`, status 'GameOver', nothing else changed);
|
||||
* otherwise the state with the new snake, `dir: nextDir`, and, when eating, the apple gone and the score one higher.
|
||||
* Do not call `advance`. On every sane state the result must equal `advance(state)`.
|
||||
* @type {(state: GameState) => GameState}
|
||||
*/
|
||||
export const advanceSpec = (state) => todo('m12')
|
||||
Reference in new issue
Block a user