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
+186
@@ -0,0 +1,186 @@
|
||||
// The test fixture every spec uses: one page of one track, with the fixed seed, the paused fake clock and the drawing
|
||||
// recorder installed, a Model fed exactly the inputs the page received, and a check that compares the two.
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { fixSeed, SEED } from './seed.mjs'
|
||||
import { FakeClock } from './clock.mjs'
|
||||
import { recordDrawing, framesFrom, canvasColors, pageShape, expectedShape } from './drawing.mjs'
|
||||
import { Model, programs } from './oracle.mjs'
|
||||
|
||||
/** The tracks under test: E2E_TRACKS=js,scala,fsharp (scripts/e2e.sh sets it from --tracks). */
|
||||
export const TRACKS = (process.env.E2E_TRACKS ?? 'js,scala,fsharp').split(',').filter(Boolean)
|
||||
|
||||
// Playwright's key names for the DOM `key` values the programs listen to.
|
||||
const KEY_NAMES = { ' ': 'Space' }
|
||||
const keyName = (key) => KEY_NAMES[key] ?? key
|
||||
|
||||
const describeShape = (s) => (s ? JSON.stringify(s) : 'nothing')
|
||||
|
||||
export class App {
|
||||
constructor(page, track, name, { seed = SEED, best = 0 } = {}) {
|
||||
this.page = page
|
||||
this.track = track
|
||||
this.name = name
|
||||
this.seed = seed
|
||||
this.best = best
|
||||
this.clock = new FakeClock(page)
|
||||
this.colors = new Map()
|
||||
this.errors = []
|
||||
page.on('pageerror', (e) => this.errors.push(`page error: ${e.message}`))
|
||||
page.on('console', (m) => { if (m.type() === 'error') this.errors.push(`console error: ${m.text()}`) })
|
||||
}
|
||||
|
||||
get url() {
|
||||
return `/${this.track}/${programs[this.name].page}`
|
||||
}
|
||||
|
||||
/** Install the helpers and open the page; nothing is drawn until start(). */
|
||||
async open() {
|
||||
await fixSeed(this.page, this.seed)
|
||||
await recordDrawing(this.page)
|
||||
await this.clock.install()
|
||||
await this.page.goto(this.url)
|
||||
}
|
||||
|
||||
/** Run the clock to the page's first frame and set up a fresh Model with `best` as the stored best. */
|
||||
async start({ best = this.best } = {}) {
|
||||
this.best = best
|
||||
this.model = new Model(this.name, { seed: this.seed, best })
|
||||
this.seen = 0 // page frames already compared with the model
|
||||
this.lastPageFrame = null
|
||||
const first = await this.clock.start(async () => {
|
||||
const [f] = await framesFrom(this.page, 0)
|
||||
return f ? f.at : null
|
||||
})
|
||||
this.model.feed(first)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Reload the page (its storage stays), then start again with `best` as the best the page should load. */
|
||||
async reload({ best } = {}) {
|
||||
await this.page.reload()
|
||||
return this.start({ best })
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.model.state
|
||||
}
|
||||
|
||||
async advance(ms) {
|
||||
this.model.feed(await this.clock.advance(ms))
|
||||
}
|
||||
|
||||
/** Advance one animation frame at a time until `done(state)` holds in the model (checked by peeking ahead), and
|
||||
* stop on the first frame where it does. Returns the fake ms that passed. */
|
||||
async advanceUntil(done, limitMs = 60000) {
|
||||
const from = this.clock.now
|
||||
const ms = (() => {
|
||||
for (let n = 1; n * 16 <= limitMs; n++) {
|
||||
if (done(this.model.peek(this.clock.timesBetween(from, from + n * 16)))) return n * 16
|
||||
}
|
||||
throw new Error(`advanceUntil: the model never got there within ${limitMs} ms`)
|
||||
})()
|
||||
await this.advance(ms)
|
||||
return ms
|
||||
}
|
||||
|
||||
async jump(ms) {
|
||||
this.model.feed(await this.clock.jump(ms))
|
||||
}
|
||||
|
||||
async press(key) {
|
||||
await this.page.keyboard.press(keyName(key))
|
||||
this.model.feed([{ tag: 'KeyDown', key }, { tag: 'KeyUp', key }])
|
||||
}
|
||||
|
||||
async down(key) {
|
||||
await this.page.keyboard.down(keyName(key))
|
||||
this.model.feed([{ tag: 'KeyDown', key }])
|
||||
}
|
||||
|
||||
async up(key) {
|
||||
await this.page.keyboard.up(keyName(key))
|
||||
this.model.feed([{ tag: 'KeyUp', key }])
|
||||
}
|
||||
|
||||
/**
|
||||
* A real mouse click on canvas pixel (px, py), wherever and however large the page shows the canvas: the window
|
||||
* position is the middle of that pixel, through the canvas's bounding box, border and CSS scale. The model is fed a
|
||||
* Click at (px, py), so the page's own conversion from window to canvas coordinates is what is under test.
|
||||
*/
|
||||
async click(px, py) {
|
||||
const box = await this.page.locator('canvas').evaluate((c) => {
|
||||
const r = c.getBoundingClientRect()
|
||||
return { left: r.left + c.clientLeft, top: r.top + c.clientTop, sx: c.clientWidth / c.width, sy: c.clientHeight / c.height }
|
||||
})
|
||||
await this.page.mouse.click(box.left + (px + 0.5) * box.sx, box.top + (py + 0.5) * box.sy)
|
||||
this.model.feed([{ tag: 'Click', x: px, y: py }])
|
||||
}
|
||||
|
||||
async color(c) {
|
||||
if (!this.colors.has(c)) this.colors.set(c, (await canvasColors(this.page, [c]))[0])
|
||||
return this.colors.get(c)
|
||||
}
|
||||
|
||||
/**
|
||||
* The check every test makes: every frame the page has drawn since the last check equals, shape by shape and in
|
||||
* order, what the model drew for the corresponding input; and the page has drawn exactly as many frames as the
|
||||
* model. Returns the page's last frame (shapes normalised for comparison).
|
||||
*/
|
||||
async expectInSync(label = '') {
|
||||
const fresh = await framesFrom(this.page, this.seen)
|
||||
const modelFrames = this.model.frames
|
||||
const colors = [...new Set(modelFrames.slice(this.seen).flatMap((f) => f.shapes.map((s) => s.color)))]
|
||||
await Promise.all(colors.map((c) => this.color(c)))
|
||||
const color = (c) => this.colors.get(c)
|
||||
const where = `${this.track} ${this.name}${label ? `, ${label}` : ''}`
|
||||
for (const [i, frame] of fresh.entries()) {
|
||||
const n = this.seen + i
|
||||
const want = modelFrames[n]
|
||||
const got = frame.shapes.map(pageShape)
|
||||
const t = frame.at - this.clock.t0
|
||||
if (!want) {
|
||||
throw new Error(`${where}: the page drew frame #${n} (at ${t} ms) but the model drew only ${modelFrames.length} frames. ` +
|
||||
`Page frame: ${got.length} shapes, first ${describeShape(got[0])}`)
|
||||
}
|
||||
const exp = want.shapes.map((s) => expectedShape(s, color))
|
||||
const j = exp.findIndex((s, k) => JSON.stringify(s) !== JSON.stringify(got[k]))
|
||||
if (j >= 0 || exp.length !== got.length) {
|
||||
const k = j >= 0 ? j : Math.min(exp.length, got.length)
|
||||
throw new Error(`${where}: frame #${n} (page at ${t} ms; model input ${JSON.stringify(want.input)}) differs at shape ${k}:\n` +
|
||||
` page drew: ${describeShape(got[k])}\n model says: ${describeShape(exp[k])}\n` +
|
||||
` (page ${got.length} shapes, model ${exp.length})`)
|
||||
}
|
||||
}
|
||||
this.seen += fresh.length
|
||||
if (fresh.length > 0) this.lastPageFrame = fresh[fresh.length - 1].shapes.map(pageShape)
|
||||
expect(this.seen, `${where}: frames drawn by the page vs by the model`).toBe(modelFrames.length)
|
||||
return this.lastPageFrame
|
||||
}
|
||||
|
||||
/** The texts of the page's last checked frame. */
|
||||
texts() {
|
||||
return (this.lastPageFrame ?? []).filter((s) => s.tag === 'Text').map((s) => s.text)
|
||||
}
|
||||
|
||||
/** Model shapes normalised the way the page's are, for direct comparisons with a scene built in the test. */
|
||||
async expected(scene) {
|
||||
await Promise.all(scene.map((s) => this.color(s.color)))
|
||||
return scene.map((s) => expectedShape(s, (c) => this.colors.get(c)))
|
||||
}
|
||||
}
|
||||
|
||||
/** `app(track, name, options)` opens a page and returns its App; the test fails if the page logged any error. */
|
||||
export const test = base.extend({
|
||||
app: async ({ page }, use) => {
|
||||
const apps = []
|
||||
await use(async (track, name, options) => {
|
||||
const app = new App(page, track, name, options)
|
||||
apps.push(app)
|
||||
await app.open()
|
||||
return app
|
||||
})
|
||||
for (const app of apps) expect(app.errors, `${app.track} ${app.name}: page and console errors`).toEqual([])
|
||||
},
|
||||
})
|
||||
|
||||
export { expect }
|
||||
@@ -0,0 +1,91 @@
|
||||
// Technique 2: a fake clock.
|
||||
// Playwright's page.clock replaces requestAnimationFrame, performance.now, Date and the timers in the page. Paused,
|
||||
// it moves only when a test calls runFor or fastForward, so the game sees exactly the time the test chooses.
|
||||
//
|
||||
// How fake time becomes the model's `Time t` events (worked out from Playwright 1.65's clock and the three glues):
|
||||
// - The fake clock counts integer milliseconds ("ticks"). A requestAnimationFrame callback is due at the next
|
||||
// multiple of 16 ticks after the request (`16 - ticks % 16` later) and receives the tick count as its timestamp.
|
||||
// - Each track's glue (js/solutions/browser.js, scala/web/BrowserPlatform.scala, fsharp/web/BrowserPlatform.fs)
|
||||
// remembers the timestamp T0 of its first callback and feeds `Time(T - T0)` for every callback at tick T, and asks
|
||||
// for the next frame inside the callback (so the next one is due 16 ticks later). Scala and F# truncate to a whole
|
||||
// number; fake timestamps are whole numbers already, so all three see the same values.
|
||||
// - Hence: the first frame is Time 0, and from a moment at tick N, `runFor(ms)` fires one Time(T - T0) for every
|
||||
// multiple of 16 T in (N, N + ms], in order; `fastForward(ms)` fires the single pending frame once, at N + ms
|
||||
// (Time(N + ms - T0)) — that is how a test makes one long frame, like a tab that was hidden.
|
||||
// - Keys and clicks sent between two clock calls reach the page after every frame up to the current tick, so the
|
||||
// model is fed them in that order.
|
||||
// This class mirrors that arithmetic and returns, for each move of the clock, the exact list of Time events the page
|
||||
// received; the oracle is fed those. The technique test (tests/techniques.spec.mjs) checks the mapping against the
|
||||
// timestamps of the recorded frames on every track, so a change in Playwright's clock shows up there first.
|
||||
|
||||
/** Interval of Playwright's fake requestAnimationFrame, in ms. */
|
||||
export const FRAME = 16
|
||||
|
||||
export class FakeClock {
|
||||
constructor(page) {
|
||||
this.page = page
|
||||
this.now = null // current tick, known after start()
|
||||
this.t0 = null // tick of the page's first animation frame
|
||||
}
|
||||
|
||||
/** Install a paused clock for every document the page loads from now on. Call before page.goto. */
|
||||
async install() {
|
||||
await this.page.clock.install({ time: new Date('2026-01-01T00:00:00Z') })
|
||||
await this.page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'))
|
||||
}
|
||||
|
||||
/** The tick the page's clock shows now. */
|
||||
ticks() {
|
||||
return this.page.evaluate(() => performance.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the clock one frame at a time until the page has drawn its first frame (`drawn()` returns that frame's tick,
|
||||
* or null while nothing is drawn). Before that, the page may still be loading its script or the replay viewer its
|
||||
* recording; frames that fire before the program asked for one do not exist for it. Returns [Time 0].
|
||||
*/
|
||||
async start(drawn, tries = 200) {
|
||||
this.now = await this.ticks()
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const at = await drawn()
|
||||
if (at !== null) {
|
||||
if (this.now !== at) throw new Error(`fake clock: expected to stop on the first frame (tick ${at}), but it shows ${this.now}`)
|
||||
this.t0 = at
|
||||
return [{ tag: 'Time', t: 0 }]
|
||||
}
|
||||
// Up to the next multiple of 16, where a frame fires if the program has asked for one.
|
||||
const step = FRAME - (this.now % FRAME)
|
||||
await this.page.clock.runFor(step)
|
||||
this.now += step
|
||||
}
|
||||
throw new Error('the page drew nothing within 200 animation frames of fake time')
|
||||
}
|
||||
|
||||
/** The Time events the page receives while the clock runs from tick `from` to tick `to` frame by frame. */
|
||||
timesBetween(from, to) {
|
||||
const first = Math.floor(from / FRAME) + 1
|
||||
const last = Math.floor(to / FRAME)
|
||||
return Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => ({ tag: 'Time', t: (first + i) * FRAME - this.t0 }))
|
||||
}
|
||||
|
||||
/** Let `ms` of fake time pass, one animation frame every 16 ms. Returns the Time events the page received. */
|
||||
async advance(ms) {
|
||||
const events = this.timesBetween(this.now, this.now + ms)
|
||||
await this.page.clock.runFor(ms)
|
||||
this.now += ms
|
||||
return events
|
||||
}
|
||||
|
||||
/** Advance to the next tick that is a multiple of 16 after `this.now + ms` (inclusive), so a frame fires there. */
|
||||
async advanceToFrameAfter(ms) {
|
||||
const target = Math.ceil((this.now + ms) / FRAME) * FRAME
|
||||
return this.advance(target - this.now)
|
||||
}
|
||||
|
||||
/** Jump `ms` ahead in one go: the pending animation frame fires once, at the end. Returns that one Time event. */
|
||||
async jump(ms) {
|
||||
await this.page.clock.fastForward(ms)
|
||||
this.now += ms
|
||||
return [{ tag: 'Time', t: this.now - this.t0 }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Technique 3: recorded drawing.
|
||||
// The glue of every track draws a Rect shape as `fillStyle = color; fillRect(x, y, w, h)` and a Text shape as
|
||||
// `fillStyle = color; font = "<size>px monospace"; textAlign = align; fillText(text, x, y)`. This init script wraps
|
||||
// fillRect and fillText on CanvasRenderingContext2D.prototype before any page script runs and records each call with
|
||||
// the fill style, font and alignment in force at the moment of the call, read back from the context (so colours and
|
||||
// fonts are recorded the way the canvas reports them, e.g. "#7cb342" and "16px monospace").
|
||||
//
|
||||
// Frames. A frame is everything drawn in one go: in all three runtimes one input (an animation frame, a key, a click)
|
||||
// is fed to the program and every drawing it produces is performed synchronously before the callback returns. So the
|
||||
// first call after a quiet spell opens a frame (stamped with the fake `performance.now()` of that moment) and a
|
||||
// queued microtask, which runs as soon as the callback has returned, closes it. One frame of the page therefore
|
||||
// corresponds to the drawings the model produces for one input; if an input produces several drawings (several game
|
||||
// frames due in one animation frame), they are all in that one page frame, in order.
|
||||
|
||||
/** Runs in the page. */
|
||||
const recorderScript = () => {
|
||||
const frames = []
|
||||
let open = null
|
||||
const current = () => {
|
||||
if (open === null) {
|
||||
open = { at: performance.now(), shapes: [] }
|
||||
frames.push(open)
|
||||
queueMicrotask(() => { open = null })
|
||||
}
|
||||
return open
|
||||
}
|
||||
const proto = CanvasRenderingContext2D.prototype
|
||||
const fillRect = proto.fillRect
|
||||
const fillText = proto.fillText
|
||||
proto.fillRect = function (x, y, w, h) {
|
||||
current().shapes.push({ tag: 'Rect', x, y, w, h, color: this.fillStyle })
|
||||
return fillRect.call(this, x, y, w, h)
|
||||
}
|
||||
proto.fillText = function (text, x, y, ...rest) {
|
||||
current().shapes.push({ tag: 'Text', x, y, text: String(text), color: this.fillStyle, font: this.font, align: this.textAlign })
|
||||
return fillText.call(this, text, x, y, ...rest)
|
||||
}
|
||||
Object.defineProperty(window, '__e2eFrames', { value: frames })
|
||||
}
|
||||
|
||||
/** Install the recorder for every document the page loads from now on. */
|
||||
export const recordDrawing = (page) => page.addInitScript(recorderScript)
|
||||
|
||||
/** The frames recorded in the current document from index `from` on: [{at, shapes}]. */
|
||||
export const framesFrom = (page, from = 0) => page.evaluate((i) => (window.__e2eFrames ?? []).slice(i), from)
|
||||
|
||||
/** How many frames the current document has recorded. */
|
||||
export const frameCount = (page) => page.evaluate(() => (window.__e2eFrames ?? []).length)
|
||||
|
||||
/** Ask the page's canvas how it reports each colour (fillStyle normalises "#ABC", names and so on). */
|
||||
export const canvasColors = (page, colors) => page.evaluate((cs) => {
|
||||
const ctx = document.createElement('canvas').getContext('2d')
|
||||
return cs.map((c) => { ctx.fillStyle = '#000000'; ctx.fillStyle = c; return ctx.fillStyle })
|
||||
}, colors)
|
||||
|
||||
/** A recorded Text shape, with its font split into size and family, so it can be compared with a model shape. */
|
||||
export const pageShape = (s) => {
|
||||
if (s.tag !== 'Text') return s
|
||||
const m = /^\s*(?:\S+\s+)*?(\d+(?:\.\d+)?)px\s+(.+?)\s*$/.exec(s.font)
|
||||
return { tag: 'Text', x: s.x, y: s.y, text: s.text, color: s.color, size: m ? Number(m[1]) : s.font, family: m ? m[2] : s.font, align: s.align }
|
||||
}
|
||||
|
||||
/** A model shape as the page should have recorded it; `color` maps a model colour to the canvas's spelling. */
|
||||
export const expectedShape = (s, color) =>
|
||||
s.tag === 'Rect'
|
||||
? { tag: 'Rect', x: s.x, y: s.y, w: s.w, h: s.h, color: color(s.color) }
|
||||
: { tag: 'Text', x: s.x, y: s.y, text: s.text, color: color(s.color), size: s.size, family: 'monospace', align: s.align }
|
||||
@@ -0,0 +1,106 @@
|
||||
// The oracle: the pure JS solutions, run in Node, say what a page must draw for a given seed, stored best and list of
|
||||
// inputs. Nothing here touches a browser.
|
||||
//
|
||||
// A program (`main`, `pongMain`, `replayMain(recording)`, `lifeMain`) is an IO that yields a stream; `trace` answers
|
||||
// its RandomSeed and LoadBest ops with the test's seed and best, exactly as the course's `play(main, seed, events,
|
||||
// best)` does (js/solutions/app.js). The Model then steps that stream one input at a time and traces each IO it emits,
|
||||
// so it knows, per input, which shapes are drawn and which scores are saved. Flattened, its output equals `play`'s
|
||||
// (tests/techniques.spec.mjs checks that); grouped per input, it lines up with the page's recorded frames.
|
||||
//
|
||||
// Next to the program, the Model steps a stream of the program's *states*, built from the same pieces as the program
|
||||
// (e.g. `clampTime(sessions(seed, best, speedActions), MAX_GAP_MS)` for Snake). It is used only to plan inputs and to
|
||||
// state assertions in game terms ("the head moved one cell"); every judgement of the page is made on its drawing.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { trace } from '../../js/solutions/io.js'
|
||||
import { clampTime } from '../../js/solutions/stream.js'
|
||||
import { main, play, sessions, sessionView, speedActions, MAX_GAP_MS, FRAME_MS } from '../../js/solutions/app.js'
|
||||
import { movesEvery } from '../../js/solutions/game.js'
|
||||
import { newGame } from '../../js/solutions/spawn.js'
|
||||
import { pongMain, pongStates, pongActions, FRAME_MS_PONG } from '../../js/solutions/pong/app.js'
|
||||
import { pongView } from '../../js/solutions/pong/view.js'
|
||||
import { replayMain, viewerStates, replayActions, viewerView, frames as replayFrames, REPLAY_MS } from '../../js/solutions/replay.js'
|
||||
import { lifeMain, lifeStates, lifeActions, GEN_MS } from '../../js/solutions/life/app.js'
|
||||
import { lifeView } from '../../js/solutions/life/view.js'
|
||||
import { LCELL, LW, LH } from '../../js/solutions/life/model.js'
|
||||
|
||||
export const recording = JSON.parse(readFileSync(new URL('../../fixtures/replay-sample.json', import.meta.url), 'utf8'))
|
||||
|
||||
/** The four pages of a track, the program each runs, and the matching stream of states. */
|
||||
export const programs = {
|
||||
snake: {
|
||||
page: 'index.html',
|
||||
main: () => main,
|
||||
states: (seed, best) => clampTime(sessions(seed, best, speedActions), MAX_GAP_MS),
|
||||
},
|
||||
pong: {
|
||||
page: 'pong.html',
|
||||
main: () => pongMain,
|
||||
states: (seed) => clampTime(pongStates(seed, pongActions), MAX_GAP_MS),
|
||||
},
|
||||
replay: {
|
||||
page: 'replay.html',
|
||||
main: () => replayMain(recording),
|
||||
states: () => clampTime(viewerStates(replayFrames(recording).length - 1, replayActions), MAX_GAP_MS),
|
||||
},
|
||||
life: {
|
||||
page: 'life.html',
|
||||
main: () => lifeMain,
|
||||
states: (seed) => clampTime(lifeStates(seed, lifeActions), MAX_GAP_MS),
|
||||
},
|
||||
}
|
||||
|
||||
export class Model {
|
||||
constructor(name, { seed, best = 0 }) {
|
||||
const program = programs[name]
|
||||
this.name = name
|
||||
this.seed = seed
|
||||
this.best = best
|
||||
const traced = trace(program.main(), seed, best)
|
||||
this.startOps = traced.ops // RandomSeed, LoadBest: what the program asks before it starts
|
||||
this.stream = traced.result
|
||||
this.stateStream = program.states(seed, best)
|
||||
this.state = null // latest state the program emitted
|
||||
this.inputs = [] // every input fed so far
|
||||
this.frames = [] // per input that drew anything: {input, shapes}
|
||||
this.saves = [] // scores passed to SaveBest, in order
|
||||
}
|
||||
|
||||
/** Feed inputs, in order, as the page received them. */
|
||||
feed(inputs) {
|
||||
for (const input of inputs) {
|
||||
const [ios, next] = this.stream.step(input)
|
||||
this.stream = next
|
||||
const ops = ios.flatMap((io) => trace(io, this.seed, this.best).ops)
|
||||
const shapes = ops.filter((op) => op.tag === 'Draw').map((op) => op.shape)
|
||||
this.saves.push(...ops.filter((op) => op.tag === 'SaveBest').map((op) => op.score))
|
||||
if (shapes.length > 0) this.frames.push({ input, shapes })
|
||||
const [states, nextStates] = this.stateStream.step(input)
|
||||
this.stateStream = nextStates
|
||||
if (states.length > 0) this.state = states[states.length - 1]
|
||||
this.inputs.push(input)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/** What the state would be after these inputs, without feeding them. Streams are values, so this is free. */
|
||||
peek(inputs) {
|
||||
const [states] = inputs.reduce(([acc, s], input) => {
|
||||
const [out, next] = s.step(input)
|
||||
return [out.length > 0 ? out[out.length - 1] : acc, next]
|
||||
}, [this.state, this.stateStream])
|
||||
return states
|
||||
}
|
||||
|
||||
/** The course's own one-equality run of the program over every input fed so far. */
|
||||
play() {
|
||||
return play(programs[this.name].main(), this.seed, this.inputs, this.best)
|
||||
}
|
||||
|
||||
lastFrame() {
|
||||
return this.frames.length === 0 ? null : this.frames[this.frames.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
// Re-exported for tests that state expectations directly in model terms.
|
||||
export { sessionView, newGame, movesEvery, pongView, viewerView, replayFrames, lifeView }
|
||||
export { FRAME_MS, MAX_GAP_MS, FRAME_MS_PONG, REPLAY_MS, GEN_MS, LCELL, LW, LH }
|
||||
@@ -0,0 +1,32 @@
|
||||
// Technique 1: a fixed seed.
|
||||
// Every track's glue asks the browser for its seed the same way: it makes a Uint32Array of length 1 and hands it to
|
||||
// crypto.getRandomValues (JS: `new Uint32Array(1)`; Scala: `new Uint32Array(1)`; F#: `Array.zeroCreate<uint32> 1`,
|
||||
// which Fable compiles to a Uint32Array). This init script replaces `getRandomValues` before any page script runs,
|
||||
// writes the seed into such an array, and logs every call so a test can prove the page asked exactly once, in exactly
|
||||
// that shape. Any other request (none of the pages makes one) falls through to the real generator and is logged too.
|
||||
|
||||
/** The seed every test uses unless it says otherwise. Above 2^31 on purpose: a track that read the seed as a signed
|
||||
* 32-bit number would start a different game and every comparison with the model would fail. */
|
||||
export const SEED = 0xdeadbeef
|
||||
|
||||
/** Runs in the page (Playwright serialises it), so it may use only its argument. */
|
||||
const seedScript = (seed) => {
|
||||
const real = Crypto.prototype.getRandomValues
|
||||
const calls = []
|
||||
Object.defineProperty(window, '__e2eSeedCalls', { value: calls })
|
||||
Crypto.prototype.getRandomValues = function (array) {
|
||||
const type = array?.constructor?.name
|
||||
calls.push({ type, length: array?.length })
|
||||
if (type === 'Uint32Array' && array.length === 1) {
|
||||
array[0] = seed
|
||||
return array
|
||||
}
|
||||
return real.call(this, array)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the fixed seed for every document the page loads from now on. */
|
||||
export const fixSeed = (page, seed = SEED) => page.addInitScript(seedScript, seed)
|
||||
|
||||
/** The calls the current document made to crypto.getRandomValues: [{type, length}]. */
|
||||
export const seedCalls = (page) => page.evaluate(() => window.__e2eSeedCalls ?? null)
|
||||
Generated
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "pure-snake-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pure-snake-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.65.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz",
|
||||
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.65.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
|
||||
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.65.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
|
||||
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "pure-snake-e2e",
|
||||
"private": true,
|
||||
"description": "Maintainer tooling: end-to-end tests of the three tracks' browser pages against the pure JS model. Run scripts/e2e.sh from the repository root.",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.65.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Playwright configuration for the end-to-end tests. Run through scripts/e2e.sh, which builds and serves a copy of the
|
||||
// repository whose <track>/src holds the solutions and passes its address in E2E_BASE_URL.
|
||||
//
|
||||
// Browsers: Chromium and WebKit, and Firefox if a build is installed. Nothing is ever downloaded. If the exact build
|
||||
// this Playwright version expects is missing but another revision of the same browser is in the Playwright cache
|
||||
// (~/.cache/ms-playwright, or PLAYWRIGHT_BROWSERS_PATH), that one is used and named; if there is none, the project is
|
||||
// left out with a message.
|
||||
import { defineConfig, chromium, webkit, firefox } from '@playwright/test'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const cache = process.env.PLAYWRIGHT_BROWSERS_PATH || join(homedir(), '.cache', 'ms-playwright')
|
||||
const launchers = {
|
||||
chromium: ['chrome-linux64/chrome', 'chrome-linux/chrome'],
|
||||
webkit: ['pw_run.sh'],
|
||||
firefox: ['firefox/firefox'],
|
||||
}
|
||||
// Print the notes once, from the main process, not again from every worker.
|
||||
const say = (msg) => { if (!process.env.TEST_WORKER_INDEX) console.log(msg) }
|
||||
|
||||
const browser = (name, type) => {
|
||||
if (existsSync(type.executablePath())) return { name, use: { browserName: name } }
|
||||
const other = existsSync(cache)
|
||||
? readdirSync(cache)
|
||||
.filter((d) => new RegExp(`^${name}-\\d+$`).test(d))
|
||||
.sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]))
|
||||
.flatMap((d) => launchers[name].map((l) => join(cache, d, l)))
|
||||
.find(existsSync)
|
||||
: undefined
|
||||
if (other) {
|
||||
say(`e2e: ${name}: the expected build is not installed; using ${other}`)
|
||||
return { name, use: { browserName: name, launchOptions: { executablePath: other } } }
|
||||
}
|
||||
say(`e2e: ${name}: no build installed, skipping this project (npx playwright install ${name} would download one)`)
|
||||
return null
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'tests',
|
||||
fullyParallel: true,
|
||||
workers: process.env.E2E_WORKERS ? Number(process.env.E2E_WORKERS) : '50%',
|
||||
forbidOnly: true,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
|
||||
outputDir: 'test-results',
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:8000',
|
||||
viewport: { width: 1280, height: 900 },
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [browser('chromium', chromium), browser('webkit', webkit), browser('firefox', firefox)].filter(Boolean),
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
Reference in new issue
Block a user