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,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')
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user