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

+81
View File
@@ -0,0 +1,81 @@
// Conway's Life (life.html) on every track, judged against the pure model.
import { test, expect, TRACKS } from '../lib/app.mjs'
import { FRAME } from '../lib/clock.mjs'
import { GEN_MS, LCELL } from '../lib/oracle.mjs'
const GREEN = '#7cb342'
const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 640)))
// Live cells as the page drew them: the green squares, back in cell coordinates.
const cellsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Rect' && s.color === GREEN)
.map((s) => ({ x: (s.x - 1) / LCELL, y: (s.y - 1) / LCELL }))
const generationOf = (shapes) => Number(/^Generation (\d+)$/.exec(lastScene(shapes).find((s) => s.tag === 'Text' && s.text.startsWith('Generation')).text)[1])
const rowMajor = (cells) => [...cells].sort((p, q) => p.y - q.y || p.x - q.x)
const beats = (ms, a, b) => Math.floor(b / ms) - Math.floor(a / ms)
const lastFrameTime = (a) => Math.floor(a.clock.now / FRAME) * FRAME - a.clock.t0
for (const track of TRACKS) {
test.describe(`${track}: life`, () => {
test('clicks on a CSS-scaled canvas with a border toggle the cells the model says', async ({ app, page }) => {
const a = await (await app(track, 'life')).start()
// Show the 640 x 504 canvas 1.5 times as large; its 2 px border stays. Clicks must still land on the right cells.
await page.locator('canvas').evaluate((c) => { c.style.width = `${c.width * 1.5}px` })
expect(await page.locator('canvas').evaluate((c) => [c.clientWidth, c.clientHeight, c.clientLeft])).toEqual([960, 756, 2])
await a.expectInSync()
const c = (x, y) => [x * LCELL + 8, y * LCELL + 8] // the centre of cell (x, y)
const clicks = [
...[c(10, 10), c(11, 10), c(12, 10)], // cell centres
[0, 0], // the first pixel of the board: cell (0, 0)
[639, 479], // the last pixel of the board: cell (39, 29)
[5 * LCELL, 7 * LCELL + 15], // the left edge of cell (5, 7), bottom row of its pixels
[6 * LCELL - 1, 7 * LCELL], // the right edge of the same cell, top row: switches it off again
[6 * LCELL, 7 * LCELL], // one pixel further right: cell (6, 7)
[320, 490], // the status strip under the board: no cell
]
for (const [x, y] of clicks) {
await a.click(x, y)
await a.expectInSync(`click at canvas pixel (${x}, ${y})`)
}
await a.advance(200)
const drawn = await a.expectInSync()
const want = rowMajor([{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 11, y: 10 }, { x: 12, y: 10 }, { x: 39, y: 29 }, { x: 6, y: 7 }])
expect(cellsOf(drawn)).toEqual(want)
expect(a.state.cells).toEqual(want)
})
test('N steps, Space runs one generation per 150 ms, C clears, R fills the board as the model says', async ({ app }) => {
const a = await (await app(track, 'life')).start()
for (const x of [10, 11, 12]) await a.click(x * LCELL + 8, 10 * LCELL + 8)
await a.press('n')
const stepped = await a.expectInSync('after N')
expect(cellsOf(stepped)).toEqual([{ x: 11, y: 9 }, { x: 11, y: 10 }, { x: 11, y: 11 }])
expect(generationOf(stepped)).toBe(1)
await a.advance(40)
await a.press(' ')
const startedAt = lastFrameTime(a)
await a.expectInSync('running')
for (let i = 0; i < 8; i++) {
await a.advance(GEN_MS / 2)
const f = await a.expectInSync(`running, ${i}`)
expect(generationOf(f)).toBe(1 + beats(GEN_MS, startedAt, lastFrameTime(a)))
}
await a.press(' ')
const pausedGen = generationOf(await a.expectInSync('paused'))
await a.advance(600)
expect(generationOf(await a.expectInSync('paused for 600 ms'))).toBe(pausedGen)
await a.press('c')
const cleared = await a.expectInSync('after C')
expect(cellsOf(cleared)).toEqual([])
expect(generationOf(cleared)).toBe(0)
await a.press('r')
const filled = await a.expectInSync('after R')
expect(cellsOf(filled)).toEqual(a.state.cells)
expect(cellsOf(filled).length).toBeGreaterThan(100)
await a.press('n')
await a.advance(300)
await a.expectInSync('after R and N')
})
})
}
+75
View File
@@ -0,0 +1,75 @@
// Pong (pong.html) on every track, judged against the pure model.
import { test, expect, TRACKS } from '../lib/app.mjs'
// The player's paddle is the white 8 x 64 rect in column 2 (x = 16); its top row in cells is y / 8.
const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.w === 640)))
const paddleOf = (shapes) => lastScene(shapes).find((s) => s.tag === 'Rect' && s.x === 16 && s.h === 64).y / 8
const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
for (const track of TRACKS) {
test.describe(`${track}: pong`, () => {
test('holding and releasing keys moves the paddle as the model says', async ({ app }) => {
const a = await (await app(track, 'pong')).start()
const p0 = paddleOf(await a.expectInSync())
await a.down('ArrowUp')
await a.advance(300)
const p1 = paddleOf(await a.expectInSync('holding ArrowUp'))
await a.up('ArrowUp')
await a.advance(300)
const p2 = paddleOf(await a.expectInSync('released'))
await a.down('ArrowDown')
await a.advance(600)
const p3 = paddleOf(await a.expectInSync('holding ArrowDown'))
await a.up('ArrowDown')
await a.advance(100)
await a.expectInSync('released again')
// One cell per 30 ms game frame while held, none after release.
expect(p0).toBe(21)
expect(p1).toBeLessThan(p0)
expect(p2).toBe(p1)
expect(p3).toBeGreaterThan(p2)
})
test('a whole match on the fake clock ends with the model\'s score and the right winner text', async ({ app }) => {
const a = await (await app(track, 'pong')).start()
// A simple player: every 90 ms, hold the key that brings the paddle's middle toward the ball.
let held = null
for (let i = 0; i < 1000 && a.state.status === 'Playing'; i++) {
const { ball, player } = a.state
const want = player + 4 < ball.y - 1 ? 'ArrowDown' : player + 4 > ball.y + 1 ? 'ArrowUp' : null
if (want !== held) {
if (held) await a.up(held)
if (want) await a.down(want)
held = want
}
await a.advance(90)
if (i % 10 === 0) await a.expectInSync(`match, ${i * 90} ms`)
}
if (held) await a.up(held)
await a.advance(500)
const end = await a.expectInSync('match over')
const { score } = a.state
expect(a.state.status).toBe('Over')
expect(Math.max(score.player, score.cpu)).toBe(5)
expect(textsOf(end)).toEqual(expect.arrayContaining([`${score.player} ${score.cpu}`,
score.player > score.cpu ? 'You Win!' : 'CPU Wins', 'Press R to restart']))
})
test('R restarts and keeps a held key', async ({ app }) => {
const a = await (await app(track, 'pong')).start()
await a.advance(300)
await a.down('ArrowDown')
await a.advance(150)
const before = paddleOf(await a.expectInSync('holding ArrowDown'))
await a.press('r')
const restarted = paddleOf(await a.expectInSync('after R'))
await a.advance(300)
const after = paddleOf(await a.expectInSync('after R, still holding'))
await a.up('ArrowDown')
await a.expectInSync('released')
expect(before).toBeGreaterThan(21)
expect(restarted).toBe(21)
expect(after).toBeGreaterThan(21)
})
})
}
+58
View File
@@ -0,0 +1,58 @@
// The replay viewer (replay.html) on every track, judged against the pure model. The recording is
// fixtures/replay-sample.json, which the pages fetch and the oracle reads from disk.
import { test, expect, TRACKS } from '../lib/app.mjs'
import { FRAME } from '../lib/clock.mjs'
import { recording, REPLAY_MS } from '../lib/oracle.mjs'
const LAST = recording.actions.length
const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 400)))
const stepOf = (shapes) => {
const t = lastScene(shapes).find((s) => s.tag === 'Text' && s.text.startsWith('step '))
return Number(/^step (\d+) \//.exec(t.text)[1])
}
const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
// Multiples of `ms` in (a, b]: how many beats of a `ms` interval fall between two frames at times a and b.
const beats = (ms, a, b) => Math.floor(b / ms) - Math.floor(a / ms)
// Time of the last animation frame so far, in the page's own clock (ms since its first frame).
const lastFrameTime = (a) => Math.floor(a.clock.now / FRAME) * FRAME - a.clock.t0
for (const track of TRACKS) {
test.describe(`${track}: replay`, () => {
test('Right, Left, Home and End show the model\'s frames', async ({ app }) => {
const a = await (await app(track, 'replay')).start()
expect(stepOf(await a.expectInSync())).toBe(0)
const seen = []
for (const key of ['ArrowRight', 'ArrowRight', 'ArrowRight', 'ArrowLeft', 'End', 'ArrowRight', 'ArrowLeft', 'Home', 'ArrowLeft']) {
await a.press(key)
seen.push(stepOf(await a.expectInSync(`after ${key}`)))
await a.advance(50)
}
await a.expectInSync()
expect(seen).toEqual([1, 2, 3, 2, LAST, LAST, LAST - 1, 0, 0])
expect(textsOf(a.lastPageFrame)).toContain(`step 0 / ${LAST}`)
})
test('autoplay advances one frame per 100 ms of fake time and stops on the last frame', async ({ app }) => {
const a = await (await app(track, 'replay')).start()
await a.advance(130)
await a.press(' ')
const pressedAt = lastFrameTime(a)
expect(textsOf(await a.expectInSync('play'))).toContain('Space: pause')
// For a while, check every 100 ms: the step shown is the number of 100 ms beats since the key.
for (let i = 0; i < 12; i++) {
await a.advance(REPLAY_MS)
expect(stepOf(await a.expectInSync(`playing, ${i}`))).toBe(beats(REPLAY_MS, pressedAt, lastFrameTime(a)))
}
// Then run to the end and beyond, a second at a time; it stops on the last frame.
while (a.state.playing) {
await a.advance(1000)
await a.expectInSync('playing on')
}
await a.advance(1000)
const end = await a.expectInSync('stopped')
expect(stepOf(end)).toBe(LAST)
expect(textsOf(end)).toContain('Left/Right: step Home/End: jump Space: play')
expect(beats(REPLAY_MS, pressedAt, lastFrameTime(a))).toBeGreaterThan(LAST)
})
})
}
+227
View File
@@ -0,0 +1,227 @@
// Snake (index.html) on every track, judged against the pure model: the page must draw, frame for frame, what the
// course's own program draws for the same seed, stored best and inputs.
import { test, expect, TRACKS } from '../lib/app.mjs'
import { framesFrom } from '../lib/drawing.mjs'
import { FRAME } from '../lib/clock.mjs'
import { SEED } from '../lib/seed.mjs'
import { sessionView, newGame, movesEvery, FRAME_MS } from '../lib/oracle.mjs'
import { browserPlatform } from '../../js/solutions/browser.js'
// What the reference glue (js/solutions/browser.js) reads from a stored string: its own loadBest, given a window
// whose localStorage holds `raw`.
// What the reference glue (js/solutions/browser.js) reads from a stored string: its own loadBest, given a window
// whose localStorage holds `raw`. The rule: only plain decimal digits with a value of at most 2147483647 count.
const referenceLoadBest = (raw) =>
browserPlatform({ getContext: () => ({}) }, { localStorage: { getItem: () => raw } }).loadBest()
// Stored strings that saveBest never writes. Number() would read most of them as a number; the glue must not.
const SPELLINGS = ['007', '2147483647', '+7', ' 7', '7 ', '0x10', '1e2', '7.0', '2147483648']
// No known differences between the tracks. If one appears, list it here as {track: {raw: reason}} and report it.
const KNOWN = {}
const BEST_KEY = 'pure-snake-best'
const HEAD = '#7cb342'
const DIRS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
const KEY_OF = { Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight' }
const OPPOSITE = { Up: 'Down', Down: 'Up', Left: 'Right', Right: 'Left' }
// One page frame can hold several scenes (several game frames due in one animation frame); the last one is the picture.
const lastScene = (shapes) => {
const i = shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 400)
return shapes.slice(Math.max(i, 0))
}
const headOf = (shapes) => {
const r = lastScene(shapes).find((s) => s.tag === 'Rect' && s.color === HEAD)
return r ? { x: (r.x - 1) / 20, y: (r.y - 1) / 20 } : null
}
const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
const headMoved = (before) => (s) => s.game.snake[0].x !== before.x || s.game.snake[0].y !== before.y || s.game.status !== 'Playing'
// A greedy player for the scripted game: head for the apple, never into a wall or the body, never reverse.
const choose = (g) => {
const h = g.snake[0]
const body = g.snake.slice(0, -1)
const safe = Object.entries(DIRS).filter(([key, [dx, dy]]) => {
const dir = key.slice(5)
const p = { x: h.x + dx, y: h.y + dy }
return dir !== OPPOSITE[g.dir] && p.x >= 0 && p.y >= 0 && p.x < g.width && p.y < g.height &&
!body.some((b) => b.x === p.x && b.y === p.y)
})
const dist = ([, [dx, dy]]) => Math.abs(h.x + dx - g.apple.x) + Math.abs(h.y + dy - g.apple.y)
const [best] = safe.sort((a, b) => dist(a) - dist(b))
return best ? best[0].slice(5) : g.dir
}
/**
* Play the scripted game until `done(session)`: after every step, press the key the greedy player wants (checking the
* page against the model after the key), then run the clock to the next step (checking again). Returns, per step,
* the score before it and how many game frames it took, counted from the page's frames (each game frame draws once,
* and so does each key that turns the snake).
*/
const playUntil = async (a, done, maxSteps = 200) => {
await a.expectInSync('before the scripted game')
const steps = []
for (let i = 0; i < maxSteps && !done(a.state); i++) {
const g = a.state.game
const want = choose(g)
const seenBefore = a.seen
const keys = want !== g.nextDir ? 1 : 0
if (keys) {
await a.press(KEY_OF[want])
await a.expectInSync(`after ${KEY_OF[want]} at step ${i}`)
}
await a.advanceUntil(headMoved(g.snake[0]))
await a.expectInSync(`step ${i}`)
steps.push({ score: g.score, frames: a.seen - seenBefore - keys })
}
expect(done(a.state), 'the scripted game reached its goal').toBe(true)
return steps
}
for (const track of TRACKS) {
test.describe(`${track}: snake`, () => {
test('first frame equals the new session\'s scene', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
expect(await a.expectInSync()).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 0, improved: false })))
})
test('one step every 200 ms of fake time at score 0', async ({ app, page }) => {
const a = await (await app(track, 'snake')).start()
await a.advance(1000)
await a.expectInSync()
const frames = await framesFrom(page, 0)
const moves = frames.filter((f, i) => i > 0 && JSON.stringify(headOf(f.shapes)) !== JSON.stringify(headOf(frames[i - 1].shapes)))
// A step every movesEvery(0) = 8 game frames of 25 ms, seen on the first animation frame at or after it.
expect(movesEvery(0) * FRAME_MS).toBe(200)
expect(moves.map((f) => f.at - a.clock.t0)).toEqual([200, 400, 600, 800].map((t) => Math.ceil(t / FRAME) * FRAME))
expect(moves.map((f) => headOf(f.shapes).x)).toEqual([11, 12, 13, 14])
})
test('a scripted game eats three apples, matching the model after every key, and speeds up after the third', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
const steps = await playUntil(a, (s) => s.game.score >= 3)
const after = await playUntil(a, (s) => s.game.score >= 4 || s.game.status !== 'Playing', 6)
const all = [...steps, ...after]
// Every step took movesEvery(score) game frames on the page: 8 at scores 0-2, 7 from the third apple on.
expect(all.map((s) => s.frames)).toEqual(all.map((s) => movesEvery(s.score)))
expect(all.filter((s) => s.score === 3).length, 'steps observed at score 3').toBeGreaterThan(0)
expect(textsOf(a.lastPageFrame)).toContain(`Score: ${a.state.game.score}`)
})
test('Up then Left inside one step turns up, then left (turn queue)', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
await a.advanceUntil(headMoved(a.state.game.snake[0]))
const h0 = headOf(await a.expectInSync())
await a.press('ArrowUp')
await a.expectInSync('after ArrowUp')
await a.press('ArrowLeft')
await a.expectInSync('after ArrowLeft')
await a.advanceUntil(headMoved(a.state.game.snake[0]))
const h1 = headOf(await a.expectInSync('first step'))
await a.advanceUntil(headMoved(a.state.game.snake[0]))
const h2 = headOf(await a.expectInSync('second step'))
expect([h1, h2]).toEqual([{ x: h0.x, y: h0.y - 1 }, { x: h0.x - 1, y: h0.y - 1 }])
})
test('Space shows Paused and freezes the scene, Space resumes', async ({ app, page }) => {
const a = await (await app(track, 'snake')).start()
await a.advance(300)
await a.press(' ')
const frozen = await a.expectInSync('paused')
expect(textsOf(frozen)).toEqual(expect.arrayContaining(['Paused', 'Press Space to resume']))
const from = a.seen
await a.advance(1000)
await a.expectInSync('paused for 1 s')
const during = await framesFrom(page, from)
expect(during.length, 'the page keeps drawing while paused').toBeGreaterThan(30)
for (const f of during) expect(f.shapes).toEqual(during[0].shapes)
expect(headOf(during.at(-1).shapes)).toEqual(headOf(frozen))
await a.press(' ')
await a.advance(400)
const resumed = await a.expectInSync('resumed')
expect(textsOf(resumed)).not.toContain('Paused')
expect(headOf(resumed)).not.toEqual(headOf(frozen))
})
test('a crash shows Game Over, R restarts', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
await a.advanceUntil((s) => s.game.status === 'GameOver')
const over = await a.expectInSync('crashed')
expect(textsOf(over)).toEqual(expect.arrayContaining(['Game Over', 'Press R to restart']))
await a.advance(500)
await a.expectInSync('after the crash')
// Restart starts a new game from the game's current seed (which every apple has moved on), not from SEED.
const seed = a.state.game.seed
await a.press('r')
const again = await a.expectInSync('after R')
expect(lastScene(again)).toEqual(await a.expected(sessionView({ game: newGame(seed), best: a.state.best, improved: false })))
await a.advance(500)
await a.expectInSync('playing again')
})
test('a 10 s jump of the fake clock in one frame advances the game by 250 ms only', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
await a.advance(500)
const h1 = headOf(await a.expectInSync())
await a.jump(10_000)
const h2 = headOf(await a.expectInSync('after the jump'))
// 250 ms are 10 game frames: one or two steps. Unclamped, 10 s would be 50 steps: into the wall.
expect(Math.abs(h2.x - h1.x) + Math.abs(h2.y - h1.y)).toBeGreaterThanOrEqual(1)
expect(Math.abs(h2.x - h1.x) + Math.abs(h2.y - h1.y)).toBeLessThanOrEqual(2)
expect(a.state.game.status).toBe('Playing')
await a.advance(400)
await a.expectInSync('after the jump, running on')
})
test('the best score is saved on improvement and shown after a reload; a stored best of 37 too', async ({ app, page }) => {
const a = await (await app(track, 'snake')).start()
await playUntil(a, (s) => s.game.score >= 1)
expect(a.model.saves).toEqual([1])
expect(await page.evaluate((k) => localStorage.getItem(k), BEST_KEY)).toBe('1')
await a.reload({ best: 1 })
const shown = await a.expectInSync('after reload')
expect(shown).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 1, improved: false })))
expect(textsOf(shown)).toContain('Best: 1')
await page.evaluate((k) => localStorage.setItem(k, '37'), BEST_KEY)
await a.reload({ best: 37 })
expect(textsOf(await a.expectInSync('stored 37'))).toContain('Best: 37')
await a.advance(300)
await a.expectInSync('stored 37, running')
})
test('garbage in localStorage reads as Best: 0 and the game runs', async ({ app, page }) => {
const a = await (await app(track, 'snake')).start()
for (const raw of ['banana', '-5', '3.5', 'NaN', '1e999', '99999999999999999999', '']) {
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BEST_KEY, raw])
await a.reload({ best: 0 })
expect(textsOf(await a.expectInSync(`stored ${JSON.stringify(raw)}`))).toContain('Best: 0')
await a.advance(250)
await a.expectInSync(`stored ${JSON.stringify(raw)}, running`)
}
})
for (const raw of SPELLINGS) {
test(`a stored best spelled ${JSON.stringify(raw)} reads as the reference glue reads it`, async ({ app, page }) => {
test.fail(Boolean(KNOWN[track]?.[raw]), KNOWN[track]?.[raw])
const want = referenceLoadBest(raw)
await page.addInitScript(([k, v]) => localStorage.setItem(k, v), [BEST_KEY, raw])
const a = await (await app(track, 'snake')).start({ best: want })
expect(textsOf(await a.expectInSync(`stored ${JSON.stringify(raw)}`))).toContain(`Best: ${want}`)
})
}
test('a page whose localStorage throws still runs, shows Best: 0, and survives a new best', async ({ app, page }) => {
await page.addInitScript(() => {
Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw new DOMException('blocked by the test', 'SecurityError') } })
})
const a = await (await app(track, 'snake')).start()
expect(textsOf(await a.expectInSync())).toContain('Best: 0')
await playUntil(a, (s) => s.game.score >= 1)
expect(a.model.saves).toEqual([1])
expect(textsOf(a.lastPageFrame)).toContain('Best: 1')
await a.advance(300)
await a.expectInSync('running after the failed save')
})
})
}
+69
View File
@@ -0,0 +1,69 @@
// 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'])
})