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

228 lines
12 KiB
JavaScript

// 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')
})
})
}