Files
functional-programming-snake/e2e/tests/techniques.spec.mjs
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

70 lines
4.0 KiB
JavaScript

// Each of the three techniques the other specs build on, proven on its own, on every track; and the oracle checked
// against the course's own `play`.
import { test, expect, TRACKS } from '../lib/app.mjs'
import { seedCalls, SEED } from '../lib/seed.mjs'
import { framesFrom, frameCount } from '../lib/drawing.mjs'
import { FRAME } from '../lib/clock.mjs'
import { Model, sessionView, newGame } from '../lib/oracle.mjs'
for (const track of TRACKS) {
test.describe(`${track}: techniques`, () => {
test('fixed seed: the page asks for one Uint32Array(1) and starts the game that seed makes', async ({ app }) => {
const a = await app(track, 'snake')
await a.start()
expect(await seedCalls(a.page)).toEqual([{ type: 'Uint32Array', length: 1 }])
// The apple of the first frame is where newGame(SEED) puts it; another seed would (almost surely) put it elsewhere.
const apple = (await a.expectInSync()).find((s) => s.tag === 'Rect' && s.color === '#e53935')
const want = newGame(SEED).apple
expect(apple).toMatchObject({ x: want.x * 20 + 1, y: want.y * 20 + 1 })
expect(newGame(SEED + 1).apple).not.toEqual(want)
})
test('fake clock: frames come only when the test moves the clock, with the timestamps the clock arithmetic predicts', async ({ app, page }) => {
const a = await app(track, 'snake')
await a.start()
// Paused: real time passing changes nothing.
const before = await frameCount(page)
await page.waitForTimeout(300)
expect(await frameCount(page)).toBe(before)
// 1000 ms of fake time: the page draws once per 25 ms game frame, and each drawing happens on the first
// animation frame (a multiple of 16 ms after the first) at or after that game frame's time.
const startTick = a.clock.now
await a.advance(1000)
expect(await a.clock.ticks()).toBe(startTick + 1000)
const frames = await framesFrom(page, 0)
const times = frames.map((f) => f.at - a.clock.t0)
const due = Array.from({ length: 40 }, (_, i) => (i + 1) * 25).map((g) => Math.ceil(g / FRAME) * FRAME).filter((t) => t <= 1000)
expect(times).toEqual([0, ...due])
// And the model, fed the Time events of the same arithmetic, drew exactly the same frames.
await a.expectInSync()
// One long frame: fastForward fires one animation frame, 10 s later.
await a.jump(10_000)
const [last] = (await framesFrom(page, frames.length)).slice(-1)
expect(last.at - a.clock.t0).toBe(1000 + 10_000)
await a.expectInSync()
})
test('recorded drawing: the first frame is the new session\'s scene, with colours, fonts and alignment as drawn', async ({ app, page }) => {
const a = await app(track, 'snake')
await a.start()
const [first] = await framesFrom(page, 0)
// Read straight from the recorder, before any normalisation: what the canvas reported at each call.
const text = first.shapes.find((s) => s.tag === 'Text' && s.text.startsWith('Best'))
expect(text).toMatchObject({ text: 'Best: 0', color: '#ffffff', align: 'right', x: 394, y: 18 })
expect(text.font).toMatch(/16px monospace/)
expect(first.shapes[0]).toEqual({ tag: 'Rect', x: 0, y: 0, w: 400, h: 400, color: '#111111' })
expect(await a.expectInSync()).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 0, improved: false })))
})
})
}
test('oracle: fed input by input, the model emits exactly what the course\'s play() emits for the same inputs', () => {
const m = new Model('snake', { seed: SEED, best: 4 })
const times = Array.from({ length: 80 }, (_, i) => ({ tag: 'Time', t: i * 16 }))
m.feed(times.slice(0, 30)).feed([{ tag: 'KeyDown', key: 'ArrowUp' }, { tag: 'KeyUp', key: 'ArrowUp' }]).feed(times.slice(30))
m.feed([{ tag: 'Time', t: 80 * 16 + 10_000 }])
const drawn = m.play().flat().filter((op) => op.tag === 'Draw').map((op) => op.shape)
expect(m.frames.flatMap((f) => f.shapes)).toEqual(drawn)
expect(m.startOps.map((op) => op.tag)).toEqual(['RandomSeed', 'LoadBest'])
})