Files
functional-programming-snake/fsharp/solutions/Check.fs
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

171 lines
7.5 KiB
FSharp

/// 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.fs (`seed -> (value, nextSeed)`).
module Snake.Check
open Snake.Model
open Snake.Random
/// What `forAll` reports: every value passed, or the first one that did not.
type CheckResult<'a> =
| Passed of runs: int
| Failed of run: int * seed: uint32 * value: 'a
/// One of the elements of the non-empty list `xs`: the one at index `intBelow (List.length xs)`.
let elementOf (xs: 'a list) : Random<'a> =
Random.intBelow (List.length xs) |> Random.map (fun i -> List.item i xs)
/// `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next.
let rec listOf (n: int) (gen: Random<'a>) : Random<'a list> =
if n <= 0 then
Random.pure []
else
gen |> Random.bind (fun a -> listOf (n - 1) gen |> Random.map (fun rest -> a :: rest))
// Ticks appear three times so that the snake moves more often than it turns.
let private actionPool: Action list =
[ Tick; Tick; Tick; Turn Up; Turn Down; Turn Left; Turn Right ]
/// A generator of `n` random actions (given).
let genActions (n: int) : Random<Action list> = listOf n (elementOf actionPool)
/// 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. `go` calls itself last, so it runs in constant stack.
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> =
let rec go (i: int) (s: uint32) : CheckResult<'a> =
if i >= runs then
Passed runs
else
let value, next = Random.run s gen
if prop value then go (i + 1) next else Failed(i, s, value)
go 0 seed
/// Every list that is `xs` with exactly one element removed, in the order of the removed index.
let removals (xs: 'a list) : 'a list list = xs |> List.mapi (fun i _ -> List.removeAt i xs)
/// Keep removing one element for as long as the list still fails; the result is 1-minimal.
let rec minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list =
match List.tryFind fails (removals xs) with
| Some smaller -> minimize fails smaller
| None -> xs
let private neighbours (a: Point, b: Point) : bool = abs (a.X - b.X) + abs (a.Y - b.Y) = 1
let rec private allDifferent (ps: Point list) : bool =
match ps with
| [] -> true
| p :: rest -> not (Util.contains rest p (=)) && allDifferent rest
// Each invariant: its name, and a predicate that is true when the state is fine.
// `Option.forall` is true for `None`: no apple breaks nothing.
let private invariants: (string * (GameState -> bool)) list =
[ "snake-not-empty", (fun s -> not (List.isEmpty s.Snake))
"snake-in-bounds", (fun s -> s.Snake |> List.forall (Rules.inBounds s))
"snake-no-overlap", (fun s -> allDifferent s.Snake)
"snake-connected", (fun s -> s.Snake |> List.pairwise |> List.forall neighbours)
"apple-in-bounds", (fun s -> s.Apple |> Option.forall (Rules.inBounds s))
"apple-not-on-snake", (fun s -> s.Apple |> Option.forall (fun a -> not (Util.contains s.Snake a (=))))
"turn-not-reversed", (fun s -> s.NextDir <> opposite s.Dir)
"queued-not-reversed", (fun s -> s.Queued |> Option.forall (fun q -> q <> opposite s.NextDir)) ]
/// The names of the invariants that `state` breaks, in the order of the table above.
let violations (state: GameState) : string list =
invariants |> List.filter (fun (_, holds) -> not (holds state)) |> List.map fst
/// The numbers of states between consecutive moves of the snake's head.
let moveGaps (states: GameState list) : int list =
let moved =
states
|> List.pairwise
|> List.indexed
|> List.filter (fun (_, (before, after)) -> List.tryHead after.Snake <> List.tryHead before.Snake)
|> List.map (fun (i, _) -> i + 1)
moved |> List.pairwise |> List.map (fun (a, b) -> b - a)
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
let private directions: Direction list = [ 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).
let private headingOf (snake: Point list) : Direction =
match snake with
| head :: second :: _ -> directions |> List.find (fun d -> Rules.move second d = head)
| _ -> Right
/// A random snake, head first: a random head, then a random walk that never leaves the board or crosses itself.
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list> =
// Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell.
let rec grow (snake: Point list) (left: int) : Random<Point list> =
if left <= 0 then
Random.pure snake
else
let last = List.last snake
let options =
directions
|> List.map (Rules.move last)
|> List.filter (fun p ->
p.X >= 0 && p.X < width && p.Y >= 0 && p.Y < height && not (Util.contains snake p (=)))
if List.isEmpty options then
Random.pure snake
else
elementOf options |> Random.bind (fun p -> grow (snake @ [ p ]) (left - 1))
Random.intBelow width
|> Random.bind (fun x -> Random.intBelow height |> Random.bind (fun y -> grow [ { X = x; Y = y } ] (maxLen - 1)))
/// A random sane Playing state on a `width` x `height` board (given).
let genState (width: int) (height: int) : Random<GameState> =
Random.intBelow (width * height)
|> Random.bind (fun n ->
genSnake width height (n + 1)
|> Random.bind (fun snake ->
let dir = headingOf snake
let state =
{ Width = width
Height = height
Snake = snake
Dir = dir
NextDir = dir
Apple = None
Seed = 0u
Score = 0
Status = Playing
Frame = 0
Queued = None }
let free = Spawn.freeCells state
elementOf (directions |> List.filter (fun d -> d <> opposite dir))
|> Random.bind (fun nextDir ->
Random.intBelow 2
|> Random.bind (fun coin ->
(if coin = 0 || List.isEmpty free then Random.pure None else elementOf free |> Random.map Some)
|> Random.map (fun apple -> { state with NextDir = nextDir; Apple = apple })))))
/// The step of m03, 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.
let advanceSpec (state: GameState) : GameState =
if state.Status <> Playing then
state
else
let head = Rules.move (List.head state.Snake) state.NextDir
let eating = state.Apple = Some head
let snake = head :: (if eating then state.Snake else Util.dropLast state.Snake)
let broken =
violations { state with Snake = snake }
|> List.exists (fun v -> v = "snake-in-bounds" || v = "snake-no-overlap")
if broken then
{ state with Dir = state.NextDir; Status = GameOver }
else
{ state with
Snake = snake
Dir = state.NextDir
Apple = if eating then None else state.Apple
Score = state.Score + (if eating then 1 else 0) }