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
@@ -49,6 +49,13 @@ test('m01 deepFreeze reaches unfrozen children of an already-frozen parent', ()
|
||||
const o = deepFreeze({ a: Object.freeze({ b: { c: 1 } }) })
|
||||
assert.ok(Object.isFrozen(o.a.b))
|
||||
})
|
||||
test('m01 deepFreeze leaves null and other non-objects alone', () => {
|
||||
const o = deepFreeze({ apple: null, score: 3, name: 'x', missing: undefined })
|
||||
assert.ok(Object.isFrozen(o))
|
||||
assert.equal(deepFreeze(null), null)
|
||||
assert.equal(deepFreeze(7), 7)
|
||||
})
|
||||
|
||||
test('m01 deepFreeze handles cyclic objects', () => {
|
||||
const o = { child: {} }
|
||||
o.child.parent = o
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pure, map, flatMap, get, put, modify, run } from '../src/state.js'
|
||||
import * as Random from '../src/random.js'
|
||||
|
||||
// Small initial states for the "behaves like" checks: two States behave alike when they give the same pair from each.
|
||||
const starts = [0, 1, 7, 42]
|
||||
const sameOnAll = (st1, st2) => starts.forEach((s) => assert.deepStrictEqual(run(st1, s), run(st2, s), `from state ${s}`))
|
||||
|
||||
// A counter step: gives the current number times 10 and adds 1 to the state. Built inside the tests, so that a stub
|
||||
// that throws fails each test on its own instead of the whole file at load time.
|
||||
const step = () => flatMap(get, (n) => map(put(n + 1), () => n * 10))
|
||||
|
||||
test('m04 State pure gives its value and leaves the state alone', () =>
|
||||
assert.deepStrictEqual(run(pure('x'), 99), ['x', 99]))
|
||||
|
||||
test('m04 State map applies f to the value and keeps the state from the underlying step', () =>
|
||||
assert.deepStrictEqual(run(map(step(), (v) => v + 1), 4), [41, 5]))
|
||||
|
||||
test('m04 State flatMap threads the state through both steps', () =>
|
||||
assert.deepStrictEqual(run(flatMap(step(), (a) => map(step(), (b) => [a, b])), 4), [[40, 50], 6]))
|
||||
|
||||
test('m04 State get gives the state as the value and leaves it alone', () =>
|
||||
assert.deepStrictEqual(run(get, 'board'), ['board', 'board']))
|
||||
|
||||
test('m04 State put replaces the state and gives undefined', () =>
|
||||
assert.deepStrictEqual(run(put(5), 99), [undefined, 5]))
|
||||
|
||||
test('m04 State modify applies f to the state and gives undefined', () =>
|
||||
assert.deepStrictEqual(run(modify((s) => s * 2), 21), [undefined, 42]))
|
||||
|
||||
test('m04 State law: left identity, flatMap(pure(a), f) behaves like f(a)', () => {
|
||||
const f = (a) => map(modify((s) => s * 2), () => a + 1)
|
||||
sameOnAll(flatMap(pure(3), f), f(3))
|
||||
})
|
||||
|
||||
test('m04 State law: right identity, flatMap(m, pure) behaves like m', () =>
|
||||
sameOnAll(flatMap(step(), pure), step()))
|
||||
|
||||
test('m04 State law: associativity, the grouping of flatMap does not matter', () => {
|
||||
const f = (a) => map(modify((s) => s * 2), () => a + 1)
|
||||
const g = (b) => flatMap(get, (s) => pure([b, s]))
|
||||
sameOnAll(flatMap(flatMap(step(), f), g), flatMap(step(), (a) => flatMap(f(a), g)))
|
||||
})
|
||||
|
||||
test('m04 State a counter hands out fresh labels in order', () => {
|
||||
const fresh = flatMap(get, (n) => map(put(n + 1), () => `L${n}`))
|
||||
const three = flatMap(fresh, (a) => flatMap(fresh, (b) => map(fresh, (c) => [a, b, c])))
|
||||
assert.deepStrictEqual(run(three, 0), [['L0', 'L1', 'L2'], 3])
|
||||
assert.deepStrictEqual(run(three, 7), [['L7', 'L8', 'L9'], 10])
|
||||
})
|
||||
|
||||
test('m04 State Random is State with the seed as S: the dice give the same values and seeds', () => {
|
||||
// One draw written only with State's get and put: read the seed, compute, store the next seed.
|
||||
const draw = flatMap(get, (seed) => {
|
||||
const [v, next] = Random.run(Random.nextUint32, seed)
|
||||
return map(put(next), () => v)
|
||||
})
|
||||
const d6State = map(draw, (v) => 1 + (v % 6))
|
||||
const twoDiceState = flatMap(d6State, (a) => map(d6State, (b) => a + b))
|
||||
const d6Random = Random.map(Random.nextUint32, (v) => 1 + (v % 6))
|
||||
const twoDiceRandom = Random.flatMap(d6Random, (a) => Random.map(d6Random, (b) => a + b))
|
||||
;[0, 1, 42, 4294967295].forEach((seed) => {
|
||||
assert.deepStrictEqual(run(draw, seed), Random.run(Random.nextUint32, seed), `draw from ${seed}`)
|
||||
assert.deepStrictEqual(run(d6State, seed), Random.run(d6Random, seed), `d6 from ${seed}`)
|
||||
assert.deepStrictEqual(run(twoDiceState, seed), Random.run(twoDiceRandom, seed), `twoDice from ${seed}`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import * as safe from '../src/safeio.js'
|
||||
import * as io from '../src/io.js'
|
||||
import { freeze as deepFreeze } from './helpers.js'
|
||||
|
||||
const { pure, effect, map, flatMap, sequence, step, drive, traceStep, trace } = safe
|
||||
|
||||
const r1 = deepFreeze({ tag: 'Rect', x: 0, y: 0, w: 1, h: 1, color: '#000000' })
|
||||
const t1 = deepFreeze({ tag: 'Text', x: 1, y: 2, text: 'hi', color: '#ffffff', size: 16, align: 'left' })
|
||||
const drawOp = (shape) => ({ tag: 'Draw', shape })
|
||||
const seedOp = { tag: 'RandomSeed' }
|
||||
const boom = () => { throw new Error('boom') }
|
||||
const N = 100000
|
||||
|
||||
// --- building -------------------------------------------------------------------------------------------------
|
||||
|
||||
test('m07b pure builds a Pure', () => assert.deepStrictEqual(pure(3), { tag: 'Pure', value: 3 }))
|
||||
|
||||
test('m07b effect(op) is an Effect whose continuation is pure', () => {
|
||||
const p = effect(seedOp)
|
||||
assert.equal(p.tag, 'Effect')
|
||||
assert.deepStrictEqual(p.op, seedOp)
|
||||
assert.deepStrictEqual(p.k(5), pure(5))
|
||||
})
|
||||
|
||||
test('m07b flatMap only builds a FlatMap node', () => {
|
||||
const m = pure(1)
|
||||
const f = (n) => pure(n + 1)
|
||||
const p = flatMap(m, f)
|
||||
assert.equal(p.tag, 'FlatMap')
|
||||
assert.equal(p.io, m)
|
||||
assert.equal(p.f, f)
|
||||
})
|
||||
|
||||
test('m07b building flatMap(pure(1), throwingF) does not throw', () =>
|
||||
assert.doesNotThrow(() => flatMap(pure(1), boom)))
|
||||
|
||||
test('m07b building map(io, throwingF) does not throw', () =>
|
||||
assert.doesNotThrow(() => map(effect(seedOp), boom)))
|
||||
|
||||
test('m07b tracing map(io, throwingF) runs f and throws', () =>
|
||||
assert.throws(() => trace(map(effect(seedOp), boom), 0), /boom/))
|
||||
|
||||
// --- step: the three rewrite rules ----------------------------------------------------------------------------
|
||||
|
||||
test('m07b step leaves a Pure unchanged', () => {
|
||||
const p = pure(1)
|
||||
assert.equal(step(p), p)
|
||||
})
|
||||
|
||||
test('m07b step leaves an Effect unchanged', () => {
|
||||
const p = effect(seedOp)
|
||||
assert.equal(step(p), p)
|
||||
})
|
||||
|
||||
test('m07b step of FlatMap(Pure(a), f) is f(a)', () =>
|
||||
assert.deepStrictEqual(step(flatMap(pure(3), (n) => pure(n * 2))), pure(6)))
|
||||
|
||||
test('m07b step of FlatMap(Effect(op, k), f) is an Effect with the same op that continues with FlatMap(k(r), f)', () => {
|
||||
const f = (n) => pure(n * 2)
|
||||
const s = step(flatMap(effect(seedOp), f))
|
||||
assert.equal(s.tag, 'Effect')
|
||||
assert.deepStrictEqual(s.op, seedOp)
|
||||
const next = s.k(5)
|
||||
assert.equal(next.tag, 'FlatMap')
|
||||
assert.deepStrictEqual(next.io, pure(5))
|
||||
assert.equal(next.f, f)
|
||||
})
|
||||
|
||||
test('m07b step re-associates FlatMap(FlatMap(m, g), f) into FlatMap(m, x => FlatMap(g(x), f))', () => {
|
||||
const m = effect(seedOp)
|
||||
const g = (n) => pure(n + 1)
|
||||
const f = (n) => pure(n * 2)
|
||||
const s = step(flatMap(flatMap(m, g), f))
|
||||
assert.equal(s.tag, 'FlatMap')
|
||||
assert.equal(s.io, m)
|
||||
const inner = s.f(4)
|
||||
assert.equal(inner.tag, 'FlatMap')
|
||||
assert.deepStrictEqual(inner.io, pure(5))
|
||||
assert.equal(inner.f, f)
|
||||
})
|
||||
|
||||
test('m07b step does not call the functions it re-associates', () =>
|
||||
assert.doesNotThrow(() => step(flatMap(flatMap(effect(seedOp), boom), boom))))
|
||||
|
||||
test('m07b step of a left-nested chain is one rewrite, not a whole walk', () => {
|
||||
// After one step, the top is still a FlatMap: step did constant work, the rest is left for later steps.
|
||||
const chain = Array.from({ length: N }).reduce((acc) => flatMap(acc, (n) => pure(n + 1)), pure(0))
|
||||
assert.equal(step(chain).tag, 'FlatMap')
|
||||
})
|
||||
|
||||
// --- the interpreter ------------------------------------------------------------------------------------------
|
||||
|
||||
test('m07b drive collects every output in order over 200 000 moves', () => {
|
||||
const [last, outs] = drive(0, (n) => n === 200000, (n) => [n + 1, n % 50000 === 0 ? [n] : []])
|
||||
assert.equal(last, 200000)
|
||||
assert.deepStrictEqual(outs, [0, 50000, 100000, 150000])
|
||||
})
|
||||
|
||||
test('m07b drive of a state that is already done makes no move', () =>
|
||||
assert.deepStrictEqual(drive(7, () => true, boom), [7, []]))
|
||||
|
||||
test('m07b traceStep answers an Effect and records its op', () => {
|
||||
const [next, ops] = traceStep(effect(seedOp), 9)
|
||||
assert.deepStrictEqual(next, pure(9))
|
||||
assert.deepStrictEqual(ops, [seedOp])
|
||||
})
|
||||
|
||||
test('m07b traceStep rewrites a FlatMap and records nothing', () => {
|
||||
const [next, ops] = traceStep(flatMap(pure(3), (n) => pure(n * 2)), 0)
|
||||
assert.deepStrictEqual(next, pure(6))
|
||||
assert.deepStrictEqual(ops, [])
|
||||
})
|
||||
|
||||
test('m07b traceStep leaves a Pure unchanged', () => {
|
||||
const p = pure(1)
|
||||
assert.deepStrictEqual(traceStep(p, 0), [p, []])
|
||||
})
|
||||
|
||||
test('m07b trace of pure(3) has result 3 and no ops', () =>
|
||||
assert.deepStrictEqual(trace(pure(3), 0), { result: 3, ops: [] }))
|
||||
|
||||
test('m07b trace answers RandomSeed with the seed and Draw with undefined', () =>
|
||||
assert.deepStrictEqual(
|
||||
trace(flatMap(effect(seedOp), (s) => map(effect(drawOp(r1)), () => s)), 9),
|
||||
{ result: 9, ops: [seedOp, drawOp(r1)] }))
|
||||
|
||||
test('m07b trace refuses operations of later modules', () =>
|
||||
assert.throws(() => trace(effect({ tag: 'LoadBest' }), 0), { message: "SafeIO.trace answers only Module 7's operations" }))
|
||||
|
||||
test('m07b sequence of nothing has no ops', () =>
|
||||
assert.deepStrictEqual(trace(sequence([]), 0), { result: undefined, ops: [] }))
|
||||
|
||||
test('m07b sequence runs effects left to right', () =>
|
||||
assert.deepStrictEqual(
|
||||
trace(sequence([effect(seedOp), effect(drawOp(t1)), effect(drawOp(r1))]), 0).ops,
|
||||
[seedOp, drawOp(t1), drawOp(r1)]))
|
||||
|
||||
// --- stack safety ---------------------------------------------------------------------------------------------
|
||||
|
||||
const leftDraws = (n) =>
|
||||
Array.from({ length: n }).reduce((acc, _, i) => flatMap(acc, () => effect(drawOp({ ...r1, x: i }))), pure(undefined))
|
||||
const rightDraws = (n) =>
|
||||
Array.from({ length: n }).reduceRight((rest, _, i) => flatMap(effect(drawOp({ ...r1, x: i })), () => rest), pure(undefined))
|
||||
|
||||
test('m07b 100 000 left-nested flatMaps of draws run, in order', () => {
|
||||
const { result, ops } = trace(leftDraws(N), 0)
|
||||
assert.equal(result, undefined)
|
||||
assert.equal(ops.length, N)
|
||||
assert.deepStrictEqual([ops[0], ops[N - 1]], [drawOp({ ...r1, x: 0 }), drawOp({ ...r1, x: N - 1 })])
|
||||
})
|
||||
|
||||
test('m07b 100 000 right-nested flatMaps of draws run, in order', () => {
|
||||
const { ops } = trace(rightDraws(N), 0)
|
||||
assert.equal(ops.length, N)
|
||||
assert.deepStrictEqual([ops[0], ops[N - 1]], [drawOp({ ...r1, x: 0 }), drawOp({ ...r1, x: N - 1 })])
|
||||
})
|
||||
|
||||
test('m07b 100 000 left-nested maps run without any op', () =>
|
||||
assert.deepStrictEqual(
|
||||
trace(Array.from({ length: N }).reduce((acc) => map(acc, (n) => n + 1), pure(0)), 0),
|
||||
{ result: N, ops: [] }))
|
||||
|
||||
test('m07b sequence of 100 000 draws runs', () =>
|
||||
assert.equal(trace(sequence(Array.from({ length: N }, (_, i) => effect(drawOp({ ...r1, x: i })))), 0).ops.length, N))
|
||||
|
||||
test('m07b a while loop over step is a complete interpreter for 100 000 left-nested flatMaps', () => {
|
||||
// What an impure runtime would do (a loop is allowed there): rewrite until the top is Pure or Effect, perform.
|
||||
const performed = []
|
||||
let current = leftDraws(N)
|
||||
while (current.tag !== 'Pure') {
|
||||
current = step(current)
|
||||
if (current.tag === 'Effect') {
|
||||
performed.push(current.op)
|
||||
current = current.k(undefined)
|
||||
}
|
||||
}
|
||||
assert.equal(performed.length, N)
|
||||
})
|
||||
|
||||
// --- the monad laws, observed through trace -------------------------------------------------------------------
|
||||
|
||||
const programs = {
|
||||
'a pure value': () => pure(4),
|
||||
'one effect': () => effect(seedOp),
|
||||
'a draw followed by reading the seed': () => flatMap(effect(drawOp(r1)), () => effect(seedOp)),
|
||||
}
|
||||
const f = (n) => flatMap(effect(drawOp({ ...r1, x: n })), () => pure(n * 2))
|
||||
const g = (n) => map(effect(seedOp), (s) => s + n)
|
||||
|
||||
Object.entries(programs).forEach(([name, program]) => {
|
||||
test(`m07b left identity holds for ${name}`, () => {
|
||||
const a = trace(program(), 5).result
|
||||
assert.deepStrictEqual(trace(flatMap(pure(a), f), 5), trace(f(a), 5))
|
||||
})
|
||||
test(`m07b right identity holds for ${name}`, () =>
|
||||
assert.deepStrictEqual(trace(flatMap(program(), pure), 5), trace(program(), 5)))
|
||||
test(`m07b associativity holds for ${name}`, () =>
|
||||
assert.deepStrictEqual(
|
||||
trace(flatMap(flatMap(program(), f), g), 5),
|
||||
trace(flatMap(program(), (x) => flatMap(f(x), g)), 5)))
|
||||
})
|
||||
|
||||
// --- the same programs under Module 7's IO --------------------------------------------------------------------
|
||||
|
||||
// Each program is written once against an interface {pure, effect, map, flatMap, sequence} and built with both.
|
||||
const shared = {
|
||||
'seed, then a draw that depends on it': (M) =>
|
||||
M.flatMap(M.effect(seedOp), (s) => M.map(M.effect(drawOp({ ...r1, x: s % 20 })), () => s)),
|
||||
'a sequence of three effects': (M) => M.sequence([M.effect(drawOp(t1)), M.effect(seedOp), M.effect(drawOp(r1))]),
|
||||
'200 left-nested flatMaps': (M) =>
|
||||
Array.from({ length: 200 }).reduce((acc, _, i) => M.flatMap(acc, (n) => M.map(M.effect(drawOp({ ...r1, x: i })), () => n + 1)), M.pure(0)),
|
||||
'nested on both sides': (M) =>
|
||||
M.flatMap(M.flatMap(M.effect(seedOp), (s) => M.flatMap(M.effect(drawOp(r1)), () => M.pure(s + 1))),
|
||||
(n) => M.map(M.sequence([M.effect(drawOp(t1)), M.effect(seedOp)]), () => n * 3)),
|
||||
}
|
||||
|
||||
Object.entries(shared).forEach(([name, program]) =>
|
||||
test(`m07b trace equals Module 7's trace for ${name}`, () =>
|
||||
assert.deepStrictEqual(trace(program(safe), 7), io.trace(program(io), 7))))
|
||||
@@ -23,6 +23,9 @@ test('m08 interval emits each multiple once as time passes', () =>
|
||||
test('m08 interval emits several multiples after one big time jump', () =>
|
||||
assert.deepStrictEqual(run(interval(10), inputs(time(35))), [10, 20, 30]))
|
||||
|
||||
test('m08 interval(1) emits every millisecond crossed', () =>
|
||||
assert.deepStrictEqual(run(interval(1), inputs(time(1), time(3))), [1, 2, 3]))
|
||||
|
||||
test('m08 interval ignores key inputs', () =>
|
||||
assert.deepStrictEqual(run(interval(10), inputs(down('a'), up('a'))), []))
|
||||
|
||||
|
||||
+25
-3
@@ -1,11 +1,12 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { states, classicActions, render, main, TICK_MS } from '../src/app.js'
|
||||
import { states, classicActions, render, main, play, TICK_MS } from '../src/app.js'
|
||||
import { run } from '../src/stream.js'
|
||||
import { trace } from '../src/io.js'
|
||||
import { view } from '../src/view.js'
|
||||
import { newGame } from '../src/spawn.js'
|
||||
import { Action } from '../src/model.js'
|
||||
import { history } from '../src/game.js'
|
||||
import { freeze } from './helpers.js'
|
||||
import { classicMain } from './m09-helpers.js'
|
||||
|
||||
@@ -33,12 +34,17 @@ test('m09 states starts with the new game, then yields one state per action', ()
|
||||
test('m09 render draws the whole view of the state', () =>
|
||||
assert.deepStrictEqual(trace(render(newGame(42)), 0).ops, view(newGame(42)).map((shape) => ({ tag: 'Draw', shape }))))
|
||||
|
||||
// Module 10 (Exercise 10.7) makes `main` also load the stored best and draw one more shape after the game's view,
|
||||
// so this test accepts both versions; m10 pins the final one exactly.
|
||||
test('m09 main asks for a random seed, then builds the stream from it', () => {
|
||||
const { ops, result: stream } = trace(main, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' })
|
||||
assert.ok(ops.length === 1 || (ops.length === 2 && ops[1].tag === 'LoadBest'),
|
||||
`main may only ask for the seed (and, from m10 on, the stored best); it asked for ${JSON.stringify(ops)}`)
|
||||
const [outs] = stream.step(time(0))
|
||||
assert.equal(outs.length, 1)
|
||||
assert.deepStrictEqual(trace(outs[0], 0).ops, trace(render(newGame(42)), 0).ops)
|
||||
const expected = trace(render(newGame(42)), 0).ops
|
||||
assert.deepStrictEqual(trace(outs[0], 0).ops.slice(0, expected.length), expected)
|
||||
})
|
||||
|
||||
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
|
||||
@@ -59,3 +65,19 @@ test('m09 main draws again once time has passed', () => {
|
||||
assert.equal(first.length, 1)
|
||||
assert.ok(later.length >= 1)
|
||||
})
|
||||
|
||||
// ---- play: the whole program in one equality (Exercise 9.5)
|
||||
|
||||
const drawOps = (state) => view(state).map((shape) => ({ tag: 'Draw', shape }))
|
||||
|
||||
test('m09 play on the classic program equals the scenes of the expected states', () => {
|
||||
const events = freeze([time(150), down('ArrowUp'), down('x'), time(300), time(310)])
|
||||
const expectedStates = history(newGame(42), [Action.Tick, Action.Turn('Up'), Action.Tick])
|
||||
assert.deepStrictEqual(play(classicMain, 42, events), expectedStates.map(drawOps))
|
||||
})
|
||||
|
||||
test('m09 play with no events gives no drawings', () =>
|
||||
assert.deepStrictEqual(play(classicMain, 42, []), []))
|
||||
|
||||
test('m09 play uses the seed for the program\'s RandomSeed', () =>
|
||||
assert.deepStrictEqual(play(classicMain, 7, freeze([time(0)])), [drawOps(newGame(7))]))
|
||||
@@ -18,9 +18,12 @@ const fakePlatform = (seed = 42) => {
|
||||
startClock: (cb) => { handlers.time = cb },
|
||||
onKeyDown: (cb) => { handlers.down = cb },
|
||||
onKeyUp: (cb) => { handlers.up = cb },
|
||||
// m13: a runtime from Module 9 does not register this yet; nothing in m09 fires a click.
|
||||
onClick: (cb) => { handlers.click = cb },
|
||||
fireTime: (t) => handlers.time(t),
|
||||
fireKey: (k) => handlers.down(k),
|
||||
fireKeyUp: (k) => handlers.up(k),
|
||||
fireClick: (x, y) => handlers.click(x, y),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { update, movesEvery } from '../src/game.js'
|
||||
import { update, movesEvery, steer } from '../src/game.js'
|
||||
import { keyToAction } from '../src/input.js'
|
||||
import { speedActions, main, FRAME_MS } from '../src/app.js'
|
||||
import { run } from '../src/stream.js'
|
||||
import { trace } from '../src/io.js'
|
||||
import {
|
||||
speedActions, main, FRAME_MS, MAX_GAP_MS, sessionUpdate, sessions, sessionView, renderSession, play,
|
||||
} from '../src/app.js'
|
||||
import { run, clampTime, interval, keyDowns } from '../src/stream.js'
|
||||
import { trace, loadBest, saveBest, map as mapIO, flatMap as flatMapIO, pure } from '../src/io.js'
|
||||
import { runIO, runApp } from '../src/runtime.js'
|
||||
import { browserPlatform } from '../src/browser.js'
|
||||
import { view } from '../src/view.js'
|
||||
import { newGame } from '../src/spawn.js'
|
||||
import { Action } from '../src/model.js'
|
||||
@@ -19,6 +23,23 @@ const fixtureTests = (name, f) => {
|
||||
fixtureTests('m10-update', update)
|
||||
fixtureTests('m10-moves-every', movesEvery)
|
||||
fixtureTests('m10-input', keyToAction)
|
||||
fixtureTests('m10-session-update', sessionUpdate)
|
||||
fixtureTests('m10-session-view', sessionView)
|
||||
|
||||
// ---- the turn queue (Exercise 10.6)
|
||||
|
||||
test('m10 steer is what update does with a Turn', () =>
|
||||
loadFixture('m10-update').cases
|
||||
.filter((c) => c.args[1].tag === 'Turn')
|
||||
.forEach((c) => assert.deepStrictEqual(steer(...freeze([c.args[0], c.args[1].dir])), c.expected, c.name)))
|
||||
|
||||
test('m10 Turn Up then Turn Left within one step: the snake goes Up on the next step and Left on the one after', () => {
|
||||
const start = newGame(42) // heading Right from (10, 10); the apple is far away
|
||||
const states = [Action.Turn('Up'), Action.Turn('Left'), Action.Tick, Action.Tick]
|
||||
.reduce((acc, a) => [...acc, update(acc[acc.length - 1], a)], [freeze(start)])
|
||||
assert.deepStrictEqual(states.slice(2).map((s) => s.snake[0]), [{ x: 10, y: 10 }, { x: 10, y: 9 }, { x: 9, y: 9 }])
|
||||
assert.deepStrictEqual(states.map((s) => s.queued), [null, null, 'Left', null, null])
|
||||
})
|
||||
|
||||
const time = (t) => ({ tag: 'Time', t })
|
||||
|
||||
@@ -34,11 +55,17 @@ test('m10 speedActions still turns keys into actions, including pause and restar
|
||||
run(speedActions, freeze([{ tag: 'KeyDown', key: ' ' }, { tag: 'KeyDown', key: 'r' }, { tag: 'KeyDown', key: 'ArrowUp' }])),
|
||||
[Action.TogglePause, Action.Restart, Action.Turn('Up')]))
|
||||
|
||||
// The drawings are compared by their first shapes, the game's view: Exercise 10.7 adds the best score after it.
|
||||
const startsWithView = (io, state) => {
|
||||
const expected = view(state)
|
||||
assert.deepStrictEqual(trace(io, 0).ops.slice(0, expected.length).map((o) => o.shape), expected)
|
||||
}
|
||||
|
||||
test('m10 main (seed 42) draws a moved snake after eight 25 ms frames', () => {
|
||||
const { ops, result: stream } = trace(main, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' })
|
||||
const [first, afterFirst] = stream.step(time(0))
|
||||
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42)))
|
||||
startsWithView(first[0], newGame(42))
|
||||
const [drawings] = [25, 50, 75, 100, 125, 150, 175, 200].reduce(
|
||||
([outs, s], t) => {
|
||||
const [out, next] = s.step(time(t))
|
||||
@@ -46,6 +73,190 @@ test('m10 main (seed 42) draws a moved snake after eight 25 ms frames', () => {
|
||||
},
|
||||
[[], afterFirst])
|
||||
assert.equal(drawings.length, 8)
|
||||
const moved = { ...newGame(42), snake: newGame(42).snake.map((p) => ({ x: p.x + 1, y: p.y })), frame: 8 }
|
||||
assert.deepStrictEqual(trace(drawings[7], 0).ops.map((o) => o.shape), view(moved))
|
||||
const moved = { ...newGame(42), snake: newGame(42).snake.map((p) => ({ x: p.x + 1, y: p.y })), frame: 0 }
|
||||
startsWithView(drawings[7], moved)
|
||||
})
|
||||
|
||||
// ---- clampTime: a long silence from the clock (a background tab) must not turn into hundreds of frames
|
||||
|
||||
const down = (key) => ({ tag: 'KeyDown', key })
|
||||
|
||||
test('m10 clampTime passes small steps of time through unchanged', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 100), freeze([5, 10, 35].map(time))), [10, 20, 30]))
|
||||
|
||||
test('m10 clampTime lets a long gap count as maxGap, and carries on from there', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([10, 5000, 5010].map(time))), [10, 20, 30, 40]))
|
||||
|
||||
test('m10 clampTime clamps the very first step too (the clock starts at 0)', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([time(1000)])), [10, 20]))
|
||||
|
||||
test('m10 clampTime never moves the clock backwards', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 100), freeze([30, 10, 40].map(time))), [10, 20, 30, 40, 50, 60]))
|
||||
|
||||
test('m10 clampTime tells the inner stream exactly the clamped time, and a repeated time adds nothing', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(1), 100), freeze([5, 5, 6].map(time))), [1, 2, 3, 4, 5, 6]))
|
||||
|
||||
test('m10 clampTime passes key inputs through, and they do not disturb the clock', () => {
|
||||
assert.deepStrictEqual(run(clampTime(keyDowns, 5), freeze([down('a'), time(50), down('b')])), ['a', 'b'])
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([time(10), down('a'), time(20)])), [10, 20])
|
||||
})
|
||||
|
||||
test('m10 running the same clamped stream twice gives the same outputs', () => {
|
||||
const s = clampTime(interval(10), 25)
|
||||
const xs = freeze([10, 5000, 5010].map(time))
|
||||
assert.deepStrictEqual(run(s, xs), run(s, xs))
|
||||
})
|
||||
|
||||
test('m10 the game accepts at most 250 ms of time at once', () => assert.equal(MAX_GAP_MS, 250))
|
||||
|
||||
test('m10 main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds', () => {
|
||||
const [, afterFirst] = trace(main, 42).result.step(time(0))
|
||||
const [drawings, next] = afterFirst.step(time(10000))
|
||||
assert.equal(drawings.length, MAX_GAP_MS / FRAME_MS)
|
||||
assert.equal(next.step(time(10025))[0].length, 1)
|
||||
})
|
||||
|
||||
// ---- a persisted high score (Exercise 10.7)
|
||||
|
||||
const loadOp = { tag: 'LoadBest' }
|
||||
const session = (game, best, improved = false) => ({ game, best, improved })
|
||||
|
||||
test('m10 loadBest records a LoadBest op and is answered with the best given to trace', () =>
|
||||
assert.deepStrictEqual(trace(loadBest, 0, 17), { result: 17, ops: [loadOp] }))
|
||||
|
||||
test('m10 trace answers LoadBest with 0 when no best is given', () =>
|
||||
assert.equal(trace(loadBest, 9).result, 0))
|
||||
|
||||
test('m10 saveBest records a SaveBest op with the score and produces undefined', () =>
|
||||
assert.deepStrictEqual(trace(saveBest(7), 0), { result: undefined, ops: [{ tag: 'SaveBest', score: 7 }] }))
|
||||
|
||||
test('m10 a program can load the best, then save a higher one', () =>
|
||||
assert.deepStrictEqual(trace(flatMapIO(loadBest, (b) => saveBest(b + 1)), 0, 4).ops, [loadOp, { tag: 'SaveBest', score: 5 }]))
|
||||
|
||||
test('m10 sessions starts with the new game and the stored best, then yields one session per action', () => {
|
||||
const out = run(sessions(42, 3, speedActions), freeze([time(25), { tag: 'KeyDown', key: ' ' }]))
|
||||
const s0 = session(newGame(42), 3)
|
||||
const s1 = sessionUpdate(s0, Action.Frame)
|
||||
assert.deepStrictEqual(out, [s0, s1, sessionUpdate(s1, Action.TogglePause)])
|
||||
})
|
||||
|
||||
test('m10 renderSession draws the session view and saves nothing when the best did not improve', () => {
|
||||
const s = freeze(session(newGame(42), 5))
|
||||
assert.deepStrictEqual(trace(renderSession(s), 0).ops, sessionView(s).map((shape) => ({ tag: 'Draw', shape })))
|
||||
})
|
||||
|
||||
test('m10 renderSession draws the session view, then saves the best, when the best improved', () => {
|
||||
const s = freeze(session({ ...newGame(42), score: 6 }, 6, true))
|
||||
assert.deepStrictEqual(trace(renderSession(s), 0).ops, [
|
||||
...sessionView(s).map((shape) => ({ tag: 'Draw', shape })),
|
||||
{ tag: 'SaveBest', score: 6 },
|
||||
])
|
||||
})
|
||||
|
||||
test('m10 main asks for the seed, then the stored best, and draws the first session', () => {
|
||||
const { ops, result: stream } = trace(main, 42, 9)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }, loadOp])
|
||||
const [first] = stream.step(time(0))
|
||||
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), sessionView(session(newGame(42), 9)))
|
||||
})
|
||||
|
||||
test('m10 play passes the best to the program: the first scene shows it', () =>
|
||||
assert.deepStrictEqual(
|
||||
play(main, 42, freeze([time(0)]), 9),
|
||||
[sessionView(session(newGame(42), 9)).map((shape) => ({ tag: 'Draw', shape }))]))
|
||||
|
||||
test('m10 play without a best answers LoadBest with 0', () =>
|
||||
assert.deepStrictEqual(
|
||||
play(main, 42, freeze([time(0)])),
|
||||
[sessionView(session(newGame(42), 0)).map((shape) => ({ tag: 'Draw', shape }))]))
|
||||
|
||||
// The golden fixture: a seed, a stored best, a scripted list of inputs, and the scene of the last drawing.
|
||||
loadFixture('m10-golden').cases.forEach((c) => {
|
||||
const [seed, events, best] = freeze(c.args)
|
||||
|
||||
test(`m10 play(main): ${c.name}`, () =>
|
||||
assert.deepStrictEqual(play(main, seed, events, best).at(-1).filter((o) => o.tag === 'Draw').map((o) => o.shape), c.expected))
|
||||
|
||||
test(`m10 play(main) saves once per improvement: ${c.name}`, () => {
|
||||
const saves = play(main, seed, events, best).flat().filter((o) => o.tag === 'SaveBest').map((o) => o.score)
|
||||
const finalBest = Number(c.expected.find((s) => s.tag === 'Text' && s.text.startsWith('Best: ')).text.slice(6))
|
||||
const improvements = Array.from({ length: Math.max(0, finalBest - best) }, (_, i) => best + 1 + i)
|
||||
assert.deepStrictEqual(saves, improvements)
|
||||
})
|
||||
})
|
||||
|
||||
// The runtime with a fake platform that records the two new calls.
|
||||
const fakePlatform = (seed = 42, best = 0) => {
|
||||
const calls = []
|
||||
const handlers = {}
|
||||
return {
|
||||
calls,
|
||||
fillRect: (...args) => calls.push(['fillRect', ...args]),
|
||||
fillText: (...args) => calls.push(['fillText', ...args]),
|
||||
randomSeed: () => { calls.push(['randomSeed']); return seed },
|
||||
loadBest: () => { calls.push(['loadBest']); return best },
|
||||
saveBest: (n) => { calls.push(['saveBest', n]) },
|
||||
startClock: (cb) => { handlers.time = cb },
|
||||
onKeyDown: (cb) => { handlers.down = cb },
|
||||
onKeyUp: (cb) => { handlers.up = cb },
|
||||
onClick: (cb) => { handlers.click = cb }, // m13; nothing in m10 fires a click
|
||||
fireTime: (t) => handlers.time(t),
|
||||
fireClick: (x, y) => handlers.click(x, y),
|
||||
}
|
||||
}
|
||||
|
||||
test('m10 runIO answers LoadBest with platform.loadBest() and continues with it', () => {
|
||||
const p = fakePlatform(42, 11)
|
||||
assert.equal(runIO(mapIO(loadBest, (b) => b + 1), p), 12)
|
||||
assert.deepStrictEqual(p.calls, [['loadBest']])
|
||||
})
|
||||
|
||||
test('m10 runIO maps SaveBest to platform.saveBest(score)', () => {
|
||||
const p = fakePlatform()
|
||||
runIO(flatMapIO(saveBest(8), () => pure('done')), p)
|
||||
assert.deepStrictEqual(p.calls, [['saveBest', 8]])
|
||||
})
|
||||
|
||||
test('m10 runApp asks for the seed and loads the best once, at start, and draws nothing before the first input', () => {
|
||||
const p = fakePlatform(42, 3)
|
||||
runApp(main, p)
|
||||
assert.deepStrictEqual(p.calls, [['randomSeed'], ['loadBest']])
|
||||
p.fireTime(0)
|
||||
p.fireTime(25)
|
||||
assert.equal(p.calls.filter((c) => c[0] === 'loadBest').length, 1)
|
||||
assert.ok(p.calls.some((c) => c[0] === 'fillText' && c[3] === 'Best: 3'))
|
||||
})
|
||||
|
||||
// browser.js: only the storage part can be checked without a browser. A fake window with a fake localStorage.
|
||||
const fakeCanvas = { getContext: () => ({}) }
|
||||
const fakeWindow = (storage) => ({ localStorage: storage, addEventListener: () => {}, requestAnimationFrame: () => {} })
|
||||
const memoryStorage = (entries = {}) => {
|
||||
const data = { ...entries }
|
||||
return { data, getItem: (k) => (k in data ? data[k] : null), setItem: (k, v) => { data[k] = String(v) } }
|
||||
}
|
||||
const throwing = { getItem: () => { throw new Error('blocked') }, setItem: () => { throw new Error('blocked') } }
|
||||
|
||||
test('m10 browserPlatform loads the best from localStorage key pure-snake-best', () =>
|
||||
assert.equal(browserPlatform(fakeCanvas, fakeWindow(memoryStorage({ 'pure-snake-best': '23' }))).loadBest(), 23))
|
||||
|
||||
const unreadable = [{}, ...['lots', '-4', '2.5', '1e2', '7.0', '+7', ' 7', '0x10', '2147483648', ''].map((v) => ({ 'pure-snake-best': v }))]
|
||||
|
||||
test('m10 browserPlatform treats a missing or unreadable best as 0', () =>
|
||||
unreadable.forEach((entries) =>
|
||||
assert.equal(browserPlatform(fakeCanvas, fakeWindow(memoryStorage(entries))).loadBest(), 0, JSON.stringify(entries))))
|
||||
|
||||
test('m10 browserPlatform saves the best under pure-snake-best', () => {
|
||||
const storage = memoryStorage()
|
||||
browserPlatform(fakeCanvas, fakeWindow(storage)).saveBest(31)
|
||||
assert.deepStrictEqual(storage.data, { 'pure-snake-best': '31' })
|
||||
})
|
||||
|
||||
// Some browsers throw on every storage call when storage is blocked, others already when `localStorage` is read.
|
||||
const blockedWindow = { addEventListener: () => {}, requestAnimationFrame: () => {}, get localStorage() { throw new Error('blocked') } }
|
||||
|
||||
test('m10 browserPlatform never throws when storage is blocked', () =>
|
||||
[fakeWindow(throwing), blockedWindow].forEach((win) => {
|
||||
const p = browserPlatform(fakeCanvas, win)
|
||||
assert.equal(p.loadBest(), 0)
|
||||
assert.doesNotThrow(() => p.saveBest(5))
|
||||
}))
|
||||
@@ -0,0 +1,106 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
REPLAY_MS, frames, viewerStart, ViewerAction, viewerUpdate, viewerKey, viewerView, replayActions, viewerStates, replayMain,
|
||||
} from '../src/replay.js'
|
||||
import { history } from '../src/game.js'
|
||||
import { newGame } from '../src/spawn.js'
|
||||
import { view } from '../src/view.js'
|
||||
import { run } from '../src/stream.js'
|
||||
import { trace } from '../src/io.js'
|
||||
import { loadFixture, freeze } from './helpers.js'
|
||||
|
||||
const fixtureTests = (name, f) => {
|
||||
const fx = loadFixture(name)
|
||||
fx.cases.forEach((c) =>
|
||||
test(`m10 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected)))
|
||||
}
|
||||
|
||||
fixtureTests('m10-viewer-update', viewerUpdate)
|
||||
fixtureTests('m10-viewer-key', viewerKey)
|
||||
fixtureTests('m10-viewer-view', viewerView)
|
||||
|
||||
// The sample recording, as the page loads it. Not a fixture of cases: a recording `{seed, actions}`.
|
||||
const sample = freeze(loadFixture('replay-sample'))
|
||||
|
||||
const time = (t) => ({ tag: 'Time', t })
|
||||
const key = (k) => ({ tag: 'KeyDown', key: k })
|
||||
const shapes = (io) => trace(io, 0).ops.map((o) => o.shape)
|
||||
|
||||
test('m10 the sample recording is worth watching: Tick and Turn only, under 400 actions, at least five apples, then game over', () => {
|
||||
assert.ok(sample.actions.every((a) => a.tag === 'Tick' || a.tag === 'Turn'))
|
||||
assert.ok(sample.actions.length >= 150 && sample.actions.length <= 400, `${sample.actions.length} actions`)
|
||||
const fs = history(newGame(sample.seed), sample.actions)
|
||||
assert.ok(fs.at(-1).score >= 5, `score ${fs.at(-1).score}`)
|
||||
assert.equal(fs.at(-1).status, 'GameOver')
|
||||
assert.equal(fs.at(-2).status, 'Playing')
|
||||
})
|
||||
|
||||
test('m10 frames replays the recording with history, from the new game of its seed', () => {
|
||||
const recording = freeze({ seed: 42, actions: [{ tag: 'Tick' }, { tag: 'Turn', dir: 'Up' }, { tag: 'Tick' }] })
|
||||
assert.deepStrictEqual(frames(recording), history(newGame(42), recording.actions))
|
||||
assert.equal(frames(sample).length, sample.actions.length + 1)
|
||||
})
|
||||
|
||||
test('m10 viewerStart is the first frame, not playing', () =>
|
||||
assert.deepStrictEqual(viewerStart(12), { index: 0, last: 12, playing: false }))
|
||||
|
||||
test('m10 ViewerAction has the six actions as plain values', () =>
|
||||
assert.deepStrictEqual(
|
||||
[ViewerAction.Next, ViewerAction.Prev, ViewerAction.First, ViewerAction.Last, ViewerAction.TogglePlay, ViewerAction.Beat],
|
||||
[{ tag: 'Next' }, { tag: 'Prev' }, { tag: 'First' }, { tag: 'Last' }, { tag: 'TogglePlay' }, { tag: 'Beat' }]))
|
||||
|
||||
test('m10 viewerUpdate keeps the index in [0, last] whatever the actions', () => {
|
||||
const actions = [ViewerAction.Prev, ViewerAction.Next, ViewerAction.Last, ViewerAction.Next, ViewerAction.TogglePlay,
|
||||
ViewerAction.Beat, ViewerAction.Beat, ViewerAction.First, ViewerAction.Prev, ViewerAction.Beat]
|
||||
const viewers = actions.reduce((acc, a) => [...acc, viewerUpdate(acc.at(-1), a)], [freeze(viewerStart(3))])
|
||||
assert.ok(viewers.every((v) => v.index >= 0 && v.index <= 3 && !(v.playing && v.index === 3)))
|
||||
})
|
||||
|
||||
test('m10 the autoplay clock beats every 100 ms', () => assert.equal(REPLAY_MS, 100))
|
||||
|
||||
test('m10 replayActions turns the clock into Beats and keys into viewer actions', () =>
|
||||
assert.deepStrictEqual(
|
||||
run(replayActions, freeze([time(100), key('ArrowRight'), key('x'), key(' '), time(300)])),
|
||||
[ViewerAction.Beat, ViewerAction.Next, ViewerAction.TogglePlay, ViewerAction.Beat, ViewerAction.Beat]))
|
||||
|
||||
test('m10 autoplay: Space, then the clock steps one frame per 100 ms and stops at the end', () => {
|
||||
const out = run(viewerStates(3, replayActions), freeze([time(0), key(' '), time(100), time(200), time(300), time(400), time(500)]))
|
||||
assert.deepStrictEqual(out.map((v) => v.index), [0, 0, 1, 2, 3, 3, 3])
|
||||
assert.deepStrictEqual(out.map((v) => v.playing), [false, true, true, true, false, false, false])
|
||||
})
|
||||
|
||||
test('m10 without Space nothing plays: the clock alone never moves the viewer', () =>
|
||||
assert.ok(run(viewerStates(3, replayActions), freeze([time(0), time(1000), time(2000)])).every((v) => v.index === 0)))
|
||||
|
||||
test('m10 replayMain asks for nothing and draws the first frame of the recording first', () => {
|
||||
const { ops, result: stream } = trace(replayMain(sample), 0)
|
||||
assert.deepStrictEqual(ops, [])
|
||||
const [drawings] = stream.step(time(0))
|
||||
assert.equal(drawings.length, 1)
|
||||
assert.deepStrictEqual(shapes(drawings[0]), viewerView(frames(sample), viewerStart(sample.actions.length)))
|
||||
assert.deepStrictEqual(shapes(drawings[0]).slice(0, view(newGame(sample.seed)).length), view(newGame(sample.seed)))
|
||||
})
|
||||
|
||||
test('m10 replayMain: End shows the last frame of the game, the game over', () => {
|
||||
const out = run(trace(replayMain(sample), 0).result, freeze([time(0), key('End')]))
|
||||
const fs = frames(sample)
|
||||
assert.deepStrictEqual(shapes(out.at(-1)), viewerView(fs, { index: fs.length - 1, last: fs.length - 1, playing: false }))
|
||||
assert.ok(shapes(out.at(-1)).some((s) => s.tag === 'Text' && s.text === 'Game Over'))
|
||||
})
|
||||
|
||||
test('m10 stepping back shows exactly the earlier scene', () => {
|
||||
const out = run(trace(replayMain(sample), 0).result,
|
||||
freeze([time(0), key('ArrowRight'), key('ArrowRight'), key('ArrowRight'), key('ArrowLeft'), key('d'), key('a'), key('a')]))
|
||||
const scenes = out.map(shapes)
|
||||
assert.deepStrictEqual(scenes[4], scenes[2])
|
||||
assert.deepStrictEqual(scenes[6], scenes[4])
|
||||
assert.deepStrictEqual(scenes[7], scenes[1])
|
||||
assert.notDeepStrictEqual(scenes[3], scenes[2])
|
||||
})
|
||||
|
||||
test('m10 replayMain: after ten seconds in a background tab autoplay moves 2 frames (250 ms), not 100', () => {
|
||||
const [, afterPlay] = trace(replayMain(sample), 0).result.step(key(' '))
|
||||
const [, afterFirst] = afterPlay.step(time(0))
|
||||
assert.equal(afterFirst.step(time(10000))[0].length, 2)
|
||||
})
|
||||
@@ -74,6 +74,11 @@ test('m11 pongStates starts with the new match, then yields one state per action
|
||||
assert.deepStrictEqual(out[3].player, 20)
|
||||
})
|
||||
|
||||
test('m11 pongMain: after ten seconds in a background tab the match advances by 250 ms, not by ten seconds', () => {
|
||||
const [, afterFirst] = trace(pongMain, 42).result.step(time(0))
|
||||
assert.equal(afterFirst.step(time(10000))[0].length, 8)
|
||||
})
|
||||
|
||||
test('m11 pongMain asks for a seed, draws the new match first, and then the updated one', () => {
|
||||
const { ops, result: stream } = trace(pongMain, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
elementOf, listOf, genActions, forAll, removals, minimize, violations, moveGaps, genSnake, genState, advanceSpec,
|
||||
} from '../src/check.js'
|
||||
import { nextUint32, intBelow, pure, run } from '../src/random.js'
|
||||
import { update, history, movesEvery } from '../src/game.js'
|
||||
import { newGame, spawnApple, tick } from '../src/spawn.js'
|
||||
import { move, advance, inBounds } from '../src/rules.js'
|
||||
import { Action, opposite, pointEquals } from '../src/model.js'
|
||||
import { contains, dropLast } from '../src/util.js'
|
||||
import { loadFixture, freeze } from './helpers.js'
|
||||
|
||||
// ---- generators
|
||||
|
||||
test('m12 elementOf picks the element at the index drawn by intBelow', () => {
|
||||
const xs = freeze(['a', 'b', 'c', 'd', 'e'])
|
||||
const [i, seed] = run(intBelow(5), 0)
|
||||
assert.deepStrictEqual(run(elementOf(xs), 0), [xs[i], seed])
|
||||
})
|
||||
|
||||
test('m12 elementOf of a one-element array always gives that element, and still uses up a draw', () =>
|
||||
assert.deepStrictEqual(run(elementOf(freeze(['only'])), 0), ['only', 1831565813]))
|
||||
|
||||
test('m12 listOf(gen, 0) is the empty list and leaves the seed alone', () =>
|
||||
assert.deepStrictEqual(run(listOf(nextUint32, 0), 99), [[], 99]))
|
||||
|
||||
test('m12 listOf threads the seed from each draw to the next', () =>
|
||||
assert.deepStrictEqual(run(listOf(nextUint32, 3), 0), [[1144304738, 1416247, 958946056], 1199730143]))
|
||||
|
||||
test('m12 listOf of a constant generator repeats the value and leaves the seed alone', () =>
|
||||
assert.deepStrictEqual(run(listOf(pure('x'), 4), 7), [['x', 'x', 'x', 'x'], 7]))
|
||||
|
||||
test('m12 genActions(n) gives n actions, the same ones for the same seed', () => {
|
||||
const [actions] = run(genActions(30), 5)
|
||||
assert.equal(actions.length, 30)
|
||||
assert.ok(actions.every((a) => a.tag === 'Tick' || a.tag === 'Turn'))
|
||||
assert.deepStrictEqual(run(genActions(30), 5), run(genActions(30), 5))
|
||||
})
|
||||
|
||||
// ---- forAll
|
||||
|
||||
test('m12 forAll reports success with the number of runs', () =>
|
||||
assert.deepStrictEqual(forAll(intBelow(10), (x) => x >= 0 && x < 10, 200, 1), { ok: true, runs: 200 }))
|
||||
|
||||
test('m12 forAll with zero runs succeeds without drawing anything', () =>
|
||||
assert.deepStrictEqual(forAll(nextUint32, () => false, 0, 1), { ok: true, runs: 0 }))
|
||||
|
||||
test('m12 forAll reports the first failing draw, its value and the seed it started from', () => {
|
||||
const gen = intBelow(10)
|
||||
const prop = (x) => x !== 7
|
||||
const [draws] = run(listOf(gen, 200), 3)
|
||||
const first = draws.findIndex((x) => !prop(x))
|
||||
assert.ok(first > 0, 'the test needs a failure that is not the very first draw')
|
||||
const result = forAll(gen, prop, 200, 3)
|
||||
assert.deepStrictEqual({ ok: result.ok, run: result.run, value: result.value }, { ok: false, run: first, value: 7 })
|
||||
assert.equal(run(gen, result.seed)[0], 7)
|
||||
})
|
||||
|
||||
test('m12 forAll stops at the first failure: the property is not asked about later values', () => {
|
||||
const gen = intBelow(10)
|
||||
const [draws] = run(listOf(gen, 200), 3)
|
||||
const first = draws.findIndex((x) => x === 7)
|
||||
let calls = 0 // test code may count; the code under test may not
|
||||
const result = forAll(gen, (x) => { calls = calls + 1; return x !== 7 }, 200, 3)
|
||||
assert.equal(result.run, first)
|
||||
assert.equal(calls, first + 1)
|
||||
})
|
||||
|
||||
test('m12 forAll is reproducible: same generator, property, runs and seed give the same report', () =>
|
||||
assert.deepStrictEqual(forAll(intBelow(1000), (x) => x % 97 !== 0, 500, 11), forAll(intBelow(1000), (x) => x % 97 !== 0, 500, 11)))
|
||||
|
||||
// ---- shrinking
|
||||
|
||||
test('m12 removals lists every way to drop one element, in order', () =>
|
||||
assert.deepStrictEqual(removals(freeze([1, 2, 3])), [[2, 3], [1, 3], [1, 2]]))
|
||||
|
||||
test('m12 removals of an empty list is empty, and of a single element is one empty list', () => {
|
||||
assert.deepStrictEqual(removals([]), [])
|
||||
assert.deepStrictEqual(removals(['a']), [[]])
|
||||
})
|
||||
|
||||
test('m12 minimize keeps exactly the elements the failure needs', () =>
|
||||
assert.deepStrictEqual(
|
||||
minimize(freeze([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), (xs) => xs.includes(3) && xs.includes(7)),
|
||||
[3, 7]))
|
||||
|
||||
test('m12 minimize keeps the order and the duplicates the failure needs', () =>
|
||||
assert.deepStrictEqual(
|
||||
minimize(freeze(['b', 'a', 'c', 'a', 'd', 'a']), (xs) => xs.filter((x) => x === 'a').length >= 2 && xs.includes('c')),
|
||||
['c', 'a', 'a']))
|
||||
|
||||
test('m12 minimize of a list that fails whatever you remove is the empty list', () =>
|
||||
assert.deepStrictEqual(minimize(freeze([1, 2, 3]), () => true), []))
|
||||
|
||||
test('m12 minimize returns a list that still fails and has no failing removal (1-minimal)', () => {
|
||||
const fails = (xs) => xs.reduce((a, b) => a + b, 0) >= 10
|
||||
const small = minimize(freeze([5, 1, 4, 2, 6, 3]), fails)
|
||||
assert.ok(fails(small))
|
||||
assert.ok(removals(small).every((ys) => !fails(ys)))
|
||||
})
|
||||
|
||||
// ---- invariants
|
||||
|
||||
const fx = loadFixture('m12-violations')
|
||||
fx.cases.forEach((c) =>
|
||||
test(`m12 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(violations(...freeze(c.args)), c.expected)))
|
||||
|
||||
// ---- moveGaps
|
||||
|
||||
const at = (x, y) => ({ snake: [{ x, y }, { x: x - 1, y }] })
|
||||
|
||||
test('m12 moveGaps measures the distance between consecutive moves of the head', () =>
|
||||
assert.deepStrictEqual(
|
||||
moveGaps(freeze([at(1, 0), at(1, 0), at(2, 0), at(2, 0), at(2, 0), at(3, 0), at(3, 1)])),
|
||||
[3, 1]))
|
||||
|
||||
test('m12 moveGaps of fewer than two moves is empty', () => {
|
||||
assert.deepStrictEqual(moveGaps([]), [])
|
||||
assert.deepStrictEqual(moveGaps(freeze([at(1, 0)])), [])
|
||||
assert.deepStrictEqual(moveGaps(freeze([at(1, 0), at(1, 0), at(2, 0), at(2, 0)])), [])
|
||||
})
|
||||
|
||||
test('m12 moveGaps counts a move at index 1', () =>
|
||||
assert.deepStrictEqual(moveGaps(freeze([at(1, 0), at(2, 0), at(2, 0), at(3, 0)])), [2]))
|
||||
|
||||
test('m12 moveGaps treats an empty snake as a head of its own', () => {
|
||||
const snake = (...cells) => ({ snake: cells })
|
||||
assert.deepStrictEqual(
|
||||
moveGaps(freeze([snake(), snake(), snake({ x: 1, y: 0 }), snake({ x: 1, y: 0 }), snake({ x: 2, y: 0 })])), [2])
|
||||
})
|
||||
|
||||
// ---- the payoff: properties of the game, and deliberately broken games ("mutants") that they must catch
|
||||
|
||||
// Every state of the game played with `upd` from newGame(1). (For the real `update` this is `history`.)
|
||||
const statesWith = (upd, start, actions) => actions.reduce((acc, a) => [...acc, upd(acc[acc.length - 1], a)], [start])
|
||||
const sane = (upd) => (actions) => statesWith(upd, newGame(1), actions).every((s) => violations(s).length === 0)
|
||||
|
||||
test('m12 statesWith(update, ...) in these tests is the history of Module 5', () => {
|
||||
const [actions] = run(genActions(25), 9)
|
||||
assert.deepStrictEqual(statesWith(update, newGame(1), actions), history(newGame(1), actions))
|
||||
})
|
||||
|
||||
test('m12 the real game keeps every invariant on 300 random games of 40 actions', () =>
|
||||
assert.deepStrictEqual(forAll(genActions(40), sane(update), 300, 2024), { ok: true, runs: 300 }))
|
||||
|
||||
// Mutant 1: `turn` checks against nextDir instead of dir (version A of Puzzle 2 in Module 3).
|
||||
const turnAgainstNextDir = (state, action) =>
|
||||
action.tag !== 'Turn' ? update(state, action)
|
||||
: state.status === 'Playing' && action.dir !== opposite(state.nextDir) ? { ...state, nextDir: action.dir }
|
||||
: state
|
||||
|
||||
test('m12 the quick double turn bug is found and shrunk to its two key presses', () => {
|
||||
const prop = sane(turnAgainstNextDir)
|
||||
const result = forAll(genActions(40), prop, 300, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
const small = minimize(result.value, (actions) => !prop(actions))
|
||||
assert.equal(small.length, 2)
|
||||
assert.ok(small.every((a) => a.tag === 'Turn'))
|
||||
assert.ok(['Up', 'Down'].includes(small[0].dir))
|
||||
assert.equal(small[1].dir, 'Left')
|
||||
})
|
||||
|
||||
// Mutant 2: `advance` forgets to check the walls.
|
||||
const advanceWithoutWalls = (state) => {
|
||||
if (state.status !== 'Playing') return state
|
||||
const head = move(state.snake[0], state.nextDir)
|
||||
const eating = state.apple !== null && pointEquals(head, state.apple)
|
||||
const body = eating ? state.snake : dropLast(state.snake)
|
||||
return contains(body, head, pointEquals)
|
||||
? { ...state, dir: state.nextDir, status: 'GameOver' }
|
||||
: { ...state, snake: [head, ...body], dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) }
|
||||
}
|
||||
// What `update` does after a step since Exercise 10.6: take the queued turn. The mutants below that replace `Tick`
|
||||
// do the same, so that each one differs from the real game in exactly one thing.
|
||||
const takeQueued = (s) => (s.status === 'Playing' && s.queued !== null ? { ...s, nextDir: s.queued, queued: null } : s)
|
||||
const noWalls = (state, action) =>
|
||||
action.tag === 'Tick' ? takeQueued(spawnApple(advanceWithoutWalls(state))) : update(state, action)
|
||||
|
||||
test('m12 a game without walls is found and shrunk to the shortest way off the board: ten ticks', () => {
|
||||
const prop = sane(noWalls)
|
||||
const result = forAll(genActions(40), prop, 300, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepStrictEqual(minimize(result.value, (actions) => !prop(actions)), Array.from({ length: 10 }, () => Action.Tick))
|
||||
})
|
||||
|
||||
// Mutant 4: a `Tick` that forgets the turn queue of Exercise 10.6 (the `Frame` step still takes it).
|
||||
const tickForgetsQueue = (state, action) => (action.tag === 'Tick' ? tick(state) : update(state, action))
|
||||
|
||||
test('m12 a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions', () => {
|
||||
const prop = sane(tickForgetsQueue)
|
||||
const result = forAll(genActions(40), prop, 300, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepStrictEqual(
|
||||
minimize(result.value, (actions) => !prop(actions)),
|
||||
[Action.Turn('Up'), Action.Turn('Right'), Action.Tick, Action.Turn('Left')])
|
||||
})
|
||||
|
||||
// Mutant 5: the queue accepts any key that differs from the pending turn, even the one straight back from it.
|
||||
const queueAnyKey = (state, action) =>
|
||||
action.tag === 'Turn' && state.status === 'Playing' && state.nextDir !== state.dir && action.dir !== state.nextDir
|
||||
? { ...state, queued: action.dir }
|
||||
: update(state, action)
|
||||
|
||||
test('m12 a queue that accepts a reversal is found and shrunk to its two key presses', () => {
|
||||
const prop = sane(queueAnyKey)
|
||||
const result = forAll(genActions(40), prop, 300, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepStrictEqual(minimize(result.value, (actions) => !prop(actions)), [Action.Turn('Up'), Action.Turn('Down')])
|
||||
})
|
||||
|
||||
// Mutant 3: the frame rule this course used at first: step whenever the *total* frame count is a multiple of movesEvery.
|
||||
const totalFrameCount = (state, action) => {
|
||||
if (action.tag !== 'Frame') return update(state, action)
|
||||
if (state.status !== 'Playing') return state
|
||||
const counted = { ...state, frame: state.frame + 1 }
|
||||
return counted.frame % movesEvery(counted.score) === 0 ? tick(counted) : counted
|
||||
}
|
||||
|
||||
// A long corridor: the snake is one step from its third apple (score 2 -> 3, so the speed changes), `frame` frames into the game.
|
||||
const corridor = (frame) => ({
|
||||
width: 60, height: 1, snake: [{ x: 1, y: 0 }, { x: 0, y: 0 }], dir: 'Right', nextDir: 'Right',
|
||||
apple: { x: 2, y: 0 }, seed: 0, score: 2, status: 'Playing', frame, queued: null,
|
||||
})
|
||||
const frames = Array.from({ length: 40 }, () => Action.Frame)
|
||||
const steady = (upd) => (frame) => {
|
||||
const states = statesWith(upd, corridor(frame), frames)
|
||||
const scoreAtMove = states.filter((s, i) => i > 0 && !pointEquals(s.snake[0], states[i - 1].snake[0])).map((s) => s.score)
|
||||
return moveGaps(states).every((gap, k) => gap === movesEvery(scoreAtMove[k]))
|
||||
}
|
||||
|
||||
test('m12 the real game moves exactly every movesEvery(score) frames, also across a speed-up', () =>
|
||||
assert.deepStrictEqual(forAll(intBelow(8), steady(update), 100, 7), { ok: true, runs: 100 }))
|
||||
|
||||
test('m12 counting frames from the start of the game makes some steps too short, and the property finds one', () => {
|
||||
const result = forAll(intBelow(56), steady(totalFrameCount), 100, 7)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(steady(totalFrameCount)(result.value), false)
|
||||
})
|
||||
|
||||
// ---- random states (Exercise 12.6)
|
||||
|
||||
const connected = (snake) => snake.slice(1).every((p, i) => Math.abs(p.x - snake[i].x) + Math.abs(p.y - snake[i].y) === 1)
|
||||
|
||||
test('m12 genSnake draws the head first, then walks from the tail (seed 0, 5x5, at most 6 cells)', () =>
|
||||
assert.deepStrictEqual(run(genSnake(5, 5, 6), 0), [
|
||||
[{ x: 3, y: 2 }, { x: 3, y: 1 }, { x: 3, y: 0 }, { x: 2, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 0 }], 4231026099]))
|
||||
|
||||
test('m12 genSnake is pinned for a few more seeds', () => {
|
||||
assert.deepStrictEqual(run(genSnake(5, 5, 6), 1)[0],
|
||||
[{ x: 2, y: 3 }, { x: 3, y: 3 }, { x: 4, y: 3 }, { x: 4, y: 4 }, { x: 3, y: 4 }, { x: 2, y: 4 }])
|
||||
assert.deepStrictEqual(run(genSnake(5, 5, 6), 42)[0],
|
||||
[{ x: 1, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 2, y: 0 }, { x: 3, y: 0 }, { x: 3, y: 1 }])
|
||||
})
|
||||
|
||||
test('m12 genSnake with maxLen 1 is just the head, drawn with two draws', () => {
|
||||
const [x, s1] = run(intBelow(5), 0)
|
||||
const [y, s2] = run(intBelow(5), s1)
|
||||
assert.deepStrictEqual(run(genSnake(5, 5, 1), 0), [[{ x, y }], s2])
|
||||
})
|
||||
|
||||
test('m12 genSnake stops early when the walk is stuck: a 1x1 board holds one cell', () =>
|
||||
assert.deepStrictEqual(run(genSnake(1, 1, 5), 3)[0], [{ x: 0, y: 0 }]))
|
||||
|
||||
test('m12 genSnake always gives a snake on the board, connected, without overlap, at most maxLen long', () =>
|
||||
assert.deepStrictEqual(
|
||||
forAll(genSnake(4, 3, 8), (snake) =>
|
||||
snake.length >= 1 && snake.length <= 8 && connected(snake)
|
||||
&& snake.every((p) => inBounds({ width: 4, height: 3 }, p))
|
||||
&& snake.every((p, i) => !contains(snake.slice(i + 1), p, pointEquals)), 300, 5),
|
||||
{ ok: true, runs: 300 }))
|
||||
|
||||
test('m12 every generated state is sane: 500 states of a 5x5 board have no violations', () =>
|
||||
assert.deepStrictEqual(forAll(genState(5, 5), (s) => violations(s).length === 0, 500, 2024), { ok: true, runs: 500 }))
|
||||
|
||||
test('m12 generated states start with seed 0, score 0 and frame 0', () => {
|
||||
const [states] = run(listOf(genState(5, 5), 50), 2024)
|
||||
assert.ok(states.every((s) => s.seed === 0 && s.score === 0 && s.frame === 0))
|
||||
})
|
||||
|
||||
test('m12 generated states are Playing, have nothing queued, and vary in length and apple', () => {
|
||||
const [states] = run(listOf(genState(5, 5), 500), 2024)
|
||||
assert.ok(states.every((s) => s.status === 'Playing' && s.queued === null && s.width === 5 && s.height === 5))
|
||||
assert.ok(states.every((s) => s.snake.length === 1 ? s.dir === 'Right' : pointEquals(move(s.snake[1], s.dir), s.snake[0])))
|
||||
assert.ok(states.some((s) => s.snake.length === 1) && states.some((s) => s.snake.length >= 10))
|
||||
assert.ok(states.some((s) => s.apple === null) && states.some((s) => s.apple !== null))
|
||||
assert.ok(states.some((s) => s.nextDir !== s.dir))
|
||||
})
|
||||
|
||||
// ---- a second implementation as an oracle (Exercise 12.7)
|
||||
|
||||
const m03 = loadFixture('m03-advance')
|
||||
m03.cases.forEach((c) =>
|
||||
test(`m12 advanceSpec agrees with Module 3's advance fixture: ${c.name}`, () =>
|
||||
assert.deepStrictEqual(advanceSpec(...freeze(c.args)), c.expected)))
|
||||
|
||||
test('m12 advance and advanceSpec agree on 500 generated states of a 5x5 board', () =>
|
||||
assert.deepStrictEqual(
|
||||
forAll(genState(5, 5), (s) => isDeepStrictEqual(advance(freeze(s)), advanceSpec(s)), 500, 2024),
|
||||
{ ok: true, runs: 500 }))
|
||||
|
||||
// Mutant 6: Puzzle 1 of Module 3, "the new head must not be on the old snake" (it forgets that the tail moves away).
|
||||
const advanceOldSnakeCheck = (state) => {
|
||||
if (state.status !== 'Playing') return state
|
||||
const head = move(state.snake[0], state.nextDir)
|
||||
const eating = state.apple !== null && pointEquals(head, state.apple)
|
||||
const body = eating ? state.snake : dropLast(state.snake)
|
||||
return !inBounds(state, head) || contains(state.snake, head, pointEquals)
|
||||
? { ...state, dir: state.nextDir, status: 'GameOver' }
|
||||
: { ...state, snake: [head, ...body], dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) }
|
||||
}
|
||||
|
||||
test('m12 no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane', () =>
|
||||
assert.deepStrictEqual(
|
||||
forAll(genState(5, 5), (s) => violations(advanceOldSnakeCheck(s)).length === 0, 500, 2024),
|
||||
{ ok: true, runs: 500 }))
|
||||
|
||||
test('m12 comparing with advanceSpec catches the wrong collision check: the head was moving onto the tail', () => {
|
||||
const result = forAll(genState(5, 5), (s) => isDeepStrictEqual(advanceOldSnakeCheck(s), advanceSpec(s)), 500, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.run, 21)
|
||||
const s = result.value
|
||||
assert.deepStrictEqual(move(s.snake[0], s.nextDir), s.snake[s.snake.length - 1])
|
||||
assert.equal(advanceOldSnakeCheck(s).status, 'GameOver')
|
||||
assert.equal(advanceSpec(s).status, 'Playing')
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { LW, LH, LCELL, STRIP, initialLife, LifeAction } from '../src/life/model.js'
|
||||
import { lifeStep, lifeUpdate } from '../src/life/rules.js'
|
||||
import { lifeView } from '../src/life/view.js'
|
||||
import { GEN_MS, lifeKey, lifeClick, lifeActions, lifeStates, lifeMain } from '../src/life/app.js'
|
||||
import { clicks, keyDowns, keyUps, interval, clampTime, run } from '../src/stream.js'
|
||||
import { trace, pure, draw } from '../src/io.js'
|
||||
import { intBelow } from '../src/random.js'
|
||||
import { runApp } from '../src/runtime.js'
|
||||
import { browserPlatform } from '../src/browser.js'
|
||||
import { map as mapStream } from '../src/stream.js'
|
||||
import { speedActions, main, play } from '../src/app.js'
|
||||
import { pongActions } from '../src/pong/app.js'
|
||||
import { loadFixture, freeze } from './helpers.js'
|
||||
|
||||
const fixtureTests = (name, f) => {
|
||||
const fx = loadFixture(name)
|
||||
fx.cases.forEach((c) =>
|
||||
test(`m13 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected)))
|
||||
}
|
||||
|
||||
fixtureTests('m13-life-step', lifeStep)
|
||||
fixtureTests('m13-life-update', lifeUpdate)
|
||||
fixtureTests('m13-life-view', lifeView)
|
||||
fixtureTests('m13-life-key', lifeKey)
|
||||
fixtureTests('m13-life-click', lifeClick)
|
||||
|
||||
const time = (t) => ({ tag: 'Time', t })
|
||||
const down = (key) => ({ tag: 'KeyDown', key })
|
||||
const click = (x, y) => ({ tag: 'Click', x, y })
|
||||
const cell = (x, y) => ({ x, y })
|
||||
const rowMajor = (cells) => cells.every((c, i) => i === 0 || cells[i - 1].y < c.y || (cells[i - 1].y === c.y && cells[i - 1].x < c.x))
|
||||
|
||||
// ---- the model and the rules
|
||||
|
||||
test('m13 the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below', () =>
|
||||
assert.deepStrictEqual([LW, LH, LCELL, STRIP], [40, 30, 16, 24]))
|
||||
|
||||
test('m13 initialLife is an empty, paused world at generation 0 with the given seed', () =>
|
||||
assert.deepStrictEqual(initialLife(9), { cells: [], running: false, seed: 9, generation: 0 }))
|
||||
|
||||
test('m13 LifeAction builds Toggle with a cell, and has the other actions as plain values', () =>
|
||||
assert.deepStrictEqual(
|
||||
[LifeAction.Frame, LifeAction.Toggle(cell(1, 2)), LifeAction.TogglePause, LifeAction.Step, LifeAction.Clear, LifeAction.Randomize],
|
||||
[{ tag: 'Frame' }, { tag: 'Toggle', cell: { x: 1, y: 2 } }, { tag: 'TogglePause' }, { tag: 'Step' }, { tag: 'Clear' },
|
||||
{ tag: 'Randomize' }]))
|
||||
|
||||
test('m13 lifeUpdate does not modify the state it is given', () => {
|
||||
const s = freeze({ cells: [cell(4, 5), cell(5, 5), cell(6, 5)], running: true, seed: 3, generation: 0 })
|
||||
const before = structuredClone(s)
|
||||
;[LifeAction.Frame, LifeAction.Toggle(cell(0, 0)), LifeAction.Toggle(cell(5, 5)), LifeAction.Randomize, LifeAction.Clear]
|
||||
.forEach((a) => assert.doesNotThrow(() => lifeUpdate(s, a)))
|
||||
assert.deepStrictEqual(s, before)
|
||||
})
|
||||
|
||||
test('m13 Toggle of a cell in the row just below the board changes nothing', () => {
|
||||
const s = freeze({ cells: [cell(4, 5)], running: false, seed: 3, generation: 2 })
|
||||
assert.deepStrictEqual(lifeUpdate(s, LifeAction.Toggle(cell(3, LH))), s)
|
||||
})
|
||||
|
||||
test('m13 a glider moves one cell diagonally every four generations', () => {
|
||||
const glider = [cell(1, 0), cell(2, 1), cell(0, 2), cell(1, 2), cell(2, 2)]
|
||||
const after4 = [1, 2, 3, 4].reduce((cells) => lifeStep(cells), freeze(glider))
|
||||
assert.deepStrictEqual(after4, [cell(2, 1), cell(3, 2), cell(1, 3), cell(2, 3), cell(3, 3)])
|
||||
})
|
||||
|
||||
test('m13 the next generation is computed from the whole old board: a blinker blinks forever', () => {
|
||||
const horizontal = [cell(9, 10), cell(10, 10), cell(11, 10)]
|
||||
const gens = Array.from({ length: 6 }).reduce((acc) => [...acc, lifeStep(acc.at(-1))], [freeze(horizontal)])
|
||||
assert.deepStrictEqual(gens[2], horizontal)
|
||||
assert.deepStrictEqual(gens[6], horizontal)
|
||||
assert.deepStrictEqual(gens[1], [cell(10, 9), cell(10, 10), cell(10, 11)])
|
||||
})
|
||||
|
||||
test('m13 Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0', () => {
|
||||
const seed = 2024
|
||||
// The same draws, written the long way round with the Module 4 generator.
|
||||
const [draws, next] = Array.from({ length: LW * LH }).reduce(([ds, s]) => {
|
||||
const [d, s2] = intBelow(4)(s)
|
||||
return [[...ds, d], s2]
|
||||
}, [[], seed])
|
||||
const expected = draws.flatMap((d, i) => (d === 0 ? [cell(i % LW, Math.floor(i / LW))] : []))
|
||||
const result = lifeUpdate(freeze(initialLife(seed)), LifeAction.Randomize)
|
||||
assert.deepStrictEqual(result.cells, expected)
|
||||
assert.equal(result.seed, next)
|
||||
assert.ok(result.cells.length > 200 && result.cells.length < 400, `${result.cells.length} live cells`)
|
||||
})
|
||||
|
||||
test('m13 every generation of a random board stays sorted row-major, on the board and without duplicates', () => {
|
||||
const start = lifeUpdate(initialLife(5), LifeAction.Randomize).cells
|
||||
const gens = Array.from({ length: 20 }).reduce((acc) => [...acc, lifeStep(acc.at(-1))], [freeze(start)])
|
||||
gens.forEach((cells) => {
|
||||
assert.ok(rowMajor(cells))
|
||||
assert.ok(cells.every((c) => c.x >= 0 && c.x < LW && c.y >= 0 && c.y < LH))
|
||||
})
|
||||
})
|
||||
|
||||
// Generous on purpose: a straightforward lifeStep takes a few milliseconds per generation. This only catches a
|
||||
// generation that is far slower than the 150 ms between two of them.
|
||||
test('m13 lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms', () => {
|
||||
const start = lifeUpdate(initialLife(11), LifeAction.Randomize).cells
|
||||
const t0 = Date.now()
|
||||
Array.from({ length: 50 }).reduce((cells) => lifeStep(cells), start)
|
||||
assert.ok(Date.now() - t0 < 2500, `${Date.now() - t0} ms`)
|
||||
})
|
||||
|
||||
// ---- the fourth kind of input
|
||||
|
||||
test('m13 clicks emits the position of every Click input and ignores everything else', () =>
|
||||
assert.deepStrictEqual(
|
||||
run(clicks, freeze([time(10), click(3, 4), down('a'), click(0, 479), { tag: 'KeyUp', key: 'a' }])),
|
||||
[{ x: 3, y: 4 }, { x: 0, y: 479 }]))
|
||||
|
||||
test('m13 the other sources ignore clicks', () => {
|
||||
const inputs = freeze([click(5, 5), time(100), click(6, 6)])
|
||||
assert.deepStrictEqual(run(keyDowns, inputs), [])
|
||||
assert.deepStrictEqual(run(keyUps, inputs), [])
|
||||
assert.deepStrictEqual(run(interval(50), inputs), [50, 100])
|
||||
assert.deepStrictEqual(run(clampTime(clicks, 250), inputs), [{ x: 5, y: 5 }, { x: 6, y: 6 }])
|
||||
})
|
||||
|
||||
test('m13 a generation every 150 ms', () => assert.equal(GEN_MS, 150))
|
||||
|
||||
test('m13 lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles', () =>
|
||||
assert.deepStrictEqual(
|
||||
run(lifeActions, freeze([time(150), down(' '), click(20, 40), down('x'), click(700, 10), down('n'), time(450)])),
|
||||
[LifeAction.Frame, LifeAction.TogglePause, LifeAction.Toggle(cell(1, 2)), LifeAction.Step, LifeAction.Frame,
|
||||
LifeAction.Frame]))
|
||||
|
||||
test('m13 lifeStates starts with the new world, then yields one state per action', () => {
|
||||
const out = run(lifeStates(7, lifeActions), freeze([time(0), click(16, 16), click(32, 16), click(48, 16), down(' '), time(150)]))
|
||||
assert.deepStrictEqual(out[0], initialLife(7))
|
||||
assert.deepStrictEqual(out.map((s) => s.cells.length), [0, 1, 2, 3, 3, 3])
|
||||
assert.deepStrictEqual(out.at(-1), { cells: [cell(2, 0), cell(2, 1), cell(2, 2)], running: true, seed: 7, generation: 1 })
|
||||
})
|
||||
|
||||
test('m13 lifeMain asks for a seed, draws the empty world first, and then a click as a live cell', () => {
|
||||
const { ops, result: stream } = trace(lifeMain, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
const [first, next] = stream.step(time(0))
|
||||
const [second] = next.step(click(17, 33))
|
||||
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), lifeView(initialLife(42)))
|
||||
assert.deepStrictEqual(
|
||||
trace(second[0], 0).ops.map((o) => o.shape),
|
||||
lifeView({ ...initialLife(42), cells: [cell(1, 2)] }))
|
||||
})
|
||||
|
||||
test('m13 lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds', () => {
|
||||
const [, afterFirst] = trace(lifeMain, 42).result.step(time(0))
|
||||
assert.equal(afterFirst.step(time(10000))[0].length, 1)
|
||||
})
|
||||
|
||||
// ---- the runtime and the browser platform (step 6 and 7 of the module)
|
||||
|
||||
const fakePlatform = () => {
|
||||
const calls = []
|
||||
const handlers = {}
|
||||
return {
|
||||
calls,
|
||||
fillRect: (...args) => calls.push(['fillRect', ...args]),
|
||||
fillText: (...args) => calls.push(['fillText', ...args]),
|
||||
randomSeed: () => 42,
|
||||
loadBest: () => 0,
|
||||
saveBest: () => {},
|
||||
startClock: (cb) => { handlers.time = cb },
|
||||
onKeyDown: (cb) => { handlers.down = cb },
|
||||
onKeyUp: (cb) => { handlers.up = cb },
|
||||
onClick: (cb) => { handlers.click = cb },
|
||||
fireTime: (t) => handlers.time(t),
|
||||
fireClick: (x, y) => handlers.click(x, y),
|
||||
registered: () => Object.keys(handlers).sort(),
|
||||
}
|
||||
}
|
||||
|
||||
test('m13 runApp registers a click handler with the platform', () => {
|
||||
const p = fakePlatform()
|
||||
runApp(pure(mapStream(clicks, () => pure(undefined))), p)
|
||||
assert.deepStrictEqual(p.registered(), ['click', 'down', 'time', 'up'])
|
||||
})
|
||||
|
||||
test('m13 runApp feeds clicks to the stream', () => {
|
||||
const p = fakePlatform()
|
||||
const program = pure(mapStream(clicks, ({ x, y }) => draw({ tag: 'Rect', x, y, w: 1, h: 1, color: '#ffffff' })))
|
||||
runApp(program, p)
|
||||
p.fireClick(33, 47)
|
||||
p.fireClick(0, 2)
|
||||
assert.deepStrictEqual(p.calls, [['fillRect', 33, 47, 1, 1, '#ffffff'], ['fillRect', 0, 2, 1, 1, '#ffffff']])
|
||||
})
|
||||
|
||||
test('m13 runApp runs Life: a click on the canvas draws the world with that cell alive', () => {
|
||||
const p = fakePlatform()
|
||||
runApp(lifeMain, p)
|
||||
p.fireTime(0)
|
||||
p.calls.length = 0
|
||||
p.fireClick(100, 250)
|
||||
assert.ok(p.calls.some((c) => c[0] === 'fillRect' && c[1] === 6 * 16 + 1 && c[2] === 15 * 16 + 1 && c[5] === '#7cb342'))
|
||||
})
|
||||
|
||||
test('m13 Snake and Pong ignore clicks: their actions and drawings are the same with and without them', () => {
|
||||
const withClicks = freeze([time(0), click(5, 5), time(25), down('ArrowUp'), click(300, 300), time(200)])
|
||||
const without = withClicks.filter((i) => i.tag !== 'Click')
|
||||
assert.deepStrictEqual(run(speedActions, withClicks), run(speedActions, without))
|
||||
assert.deepStrictEqual(run(pongActions, withClicks), run(pongActions, without))
|
||||
assert.deepStrictEqual(play(main, 42, withClicks), play(main, 42, without))
|
||||
})
|
||||
|
||||
// A fake canvas shown at half its size (CSS), with a 2-pixel border, 100 px from the left and 50 px from the top.
|
||||
const fakeCanvas = () => {
|
||||
const listeners = []
|
||||
return {
|
||||
listeners,
|
||||
width: 640, height: 504, clientWidth: 320, clientHeight: 252, clientLeft: 2, clientTop: 2,
|
||||
getContext: () => ({}),
|
||||
getBoundingClientRect: () => ({ left: 100, top: 50, width: 324, height: 256 }),
|
||||
addEventListener: (type, f) => listeners.push([type, f]),
|
||||
}
|
||||
}
|
||||
const fakeWindow = { addEventListener: () => {}, requestAnimationFrame: () => {}, localStorage: null }
|
||||
|
||||
test('m13 browserPlatform listens for clicks on the canvas, not before onClick is called', () => {
|
||||
const canvas = fakeCanvas()
|
||||
const platform = browserPlatform(canvas, fakeWindow)
|
||||
assert.deepStrictEqual(canvas.listeners, [])
|
||||
platform.onClick(() => {})
|
||||
assert.deepStrictEqual(canvas.listeners.map(([type]) => type), ['click'])
|
||||
})
|
||||
|
||||
test('m13 browserPlatform converts a click to canvas pixels, through the border and the CSS scaling', () => {
|
||||
const canvas = fakeCanvas()
|
||||
const got = []
|
||||
browserPlatform(canvas, fakeWindow).onClick((x, y) => got.push([x, y]))
|
||||
const [, listener] = canvas.listeners[0]
|
||||
listener({ clientX: 100 + 2 + 10, clientY: 50 + 2 + 20 })
|
||||
listener({ clientX: 100 + 2 + 319.75, clientY: 50 + 2 + 0.4 })
|
||||
assert.deepStrictEqual(got, [[20, 40], [639, 0]])
|
||||
})
|
||||
@@ -7,7 +7,8 @@ import { dirname, join, basename } from 'node:path'
|
||||
const srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
||||
|
||||
// Files that are allowed to touch the outside world.
|
||||
const excluded = (file) => ['runtime.js', 'browser.js', 'main.js'].includes(basename(file))
|
||||
// The entry files (main.js, life/main.js, pong/main.js and the replay viewer's replay-main.js) are given browser glue.
|
||||
const excluded = (file) => ['runtime.js', 'browser.js', 'main.js', 'replay-main.js'].includes(basename(file))
|
||||
|
||||
const banned = [
|
||||
['let', /\blet\b/],
|
||||
@@ -32,6 +33,7 @@ const banned = [
|
||||
const stripComments = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||
const violations = (code) => banned.filter(([, re]) => re.test(stripComments(code))).map(([name]) => name)
|
||||
|
||||
// Only JavaScript is scanned. src/types.d.ts (the optional type declarations) holds no code that runs, so it is skipped.
|
||||
const jsFiles = (dir) =>
|
||||
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory() ? jsFiles(join(dir, e.name)) : e.name.endsWith('.js') ? [join(dir, e.name)] : [])
|
||||
@@ -74,6 +76,9 @@ jsFiles(srcDir)
|
||||
test(`purity: ${f.slice(srcDir.length + 1)} has no banned constructs`, () =>
|
||||
assert.deepEqual(violations(readFileSync(f, 'utf8')), [])))
|
||||
|
||||
test('the purity audit skips .d.ts files', () =>
|
||||
assert.deepEqual(jsFiles(srcDir).filter((f) => f.endsWith('.d.ts')), []))
|
||||
|
||||
// helpers.js is loaded by every test file; importing src/ there would make all tests fail at load time against the stubs.
|
||||
test('test/helpers.js imports nothing from src/', () =>
|
||||
assert.doesNotMatch(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'helpers.js'), 'utf8'), /from\s+['"][^'"]*src\//))
|
||||
Reference in new issue
Block a user