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>
92 lines
4.6 KiB
JavaScript
92 lines
4.6 KiB
JavaScript
// 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 }]
|
|
}
|
|
}
|