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:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user