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:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { LW, LH, LCELL, STRIP, initialLife, LifeAction } from '../src/life/model.js'
import { lifeStep, lifeUpdate } from '../src/life/rules.js'
import { lifeView } from '../src/life/view.js'
import { GEN_MS, lifeKey, lifeClick, lifeActions, lifeStates, lifeMain } from '../src/life/app.js'
import { clicks, keyDowns, keyUps, interval, clampTime, run } from '../src/stream.js'
import { trace, pure, draw } from '../src/io.js'
import { intBelow } from '../src/random.js'
import { runApp } from '../src/runtime.js'
import { browserPlatform } from '../src/browser.js'
import { map as mapStream } from '../src/stream.js'
import { speedActions, main, play } from '../src/app.js'
import { pongActions } from '../src/pong/app.js'
import { loadFixture, freeze } from './helpers.js'
const fixtureTests = (name, f) => {
const fx = loadFixture(name)
fx.cases.forEach((c) =>
test(`m13 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected)))
}
fixtureTests('m13-life-step', lifeStep)
fixtureTests('m13-life-update', lifeUpdate)
fixtureTests('m13-life-view', lifeView)
fixtureTests('m13-life-key', lifeKey)
fixtureTests('m13-life-click', lifeClick)
const time = (t) => ({ tag: 'Time', t })
const down = (key) => ({ tag: 'KeyDown', key })
const click = (x, y) => ({ tag: 'Click', x, y })
const cell = (x, y) => ({ x, y })
const rowMajor = (cells) => cells.every((c, i) => i === 0 || cells[i - 1].y < c.y || (cells[i - 1].y === c.y && cells[i - 1].x < c.x))
// ---- the model and the rules
test('m13 the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below', () =>
assert.deepStrictEqual([LW, LH, LCELL, STRIP], [40, 30, 16, 24]))
test('m13 initialLife is an empty, paused world at generation 0 with the given seed', () =>
assert.deepStrictEqual(initialLife(9), { cells: [], running: false, seed: 9, generation: 0 }))
test('m13 LifeAction builds Toggle with a cell, and has the other actions as plain values', () =>
assert.deepStrictEqual(
[LifeAction.Frame, LifeAction.Toggle(cell(1, 2)), LifeAction.TogglePause, LifeAction.Step, LifeAction.Clear, LifeAction.Randomize],
[{ tag: 'Frame' }, { tag: 'Toggle', cell: { x: 1, y: 2 } }, { tag: 'TogglePause' }, { tag: 'Step' }, { tag: 'Clear' },
{ tag: 'Randomize' }]))
test('m13 lifeUpdate does not modify the state it is given', () => {
const s = freeze({ cells: [cell(4, 5), cell(5, 5), cell(6, 5)], running: true, seed: 3, generation: 0 })
const before = structuredClone(s)
;[LifeAction.Frame, LifeAction.Toggle(cell(0, 0)), LifeAction.Toggle(cell(5, 5)), LifeAction.Randomize, LifeAction.Clear]
.forEach((a) => assert.doesNotThrow(() => lifeUpdate(s, a)))
assert.deepStrictEqual(s, before)
})
test('m13 Toggle of a cell in the row just below the board changes nothing', () => {
const s = freeze({ cells: [cell(4, 5)], running: false, seed: 3, generation: 2 })
assert.deepStrictEqual(lifeUpdate(s, LifeAction.Toggle(cell(3, LH))), s)
})
test('m13 a glider moves one cell diagonally every four generations', () => {
const glider = [cell(1, 0), cell(2, 1), cell(0, 2), cell(1, 2), cell(2, 2)]
const after4 = [1, 2, 3, 4].reduce((cells) => lifeStep(cells), freeze(glider))
assert.deepStrictEqual(after4, [cell(2, 1), cell(3, 2), cell(1, 3), cell(2, 3), cell(3, 3)])
})
test('m13 the next generation is computed from the whole old board: a blinker blinks forever', () => {
const horizontal = [cell(9, 10), cell(10, 10), cell(11, 10)]
const gens = Array.from({ length: 6 }).reduce((acc) => [...acc, lifeStep(acc.at(-1))], [freeze(horizontal)])
assert.deepStrictEqual(gens[2], horizontal)
assert.deepStrictEqual(gens[6], horizontal)
assert.deepStrictEqual(gens[1], [cell(10, 9), cell(10, 10), cell(10, 11)])
})
test('m13 Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0', () => {
const seed = 2024
// The same draws, written the long way round with the Module 4 generator.
const [draws, next] = Array.from({ length: LW * LH }).reduce(([ds, s]) => {
const [d, s2] = intBelow(4)(s)
return [[...ds, d], s2]
}, [[], seed])
const expected = draws.flatMap((d, i) => (d === 0 ? [cell(i % LW, Math.floor(i / LW))] : []))
const result = lifeUpdate(freeze(initialLife(seed)), LifeAction.Randomize)
assert.deepStrictEqual(result.cells, expected)
assert.equal(result.seed, next)
assert.ok(result.cells.length > 200 && result.cells.length < 400, `${result.cells.length} live cells`)
})
test('m13 every generation of a random board stays sorted row-major, on the board and without duplicates', () => {
const start = lifeUpdate(initialLife(5), LifeAction.Randomize).cells
const gens = Array.from({ length: 20 }).reduce((acc) => [...acc, lifeStep(acc.at(-1))], [freeze(start)])
gens.forEach((cells) => {
assert.ok(rowMajor(cells))
assert.ok(cells.every((c) => c.x >= 0 && c.x < LW && c.y >= 0 && c.y < LH))
})
})
// Generous on purpose: a straightforward lifeStep takes a few milliseconds per generation. This only catches a
// generation that is far slower than the 150 ms between two of them.
test('m13 lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms', () => {
const start = lifeUpdate(initialLife(11), LifeAction.Randomize).cells
const t0 = Date.now()
Array.from({ length: 50 }).reduce((cells) => lifeStep(cells), start)
assert.ok(Date.now() - t0 < 2500, `${Date.now() - t0} ms`)
})
// ---- the fourth kind of input
test('m13 clicks emits the position of every Click input and ignores everything else', () =>
assert.deepStrictEqual(
run(clicks, freeze([time(10), click(3, 4), down('a'), click(0, 479), { tag: 'KeyUp', key: 'a' }])),
[{ x: 3, y: 4 }, { x: 0, y: 479 }]))
test('m13 the other sources ignore clicks', () => {
const inputs = freeze([click(5, 5), time(100), click(6, 6)])
assert.deepStrictEqual(run(keyDowns, inputs), [])
assert.deepStrictEqual(run(keyUps, inputs), [])
assert.deepStrictEqual(run(interval(50), inputs), [50, 100])
assert.deepStrictEqual(run(clampTime(clicks, 250), inputs), [{ x: 5, y: 5 }, { x: 6, y: 6 }])
})
test('m13 a generation every 150 ms', () => assert.equal(GEN_MS, 150))
test('m13 lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles', () =>
assert.deepStrictEqual(
run(lifeActions, freeze([time(150), down(' '), click(20, 40), down('x'), click(700, 10), down('n'), time(450)])),
[LifeAction.Frame, LifeAction.TogglePause, LifeAction.Toggle(cell(1, 2)), LifeAction.Step, LifeAction.Frame,
LifeAction.Frame]))
test('m13 lifeStates starts with the new world, then yields one state per action', () => {
const out = run(lifeStates(7, lifeActions), freeze([time(0), click(16, 16), click(32, 16), click(48, 16), down(' '), time(150)]))
assert.deepStrictEqual(out[0], initialLife(7))
assert.deepStrictEqual(out.map((s) => s.cells.length), [0, 1, 2, 3, 3, 3])
assert.deepStrictEqual(out.at(-1), { cells: [cell(2, 0), cell(2, 1), cell(2, 2)], running: true, seed: 7, generation: 1 })
})
test('m13 lifeMain asks for a seed, draws the empty world first, and then a click as a live cell', () => {
const { ops, result: stream } = trace(lifeMain, 42)
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
const [first, next] = stream.step(time(0))
const [second] = next.step(click(17, 33))
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), lifeView(initialLife(42)))
assert.deepStrictEqual(
trace(second[0], 0).ops.map((o) => o.shape),
lifeView({ ...initialLife(42), cells: [cell(1, 2)] }))
})
test('m13 lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds', () => {
const [, afterFirst] = trace(lifeMain, 42).result.step(time(0))
assert.equal(afterFirst.step(time(10000))[0].length, 1)
})
// ---- the runtime and the browser platform (step 6 and 7 of the module)
const fakePlatform = () => {
const calls = []
const handlers = {}
return {
calls,
fillRect: (...args) => calls.push(['fillRect', ...args]),
fillText: (...args) => calls.push(['fillText', ...args]),
randomSeed: () => 42,
loadBest: () => 0,
saveBest: () => {},
startClock: (cb) => { handlers.time = cb },
onKeyDown: (cb) => { handlers.down = cb },
onKeyUp: (cb) => { handlers.up = cb },
onClick: (cb) => { handlers.click = cb },
fireTime: (t) => handlers.time(t),
fireClick: (x, y) => handlers.click(x, y),
registered: () => Object.keys(handlers).sort(),
}
}
test('m13 runApp registers a click handler with the platform', () => {
const p = fakePlatform()
runApp(pure(mapStream(clicks, () => pure(undefined))), p)
assert.deepStrictEqual(p.registered(), ['click', 'down', 'time', 'up'])
})
test('m13 runApp feeds clicks to the stream', () => {
const p = fakePlatform()
const program = pure(mapStream(clicks, ({ x, y }) => draw({ tag: 'Rect', x, y, w: 1, h: 1, color: '#ffffff' })))
runApp(program, p)
p.fireClick(33, 47)
p.fireClick(0, 2)
assert.deepStrictEqual(p.calls, [['fillRect', 33, 47, 1, 1, '#ffffff'], ['fillRect', 0, 2, 1, 1, '#ffffff']])
})
test('m13 runApp runs Life: a click on the canvas draws the world with that cell alive', () => {
const p = fakePlatform()
runApp(lifeMain, p)
p.fireTime(0)
p.calls.length = 0
p.fireClick(100, 250)
assert.ok(p.calls.some((c) => c[0] === 'fillRect' && c[1] === 6 * 16 + 1 && c[2] === 15 * 16 + 1 && c[5] === '#7cb342'))
})
test('m13 Snake and Pong ignore clicks: their actions and drawings are the same with and without them', () => {
const withClicks = freeze([time(0), click(5, 5), time(25), down('ArrowUp'), click(300, 300), time(200)])
const without = withClicks.filter((i) => i.tag !== 'Click')
assert.deepStrictEqual(run(speedActions, withClicks), run(speedActions, without))
assert.deepStrictEqual(run(pongActions, withClicks), run(pongActions, without))
assert.deepStrictEqual(play(main, 42, withClicks), play(main, 42, without))
})
// A fake canvas shown at half its size (CSS), with a 2-pixel border, 100 px from the left and 50 px from the top.
const fakeCanvas = () => {
const listeners = []
return {
listeners,
width: 640, height: 504, clientWidth: 320, clientHeight: 252, clientLeft: 2, clientTop: 2,
getContext: () => ({}),
getBoundingClientRect: () => ({ left: 100, top: 50, width: 324, height: 256 }),
addEventListener: (type, f) => listeners.push([type, f]),
}
}
const fakeWindow = { addEventListener: () => {}, requestAnimationFrame: () => {}, localStorage: null }
test('m13 browserPlatform listens for clicks on the canvas, not before onClick is called', () => {
const canvas = fakeCanvas()
const platform = browserPlatform(canvas, fakeWindow)
assert.deepStrictEqual(canvas.listeners, [])
platform.onClick(() => {})
assert.deepStrictEqual(canvas.listeners.map(([type]) => type), ['click'])
})
test('m13 browserPlatform converts a click to canvas pixels, through the border and the CSS scaling', () => {
const canvas = fakeCanvas()
const got = []
browserPlatform(canvas, fakeWindow).onClick((x, y) => got.push([x, y]))
const [, listener] = canvas.listeners[0]
listener({ clientX: 100 + 2 + 10, clientY: 50 + 2 + 20 })
listener({ clientX: 100 + 2 + 319.75, clientY: 50 + 2 + 0.4 })
assert.deepStrictEqual(got, [[20, 40], [639, 0]])
})