feat(js): m09 input, app, runtime, browser, main, index.html and smoke test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
584f1d4825
commit
8028bb2fc0
@@ -0,0 +1,160 @@
|
|||||||
|
{
|
||||||
|
"function": "keyToAction",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "ArrowUp-turns-up",
|
||||||
|
"args": [
|
||||||
|
"ArrowUp"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Up"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "w-turns-up",
|
||||||
|
"args": [
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Up"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "W-turns-up",
|
||||||
|
"args": [
|
||||||
|
"W"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Up"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ArrowDown-turns-down",
|
||||||
|
"args": [
|
||||||
|
"ArrowDown"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Down"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "s-turns-down",
|
||||||
|
"args": [
|
||||||
|
"s"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Down"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "S-turns-down",
|
||||||
|
"args": [
|
||||||
|
"S"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Down"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ArrowLeft-turns-left",
|
||||||
|
"args": [
|
||||||
|
"ArrowLeft"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Left"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "a-turns-left",
|
||||||
|
"args": [
|
||||||
|
"a"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Left"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "A-turns-left",
|
||||||
|
"args": [
|
||||||
|
"A"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Left"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ArrowRight-turns-right",
|
||||||
|
"args": [
|
||||||
|
"ArrowRight"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Right"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "d-turns-right",
|
||||||
|
"args": [
|
||||||
|
"d"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Right"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "D-turns-right",
|
||||||
|
"args": [
|
||||||
|
"D"
|
||||||
|
],
|
||||||
|
"expected": {
|
||||||
|
"tag": "Turn",
|
||||||
|
"dir": "Right"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "x-is-ignored",
|
||||||
|
"args": [
|
||||||
|
"x"
|
||||||
|
],
|
||||||
|
"expected": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Enter-is-ignored",
|
||||||
|
"args": [
|
||||||
|
"Enter"
|
||||||
|
],
|
||||||
|
"expected": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "empty-string-is-ignored",
|
||||||
|
"args": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"expected": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "space-is-ignored (until m10)",
|
||||||
|
"args": [
|
||||||
|
" "
|
||||||
|
],
|
||||||
|
"expected": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "r-is-ignored (until m10)",
|
||||||
|
"args": [
|
||||||
|
"r"
|
||||||
|
],
|
||||||
|
"expected": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pure Snake</title>
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; gap: 12px; background: #1b1b1b; color: #ddd; font: 14px system-ui, sans-serif; }
|
||||||
|
h1 { margin: 0; font-size: 20px; }
|
||||||
|
canvas { background: #111111; border: 2px solid #333; }
|
||||||
|
p { margin: 0; color: #999; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Pure Snake</h1>
|
||||||
|
<canvas id="game" width="400" height="400"></canvas>
|
||||||
|
<p>Arrows / WASD to move. Space to pause, R to restart (after module 10).</p>
|
||||||
|
<script type="module" src="src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Action } from './model.js'
|
||||||
|
import { update } from './game.js'
|
||||||
|
import { newGame } from './spawn.js'
|
||||||
|
import { view } from './view.js'
|
||||||
|
import { map as mapIO, randomSeed, drawScene } from './io.js'
|
||||||
|
import { keyDowns, interval, map, filterMap, merge, scan, startWith } from './stream.js'
|
||||||
|
import { keyToAction } from './input.js'
|
||||||
|
|
||||||
|
export const TICK_MS = 150
|
||||||
|
|
||||||
|
// Everything that can happen to the game: a Tick every TICK_MS, and a Turn for each steering key.
|
||||||
|
export const classicActions = merge(
|
||||||
|
map(interval(TICK_MS), () => Action.Tick),
|
||||||
|
filterMap(keyDowns, keyToAction))
|
||||||
|
|
||||||
|
// Actions folded into game states. startWith puts the initial state first so it is drawn right away.
|
||||||
|
export const states = (seed, actions) =>
|
||||||
|
startWith(scan(actions, update, newGame(seed)), newGame(seed))
|
||||||
|
|
||||||
|
export const render = (state) => drawScene(view(state))
|
||||||
|
|
||||||
|
// The whole program as a value: get a seed, then turn the stream of states into a stream of drawings.
|
||||||
|
// Nothing runs here; runtime.js interprets it.
|
||||||
|
export const main = mapIO(randomSeed, (seed) => map(states(seed, classicActions), render))
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Browser glue: turns a canvas and a window into a Platform. Impure by nature; not part of the exercises' logic.
|
||||||
|
|
||||||
|
const PREVENT = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '])
|
||||||
|
|
||||||
|
export const browserPlatform = (canvas, win) => {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
// Keys that scroll the page should only steer the game.
|
||||||
|
const onKey = (cb) => (e) => {
|
||||||
|
if (PREVENT.has(e.key)) e.preventDefault()
|
||||||
|
cb(e.key)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fillRect: (x, y, w, h, color) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fillRect(x, y, w, h)
|
||||||
|
},
|
||||||
|
fillText: (x, y, text, color, size, align) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${size}px monospace`
|
||||||
|
ctx.textAlign = align
|
||||||
|
ctx.fillText(text, x, y)
|
||||||
|
},
|
||||||
|
randomSeed: () => crypto.getRandomValues(new Uint32Array(1))[0],
|
||||||
|
// t is the number of milliseconds since the first frame.
|
||||||
|
startClock: (onTime) => {
|
||||||
|
const frame = (start) => (now) => {
|
||||||
|
const t0 = start ?? now
|
||||||
|
onTime(now - t0)
|
||||||
|
win.requestAnimationFrame(frame(t0))
|
||||||
|
}
|
||||||
|
win.requestAnimationFrame(frame(undefined))
|
||||||
|
},
|
||||||
|
onKeyDown: (cb) => win.addEventListener('keydown', onKey(cb)),
|
||||||
|
onKeyUp: (cb) => win.addEventListener('keyup', onKey(cb)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Action } from './model.js'
|
||||||
|
|
||||||
|
// Key name (as in KeyboardEvent.key) -> direction. A lookup table keeps keyToAction a one-liner.
|
||||||
|
const directions = {
|
||||||
|
ArrowUp: 'Up', w: 'Up', W: 'Up',
|
||||||
|
ArrowDown: 'Down', s: 'Down', S: 'Down',
|
||||||
|
ArrowLeft: 'Left', a: 'Left', A: 'Left',
|
||||||
|
ArrowRight: 'Right', d: 'Right', D: 'Right',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keyToAction = (key) =>
|
||||||
|
Object.hasOwn(directions, key) ? Action.Turn(directions[key]) : null
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { runApp } from './runtime.js'
|
||||||
|
import { browserPlatform } from './browser.js'
|
||||||
|
import { main } from './app.js'
|
||||||
|
|
||||||
|
runApp(main, browserPlatform(document.getElementById('game'), window))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// The single impure place in the course (with browser.js and main.js): everything else only builds values.
|
||||||
|
// Here we interpret IO descriptions against a Platform and hold the one mutable variable, the current stream.
|
||||||
|
// Platform = { fillRect, fillText, randomSeed, startClock, onKeyDown, onKeyUp }
|
||||||
|
|
||||||
|
// Perform one op on the platform and return its result.
|
||||||
|
const perform = (op, platform) => {
|
||||||
|
if (op.tag === 'RandomSeed') return platform.randomSeed()
|
||||||
|
const s = op.shape
|
||||||
|
return s.tag === 'Rect'
|
||||||
|
? platform.fillRect(s.x, s.y, s.w, s.h, s.color)
|
||||||
|
: platform.fillText(s.x, s.y, s.text, s.color, s.size, s.align)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A loop, not recursion: a scene is hundreds of ops and the stack must not grow with them.
|
||||||
|
export const runIO = (io, platform) => {
|
||||||
|
let current = io
|
||||||
|
while (current.tag === 'Effect') current = current.k(perform(current.op, platform))
|
||||||
|
return current.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runApp = (main, platform) => {
|
||||||
|
let stream = runIO(main, platform)
|
||||||
|
const feed = (input) => {
|
||||||
|
const [ios, next] = stream.step(input)
|
||||||
|
stream = next
|
||||||
|
ios.forEach((io) => runIO(io, platform))
|
||||||
|
}
|
||||||
|
platform.onKeyDown((key) => feed({ tag: 'KeyDown', key }))
|
||||||
|
platform.onKeyUp((key) => feed({ tag: 'KeyUp', key }))
|
||||||
|
platform.startClock((t) => feed({ tag: 'Time', t }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { todo } from './todo.js'
|
||||||
|
import { pure } from './io.js'
|
||||||
|
|
||||||
|
/** Milliseconds between game steps. */
|
||||||
|
export const TICK_MS = 150
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `interval`),
|
||||||
|
* merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`).
|
||||||
|
*/
|
||||||
|
export const classicActions = { step: () => todo('m09') }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a stream of actions into a stream of game states: start from `newGame(seed)`, apply `update` to each action
|
||||||
|
* (scan), and emit the initial state first (startWith) so there is something to draw before anything happens.
|
||||||
|
*/
|
||||||
|
export const states = (seed, actions) => todo('m09')
|
||||||
|
|
||||||
|
/** The drawing of one state, as an IO: `drawScene(view(state))`. */
|
||||||
|
export const render = (state) => todo('m09')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole program as a value, `IO<Stream<IO<unit>>>`: ask for a random seed, then turn the stream of
|
||||||
|
* states (built from `classicActions`) into a stream of drawings (`render`). Nothing runs here.
|
||||||
|
*/
|
||||||
|
export const main = pure({ step: () => todo('m09') })
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { todo } from './todo.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Platform from a canvas and a window (see runtime.js).
|
||||||
|
* - fillRect / fillText draw on the canvas 2D context (font `${size}px monospace`, textAlign from `align`).
|
||||||
|
* - startClock calls `onTime(t)` on every animation frame (requestAnimationFrame), `t` = ms since the first frame.
|
||||||
|
* - onKeyDown / onKeyUp listen on `win`; call `preventDefault()` for arrow keys and space so the page does not scroll.
|
||||||
|
* - randomSeed uses `crypto.getRandomValues(new Uint32Array(1))[0]`.
|
||||||
|
*/
|
||||||
|
export const browserPlatform = (canvas, win) => todo('m09')
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { todo } from './todo.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a keyboard key name (as in `KeyboardEvent.key`) into the game action it triggers, or `null`.
|
||||||
|
* `ArrowUp`/`w`/`W` -> `Action.Turn('Up')`, `ArrowDown`/`s`/`S` -> Down, `ArrowLeft`/`a`/`A` -> Left,
|
||||||
|
* `ArrowRight`/`d`/`D` -> Right. Any other key -> `null`.
|
||||||
|
*/
|
||||||
|
export const keyToAction = (key) => todo('m09')
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { runApp } from './runtime.js'
|
||||||
|
import { browserPlatform } from './browser.js'
|
||||||
|
import { main } from './app.js'
|
||||||
|
|
||||||
|
runApp(main, browserPlatform(document.getElementById('game'), window))
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { todo } from './todo.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single impure place in the course (with browser.js and main.js): everything else only builds values.
|
||||||
|
* A Platform is `{ fillRect(x,y,w,h,color), fillText(x,y,text,color,size,align), randomSeed() -> number,
|
||||||
|
* startClock(onTime: t => void), onKeyDown(cb: key => void), onKeyUp(cb: key => void) }`.
|
||||||
|
* Here `let`, loops and mutation are allowed. Keep it tiny.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret an IO against a platform and return its final value.
|
||||||
|
* `Draw` of a Rect -> `platform.fillRect(x, y, w, h, color)`; of a Text -> `platform.fillText(x, y, text, color, size, align)`;
|
||||||
|
* `RandomSeed` -> `platform.randomSeed()`. The result of each op is passed to `io.k`.
|
||||||
|
* Must be a loop, not recursion: a long sequence of draws must not overflow the stack.
|
||||||
|
*/
|
||||||
|
export const runIO = (io, platform) => todo('m09')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the program: `runIO(main)` gives the stream; keep the current stream in the one mutable variable.
|
||||||
|
* Register `platform.onKeyDown` / `onKeyUp` / `startClock` so that each event steps the stream with the matching
|
||||||
|
* input (`{tag:'KeyDown', key}`, `{tag:'KeyUp', key}`, `{tag:'Time', t}`), stores the next stream,
|
||||||
|
* and runs every IO the step emitted, in order.
|
||||||
|
*/
|
||||||
|
export const runApp = (main, platform) => todo('m09')
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { states, classicActions, render, main, 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 { freeze } from './helpers.js'
|
||||||
|
|
||||||
|
const time = (t) => ({ tag: 'Time', t })
|
||||||
|
const down = (key) => ({ tag: 'KeyDown', key })
|
||||||
|
|
||||||
|
test('m09 the game ticks every 150 ms', () => assert.equal(TICK_MS, 150))
|
||||||
|
|
||||||
|
test('m09 classicActions turns Tick into actions on the clock and keys into Turn actions', () =>
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
run(classicActions, freeze([time(150), down('ArrowUp'), down('x'), time(300)])),
|
||||||
|
[Action.Tick, Action.Turn('Up'), Action.Tick]))
|
||||||
|
|
||||||
|
test('m09 states starts with the new game, then yields one state per action', () => {
|
||||||
|
const out = run(states(42, classicActions), freeze([time(150), down('ArrowUp'), time(300)]))
|
||||||
|
assert.equal(out.length, 4)
|
||||||
|
assert.deepStrictEqual(out[0], newGame(42))
|
||||||
|
assert.deepStrictEqual(out.map((s) => s.snake[0]), [
|
||||||
|
{ x: 10, y: 10 }, { x: 11, y: 10 }, { x: 11, y: 10 }, { x: 11, y: 9 },
|
||||||
|
])
|
||||||
|
assert.deepStrictEqual(out.map((s) => s.nextDir), ['Right', 'Right', 'Up', 'Up'])
|
||||||
|
assert.deepStrictEqual(out.map((s) => s.dir), ['Right', 'Right', 'Right', 'Up'])
|
||||||
|
})
|
||||||
|
|
||||||
|
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 }))))
|
||||||
|
|
||||||
|
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' }])
|
||||||
|
const [outs] = stream.step(time(0))
|
||||||
|
assert.equal(outs.length, 1)
|
||||||
|
assert.deepStrictEqual(trace(outs[0], 0).ops, trace(render(newGame(42)), 0).ops)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 main with seed 42 draws the new game first, then the ticked game after 150 ms', () => {
|
||||||
|
const { result: stream } = trace(main, 42)
|
||||||
|
const [first, next] = stream.step(time(0))
|
||||||
|
const [second] = next.step(time(150))
|
||||||
|
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42)))
|
||||||
|
assert.equal(second.length, 1)
|
||||||
|
assert.notDeepEqual(trace(second[0], 0).ops, trace(first[0], 0).ops)
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { keyToAction } from '../src/input.js'
|
||||||
|
import { loadFixture } from './helpers.js'
|
||||||
|
|
||||||
|
const fx = loadFixture('m09-input')
|
||||||
|
fx.cases.forEach((c) =>
|
||||||
|
test(`m09 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(keyToAction(...c.args), c.expected)))
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { runIO, runApp } from '../src/runtime.js'
|
||||||
|
import { pure, draw, randomSeed, map, sequence } from '../src/io.js'
|
||||||
|
import { main } from '../src/app.js'
|
||||||
|
import { view } from '../src/view.js'
|
||||||
|
import { newGame, tick } from '../src/spawn.js'
|
||||||
|
|
||||||
|
// A fake platform: records every call and lets the test fire events by hand.
|
||||||
|
const fakePlatform = (seed = 42) => {
|
||||||
|
const calls = []
|
||||||
|
const handlers = {}
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
fillRect: (...args) => calls.push(['fillRect', ...args]),
|
||||||
|
fillText: (...args) => calls.push(['fillText', ...args]),
|
||||||
|
randomSeed: () => { calls.push(['randomSeed']); return seed },
|
||||||
|
startClock: (cb) => { handlers.time = cb },
|
||||||
|
onKeyDown: (cb) => { handlers.down = cb },
|
||||||
|
onKeyUp: (cb) => { handlers.up = cb },
|
||||||
|
fireTime: (t) => handlers.time(t),
|
||||||
|
fireKey: (k) => handlers.down(k),
|
||||||
|
fireKeyUp: (k) => handlers.up(k),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the platform should have been asked to draw for a scene.
|
||||||
|
const expectedCalls = (scene) =>
|
||||||
|
scene.map((s) =>
|
||||||
|
s.tag === 'Rect'
|
||||||
|
? ['fillRect', s.x, s.y, s.w, s.h, s.color]
|
||||||
|
: ['fillText', s.x, s.y, s.text, s.color, s.size, s.align])
|
||||||
|
|
||||||
|
const rect = { tag: 'Rect', x: 1, y: 2, w: 3, h: 4, color: '#abcdef' }
|
||||||
|
const text = { tag: 'Text', x: 5, y: 6, text: 'hi', color: '#fff', size: 16, align: 'center' }
|
||||||
|
|
||||||
|
test('m09 runIO of a pure value returns it without touching the platform', () => {
|
||||||
|
const p = fakePlatform()
|
||||||
|
assert.equal(runIO(pure(7), p), 7)
|
||||||
|
assert.deepStrictEqual(p.calls, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runIO maps Draw Rect to fillRect(x, y, w, h, color)', () => {
|
||||||
|
const p = fakePlatform()
|
||||||
|
runIO(draw(rect), p)
|
||||||
|
assert.deepStrictEqual(p.calls, [['fillRect', 1, 2, 3, 4, '#abcdef']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runIO maps Draw Text to fillText(x, y, text, color, size, align)', () => {
|
||||||
|
const p = fakePlatform()
|
||||||
|
runIO(draw(text), p)
|
||||||
|
assert.deepStrictEqual(p.calls, [['fillText', 5, 6, 'hi', '#fff', 16, 'center']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runIO answers RandomSeed with platform.randomSeed() and continues with it', () => {
|
||||||
|
const p = fakePlatform(99)
|
||||||
|
assert.equal(runIO(map(randomSeed, (s) => s + 1), p), 100)
|
||||||
|
assert.deepStrictEqual(p.calls, [['randomSeed']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runIO performs effects in order', () => {
|
||||||
|
const p = fakePlatform()
|
||||||
|
runIO(sequence([draw(rect), draw(text), draw(rect)]), p)
|
||||||
|
assert.deepStrictEqual(p.calls.map((c) => c[0]), ['fillRect', 'fillText', 'fillRect'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runIO handles 100000 effects without overflowing the stack', () => {
|
||||||
|
const p = fakePlatform()
|
||||||
|
const n = 100_000
|
||||||
|
runIO(sequence(Array.from({ length: n }, () => draw(rect))), p)
|
||||||
|
assert.equal(p.calls.length, n)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runApp asks the platform for a seed once, at start, and draws nothing before the first input', () => {
|
||||||
|
const p = fakePlatform()
|
||||||
|
runApp(main, p)
|
||||||
|
assert.deepStrictEqual(p.calls, [['randomSeed']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms', () => {
|
||||||
|
const p = fakePlatform(42)
|
||||||
|
runApp(main, p)
|
||||||
|
p.calls.length = 0
|
||||||
|
p.fireTime(150)
|
||||||
|
assert.deepStrictEqual(p.calls, [
|
||||||
|
...expectedCalls(view(newGame(42))),
|
||||||
|
...expectedCalls(view(tick(newGame(42)))),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runApp feeds key presses to the game', () => {
|
||||||
|
const p = fakePlatform(42)
|
||||||
|
runApp(main, p)
|
||||||
|
p.fireTime(0)
|
||||||
|
p.calls.length = 0
|
||||||
|
p.fireKey('ArrowUp')
|
||||||
|
p.fireTime(150)
|
||||||
|
const turned = { ...newGame(42), nextDir: 'Up' }
|
||||||
|
assert.deepStrictEqual(p.calls, [
|
||||||
|
...expectedCalls(view(turned)),
|
||||||
|
...expectedCalls(view(tick(turned))),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('m09 runApp ignores key releases and unmapped keys (no redraw)', () => {
|
||||||
|
const p = fakePlatform(42)
|
||||||
|
runApp(main, p)
|
||||||
|
p.fireTime(0)
|
||||||
|
p.calls.length = 0
|
||||||
|
p.fireKeyUp('ArrowUp')
|
||||||
|
p.fireKey('x')
|
||||||
|
assert.deepStrictEqual(p.calls, [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Headless smoke test: the page loads, throws no errors, and the canvas has painted something.
|
||||||
|
// Zero dependencies: drives google-chrome over the DevTools protocol with Node's global WebSocket.
|
||||||
|
// Usage: node scripts/smoke.mjs <url> exit 0 = painted and clean, 1 = problem (reason printed)
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { createServer } from 'node:net'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const url = process.argv[2]
|
||||||
|
if (!url) { console.error('usage: smoke.mjs <url>'); process.exit(2) }
|
||||||
|
const chromePath = process.env.CHROME ?? '/usr/bin/google-chrome'
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const freePort = () => new Promise((resolve, reject) => {
|
||||||
|
const s = createServer()
|
||||||
|
s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)) })
|
||||||
|
s.on('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'smoke-'))
|
||||||
|
const port = await freePort()
|
||||||
|
const chrome = spawn(chromePath, [
|
||||||
|
'--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${dir}`,
|
||||||
|
'--no-sandbox', '--disable-gpu', '--no-first-run', 'about:blank',
|
||||||
|
], { stdio: 'ignore' })
|
||||||
|
const cleanup = () => { chrome.kill('SIGKILL'); rmSync(dir, { recursive: true, force: true }) }
|
||||||
|
const finish = (code, msg) => { if (msg) console.log(msg); cleanup(); process.exit(code) }
|
||||||
|
process.on('SIGINT', () => finish(1, 'interrupted'))
|
||||||
|
const timer = setTimeout(() => finish(1, 'FAIL: timed out'), 30000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for the debugging endpoint, then find the page target.
|
||||||
|
let target
|
||||||
|
for (let i = 0; i < 100 && !target; i++) {
|
||||||
|
try {
|
||||||
|
const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
|
||||||
|
target = list.find((t) => t.type === 'page')
|
||||||
|
} catch { await sleep(100) }
|
||||||
|
}
|
||||||
|
if (!target) finish(1, 'FAIL: could not connect to Chrome')
|
||||||
|
|
||||||
|
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||||
|
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject })
|
||||||
|
const errors = []
|
||||||
|
const pending = new Map()
|
||||||
|
let id = 0
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => {
|
||||||
|
pending.set(++id, resolve)
|
||||||
|
ws.send(JSON.stringify({ id, method, params }))
|
||||||
|
})
|
||||||
|
ws.onmessage = ({ data }) => {
|
||||||
|
const m = JSON.parse(data)
|
||||||
|
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id) }
|
||||||
|
else if (m.method === 'Runtime.exceptionThrown') {
|
||||||
|
const d = m.params.exceptionDetails
|
||||||
|
errors.push(`exception: ${d.exception?.description ?? d.text}`)
|
||||||
|
} else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
|
||||||
|
errors.push(`console.error: ${m.params.args.map((a) => a.value ?? a.description).join(' ')}`)
|
||||||
|
} else if (m.method === 'Log.entryAdded' && m.params.entry.level === 'error') {
|
||||||
|
errors.push(`log: ${m.params.entry.text} ${m.params.entry.url ?? ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await send('Runtime.enable')
|
||||||
|
await send('Log.enable')
|
||||||
|
await send('Page.enable')
|
||||||
|
await send('Page.navigate', { url })
|
||||||
|
await sleep(1000)
|
||||||
|
|
||||||
|
// Does the canvas hold at least one pixel that is not the #111111 background (and not transparent)?
|
||||||
|
const check = `(() => {
|
||||||
|
const c = document.querySelector('canvas')
|
||||||
|
if (!c) return 'no canvas'
|
||||||
|
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data
|
||||||
|
for (let i = 0; i < d.length; i += 4)
|
||||||
|
if (d[i + 3] !== 0 && (d[i] !== 0x11 || d[i + 1] !== 0x11 || d[i + 2] !== 0x11)) return 'painted'
|
||||||
|
return 'blank'
|
||||||
|
})()`
|
||||||
|
const res = await send('Runtime.evaluate', { expression: check, returnByValue: true })
|
||||||
|
const state = res.result?.value ?? `evaluation failed: ${JSON.stringify(res)}`
|
||||||
|
|
||||||
|
const problems = [...(state === 'painted' ? [] : [`canvas: ${state}`]), ...errors]
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (problems.length) finish(1, `FAIL ${url}\n ${problems.join('\n ')}`)
|
||||||
|
finish(0, `OK ${url}: canvas painted, no errors`)
|
||||||
|
} catch (e) {
|
||||||
|
finish(1, `FAIL: ${e.message}`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user