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

+91
View File
@@ -0,0 +1,91 @@
// Technique 2: a fake clock.
// Playwright's page.clock replaces requestAnimationFrame, performance.now, Date and the timers in the page. Paused,
// it moves only when a test calls runFor or fastForward, so the game sees exactly the time the test chooses.
//
// How fake time becomes the model's `Time t` events (worked out from Playwright 1.65's clock and the three glues):
// - The fake clock counts integer milliseconds ("ticks"). A requestAnimationFrame callback is due at the next
// multiple of 16 ticks after the request (`16 - ticks % 16` later) and receives the tick count as its timestamp.
// - Each track's glue (js/solutions/browser.js, scala/web/BrowserPlatform.scala, fsharp/web/BrowserPlatform.fs)
// remembers the timestamp T0 of its first callback and feeds `Time(T - T0)` for every callback at tick T, and asks
// for the next frame inside the callback (so the next one is due 16 ticks later). Scala and F# truncate to a whole
// number; fake timestamps are whole numbers already, so all three see the same values.
// - Hence: the first frame is Time 0, and from a moment at tick N, `runFor(ms)` fires one Time(T - T0) for every
// multiple of 16 T in (N, N + ms], in order; `fastForward(ms)` fires the single pending frame once, at N + ms
// (Time(N + ms - T0)) — that is how a test makes one long frame, like a tab that was hidden.
// - Keys and clicks sent between two clock calls reach the page after every frame up to the current tick, so the
// model is fed them in that order.
// This class mirrors that arithmetic and returns, for each move of the clock, the exact list of Time events the page
// received; the oracle is fed those. The technique test (tests/techniques.spec.mjs) checks the mapping against the
// timestamps of the recorded frames on every track, so a change in Playwright's clock shows up there first.
/** Interval of Playwright's fake requestAnimationFrame, in ms. */
export const FRAME = 16
export class FakeClock {
constructor(page) {
this.page = page
this.now = null // current tick, known after start()
this.t0 = null // tick of the page's first animation frame
}
/** Install a paused clock for every document the page loads from now on. Call before page.goto. */
async install() {
await this.page.clock.install({ time: new Date('2026-01-01T00:00:00Z') })
await this.page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'))
}
/** The tick the page's clock shows now. */
ticks() {
return this.page.evaluate(() => performance.now())
}
/**
* Step the clock one frame at a time until the page has drawn its first frame (`drawn()` returns that frame's tick,
* or null while nothing is drawn). Before that, the page may still be loading its script or the replay viewer its
* recording; frames that fire before the program asked for one do not exist for it. Returns [Time 0].
*/
async start(drawn, tries = 200) {
this.now = await this.ticks()
for (let i = 0; i < tries; i++) {
const at = await drawn()
if (at !== null) {
if (this.now !== at) throw new Error(`fake clock: expected to stop on the first frame (tick ${at}), but it shows ${this.now}`)
this.t0 = at
return [{ tag: 'Time', t: 0 }]
}
// Up to the next multiple of 16, where a frame fires if the program has asked for one.
const step = FRAME - (this.now % FRAME)
await this.page.clock.runFor(step)
this.now += step
}
throw new Error('the page drew nothing within 200 animation frames of fake time')
}
/** The Time events the page receives while the clock runs from tick `from` to tick `to` frame by frame. */
timesBetween(from, to) {
const first = Math.floor(from / FRAME) + 1
const last = Math.floor(to / FRAME)
return Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => ({ tag: 'Time', t: (first + i) * FRAME - this.t0 }))
}
/** Let `ms` of fake time pass, one animation frame every 16 ms. Returns the Time events the page received. */
async advance(ms) {
const events = this.timesBetween(this.now, this.now + ms)
await this.page.clock.runFor(ms)
this.now += ms
return events
}
/** Advance to the next tick that is a multiple of 16 after `this.now + ms` (inclusive), so a frame fires there. */
async advanceToFrameAfter(ms) {
const target = Math.ceil((this.now + ms) / FRAME) * FRAME
return this.advance(target - this.now)
}
/** Jump `ms` ahead in one go: the pending animation frame fires once, at the end. Returns that one Time event. */
async jump(ms) {
await this.page.clock.fastForward(ms)
this.now += ms
return [{ tag: 'Time', t: this.now - this.t0 }]
}
}