Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The engine hands out a headless page: one stub document behind the door, and both games boot through it #103
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/59-the-engine-hands-out-a-headless-page-one → main 1 commit fork
12 files
changed +905
−425
README.md +8 −6
| @@ -218,8 +218,8 @@ subtends — and the panel says which of the two you are looking at. | ||
| 218 | 218 | its host: `boot(host)` is handed the document, the window, a store, a `fetch`, |
| 219 | 219 | a source of match ids and the two factories that make a renderer and a voice, |
| 220 | 220 | and the last lines of the file are the only place a browser global is named. |
| 221 | `main.test.ts` boots the whole frame against the stub page in | |
| 222 | `stage.test-helper.ts` — elements that record what was hidden, written and | |
| 221 | `main.test.ts` boots the whole frame against `fakePage` from the engine's door, | |
| 222 | wrapped by `stage.test-helper.ts` — elements that record what was hidden, written and | |
| 223 | 223 | focused, a 2D context that writes down every call, a renderer that keeps the |
| 224 | 224 | scene it was handed, a `fetch` that keeps the bodies, and a clock the test |
| 225 | 225 | moves — `boot` hands back the running frame, which is how a test says what |
| @@ -301,10 +301,12 @@ subtends — and the panel says which of the two you are looking at. | ||
| 301 | 301 | where arithmetic knows, so a ray down the barrel hits, a ray 5° wide of it |
| 302 | 302 | misses, a retired plate cannot be hit, the same seed plays the same run twice |
| 303 | 303 | and the clock at zero refuses the trigger; and `boot.test.ts` boots the entry |
| 304 | itself against the stub page in `dom.ts` — a document of plain objects and a | |
| 305 | clock the test moves — so a key, a button, a thumb stick, a locked mouse, a | |
| 306 | canvas that gives no context, a page with WebGL2 and a page without — the | |
| 307 | drawn one against a GL recorder in `dom.ts` that writes down what it was asked | |
| 304 | itself against the engine's headless page — `fakePage` from behind the door, a | |
| 305 | document of plain objects and a clock the test moves, wrapped by | |
| 306 | `src/range/stage.test-helper.ts` with this game's own ids — so a key, a button, | |
| 307 | a thumb stick, a locked mouse, a canvas that gives no context, a page with | |
| 308 | WebGL2 and a page without — the drawn one against a GL recorder in | |
| 309 | `src/range/stage.test-helper.ts` that writes down what it was asked | |
| 308 | 310 | for, so the room, one draw per standing plate and the tracer are counted — and |
| 309 | 311 | a whole run played with the mouse (turn onto the blip the fallback drew, click, |
| 310 | 312 | watch the score move) are all checked with no browser in the room. `bundle.test.ts` then does the same to |
src/client/stage.test-helper.ts +61 −233
| @@ -1,17 +1,17 @@ | ||
| 1 | 1 | /** |
| 2 | * A page made of plain objects: the handful of shapes `main.ts` and the | |
| 3 | * engine's controls actually touch, and a clock that only moves when the test | |
| 4 | * says so. It exists so the whole frame can be booted and stepped with no | |
| 5 | * browser — the same trick `src/engine/controls.ts` plays with its `Listens` | |
| 6 | * parameter, one game up. | |
| 2 | * The shooter's half of a stub page. The page itself is the engine's — | |
| 3 | * `fakePage` behind the door hands back the document, the window, the elements, | |
| 4 | * the 2D recorder and the clock that only moves when the test says so, because | |
| 5 | * both games needed exactly that and neither may import the other's copy of it. | |
| 7 | 6 | * |
| 8 | * Everything here records rather than does: an element remembers what was | |
| 9 | * hidden, written and focused, the 2D context writes down every call, the | |
| 10 | * renderer keeps the scene it was handed, and `fetch` keeps the bodies it was | |
| 11 | * posted. The scene is one object the frame refills, so `draws` is a count and | |
| 12 | * an identity, not a history: read it in the step that produced it. The | |
| 13 | * recorders have exactly the members `boot` and `hud.ts` call, so a stub that | |
| 14 | * has to be maintained is smaller than the frame it checks. | |
| 7 | * What is left here is what is genuinely this game's: the ids `src/pages/game.ts` | |
| 8 | * renders, the `Audio` the frame plays through, the renderer that keeps the | |
| 9 | * scene it was handed, the `fetch` that keeps the bodies it was posted, the | |
| 10 | * store, the match ids — and the line that installs the whole thing as globals. | |
| 11 | * The scene is one object the frame refills, so `draws` is a count and an | |
| 12 | * identity, not a history: read it in the step that produced it. The recorders | |
| 13 | * have exactly the members `boot` and `hud.ts` call, so a stub that has to be | |
| 14 | * maintained is smaller than the frame it checks. | |
| 15 | 15 | * |
| 16 | 16 | * Two tests boot on it and they reach it from opposite sides: `main.test.ts` |
| 17 | 17 | * calls `boot(stage.host)` and hands the page straight over, while |
| @@ -20,145 +20,30 @@ | ||
| 20 | 20 | * That second way is why the scene canvas can answer `getContext("webgl2")` |
| 21 | 21 | * with a `glRecorder`: the shipped entry builds its own renderer. |
| 22 | 22 | * |
| 23 | * It ships in no bundle: only those two import it. `src/range/dom.ts` is | |
| 24 | * the same idea for the other game — it cannot import this one, because | |
| 23 | * It ships in no bundle, and neither does the engine's page under it: only | |
| 24 | * those two tests import this file, and `src/engine/page.test.ts` reads both | |
| 25 | * committed bundles for the page. `src/range/stage.test-helper.ts` is the same | |
| 26 | * half for the other game — it cannot import this one, because | |
| 25 | 27 | * `src/independence.test.ts` forbids either game from reaching into the other, |
| 26 | * but a third game copies whichever is nearer and adds its own ids. | |
| 28 | * and it no longer needs to: what the two shared is behind the door now. | |
| 27 | 29 | */ |
| 28 | 30 | |
| 31 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakePaint, type FakeWindow } from "../engine/index.ts"; | |
| 29 | 32 | import type { Audio } from "./audio.ts"; |
| 30 | import type { Paint } from "./hud.ts"; | |
| 31 | 33 | import type { Draws, Host, Stage } from "./main.ts"; |
| 32 | 34 | import type { Scene } from "./scene.ts"; |
| 33 | 35 | |
| 34 | type Handler = (event: any) => void; | |
| 35 | ||
| 36 | export type Fake = { | |
| 37 | /** Deliver an event to everything listening for it here. */ | |
| 38 | fire(type: string, event?: unknown): void; | |
| 39 | readonly listening: ReadonlySet<string>; | |
| 40 | addEventListener(type: string, handler: Handler): void; | |
| 41 | removeEventListener(type: string, handler: Handler): void; | |
| 42 | }; | |
| 43 | ||
| 44 | /** Anything that can be listened to, and told what happened. */ | |
| 45 | export function fake(): Fake { | |
| 46 | const handlers = new Map<string, Handler[]>(); | |
| 47 | return { | |
| 48 | listening: new Set<string>(), | |
| 49 | addEventListener(type, handler) { | |
| 50 | const list = handlers.get(type); | |
| 51 | if (list === undefined) handlers.set(type, [handler]); | |
| 52 | else list.push(handler); | |
| 53 | (this.listening as Set<string>).add(type); | |
| 54 | }, | |
| 55 | removeEventListener(type, handler) { | |
| 56 | const list = handlers.get(type) ?? []; | |
| 57 | const at = list.indexOf(handler); | |
| 58 | if (at >= 0) list.splice(at, 1); | |
| 59 | }, | |
| 60 | fire(type, event = {}) { | |
| 61 | for (const handler of [...(handlers.get(type) ?? [])]) handler({ preventDefault() {}, ...(event as object) }); | |
| 62 | }, | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | export type FakeElement = Fake & { | |
| 67 | id: string; | |
| 68 | tag: string; | |
| 69 | className: string; | |
| 70 | hidden: boolean; | |
| 71 | innerHTML: string; | |
| 72 | textContent: string | null; | |
| 73 | value: string; | |
| 74 | disabled: boolean; | |
| 75 | type: string; | |
| 76 | dataset: { action?: string }; | |
| 77 | /** How many times something asked for the focus. */ | |
| 78 | focused: number; | |
| 79 | readonly children: FakeElement[]; | |
| 80 | focus(): void; | |
| 81 | appendChild(child: FakeElement): void; | |
| 82 | querySelector(selector: string): FakeElement | null; | |
| 83 | }; | |
| 84 | ||
| 85 | /** | |
| 86 | * The one thing this stub knows about markup: `class="grid"` in an `innerHTML` | |
| 87 | * makes a child you can find by `.grid`. That is what the buy panel does — it | |
| 88 | * writes its shell, then fills the grid — and nothing here parses anything else. | |
| 89 | */ | |
| 90 | const CLASS = /class="([a-z-]+)"/g; | |
| 91 | ||
| 92 | export function element(options: { id?: string; tag?: string; action?: string; className?: string } = {}): FakeElement { | |
| 93 | const children: FakeElement[] = []; | |
| 94 | let html = ""; | |
| 95 | const self: FakeElement = { | |
| 96 | ...fake(), | |
| 97 | id: options.id ?? "", | |
| 98 | tag: options.tag ?? "div", | |
| 99 | className: options.className ?? "", | |
| 100 | dataset: options.action === undefined ? {} : { action: options.action }, | |
| 101 | hidden: false, | |
| 102 | textContent: "", | |
| 103 | value: "", | |
| 104 | disabled: false, | |
| 105 | type: "", | |
| 106 | focused: 0, | |
| 107 | children, | |
| 108 | get innerHTML() { | |
| 109 | return html; | |
| 110 | }, | |
| 111 | set innerHTML(value: string) { | |
| 112 | html = value; | |
| 113 | children.length = 0; | |
| 114 | for (const [, name] of value.matchAll(CLASS)) children.push(element({ className: name as string })); | |
| 115 | }, | |
| 116 | focus() { | |
| 117 | self.focused += 1; | |
| 118 | }, | |
| 119 | appendChild(child) { | |
| 120 | children.push(child); | |
| 121 | }, | |
| 122 | querySelector(selector) { | |
| 123 | const hit = (e: FakeElement): boolean => | |
| 124 | selector.startsWith(".") ? e.className === selector.slice(1) : e.tag === selector; | |
| 125 | for (const child of children) { | |
| 126 | if (hit(child)) return child; | |
| 127 | const deeper = child.querySelector(selector); | |
| 128 | if (deeper !== null) return deeper; | |
| 129 | } | |
| 130 | return null; | |
| 131 | }, | |
| 132 | }; | |
| 133 | return self; | |
| 134 | } | |
| 135 | ||
| 136 | /** Every 2D call `hud.ts` makes, written down in order. */ | |
| 137 | export type Recorder = Paint & { calls: [string, ...unknown[]][]; readonly texts: string[] }; | |
| 138 | ||
| 139 | export function recorder(): Recorder { | |
| 140 | const calls: [string, ...unknown[]][] = []; | |
| 141 | const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]); | |
| 142 | return { | |
| 143 | calls, | |
| 144 | get texts() { | |
| 145 | return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1])); | |
| 146 | }, | |
| 147 | fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1, | |
| 148 | clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"), | |
| 149 | beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"), | |
| 150 | arc: note("arc"), fill: note("fill"), stroke: note("stroke"), | |
| 151 | } as Recorder; | |
| 152 | } | |
| 153 | ||
| 154 | 36 | /** |
| 155 | 37 | * Every GL call the engine's renderer makes, written down in order, and every |
| 156 | 38 | * enum it names — numbered, so a recorded call reads back. It is the same trick |
| 157 | * the `Recorder` above plays for 2D: not a fake GPU, only a list of what was | |
| 39 | * the engine's `FakePaint` plays for 2D: not a fake GPU, only a list of what was | |
| 158 | 40 | * asked for. |
| 159 | 41 | * |
| 160 | * `main.test.ts` never sees one, because `boot(host)` is handed a renderer that | |
| 161 | * keeps the scene it was given. `bundle.test.ts` does: the committed bundle | |
| 42 | * It stays in this game rather than behind the door because a page that knew | |
| 43 | * what a renderer was would be one game's page wearing the engine's name: the | |
| 44 | * engine's canvas has an empty `gl` slot, and this is what the shooter puts in | |
| 45 | * it. `main.test.ts` never sees one, because `boot(host)` is handed a renderer | |
| 46 | * that keeps the scene it was given. `bundle.test.ts` does: the committed bundle | |
| 162 | 47 | * builds its own renderer out of the canvas it finds, so that canvas has to |
| 163 | 48 | * answer `getContext("webgl2")` with something. |
| 164 | 49 | */ |
| @@ -205,36 +90,8 @@ export function glRecorder(): GlRecorder { | ||
| 205 | 90 | }; |
| 206 | 91 | } |
| 207 | 92 | |
| 208 | export type FakeCanvas = FakeElement & { | |
| 209 | clientWidth: number; | |
| 210 | clientHeight: number; | |
| 211 | width: number; | |
| 212 | height: number; | |
| 213 | paint: Recorder; | |
| 214 | /** The WebGL2 context this canvas hands out, or null for a browser with none. */ | |
| 215 | gl: GlRecorder | null; | |
| 216 | /** How many times the game asked for the pointer. */ | |
| 217 | locks: number; | |
| 218 | getContext(kind: string): Recorder | GlRecorder | null; | |
| 219 | requestPointerLock(): void; | |
| 220 | }; | |
| 221 | ||
| 222 | export function canvas(id: string, gl: GlRecorder | null = null): FakeCanvas { | |
| 223 | return { | |
| 224 | ...element({ id, tag: "canvas" }), | |
| 225 | clientWidth: 800, clientHeight: 450, width: 0, height: 0, | |
| 226 | paint: recorder(), | |
| 227 | gl, | |
| 228 | locks: 0, | |
| 229 | getContext(kind) { | |
| 230 | if (kind === "webgl2") return this.gl; | |
| 231 | return kind === "2d" ? this.paint : null; | |
| 232 | }, | |
| 233 | requestPointerLock() { | |
| 234 | this.locks += 1; | |
| 235 | }, | |
| 236 | }; | |
| 237 | } | |
| 93 | /** One of this page's canvases: it always has a 2D recorder, and its GL one is this game's. */ | |
| 94 | export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null }; | |
| 238 | 95 | |
| 239 | 96 | /** One POST the frame made, as it was made. */ |
| 240 | 97 | export type Post = { url: string; method: string; headers: Record<string, string>; body: string }; |
| @@ -242,28 +99,13 @@ export type Post = { url: string; method: string; headers: Record<string, string | ||
| 242 | 99 | /** One draw call the frame made, with the scene it was given. */ |
| 243 | 100 | export type Drawn = { scene: Scene; dt: number }; |
| 244 | 101 | |
| 245 | /** | |
| 246 | * The document, written out rather than left to the host's type, because a test | |
| 247 | * that installs this page as a global reads it back: which ids the entry asked | |
| 248 | * for, and — through `readyState` — whether it boots on import or waits for | |
| 249 | * `DOMContentLoaded`. | |
| 250 | */ | |
| 251 | export type FakeDocument = Fake & { | |
| 252 | readyState: string; | |
| 253 | pointerLockElement: unknown; | |
| 254 | getElementById(id: string): FakeElement | null; | |
| 255 | querySelectorAll(selector: string): FakeElement[]; | |
| 256 | createElement(tag: string): FakeElement; | |
| 257 | exitPointerLock(): void; | |
| 258 | }; | |
| 259 | ||
| 260 | 102 | export type FakeStage = { |
| 261 | 103 | /** What `boot` is handed. */ |
| 262 | 104 | host: Host; |
| 263 | 105 | document: FakeDocument; |
| 264 | window: Fake & { devicePixelRatio: number }; | |
| 265 | scene: FakeCanvas; | |
| 266 | hud: FakeCanvas; | |
| 106 | window: FakeWindow; | |
| 107 | scene: StageCanvas; | |
| 108 | hud: StageCanvas; | |
| 267 | 109 | by(id: string): FakeElement; |
| 268 | 110 | /** The `[data-action]` buttons the page carries, in the page's order. */ |
| 269 | 111 | buttons: FakeElement[]; |
| @@ -323,47 +165,27 @@ const GLOBALS = ["document", "window", "localStorage", "fetch"]; | ||
| 323 | 165 | * `coarse: true` for a phone. |
| 324 | 166 | */ |
| 325 | 167 | export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage { |
| 168 | const page = fakePage({ | |
| 169 | ids: PAGE_IDS, | |
| 170 | canvases: ["scene", "hud"], | |
| 171 | hidden: HIDDEN, | |
| 172 | actions: PAGE_ACTIONS, | |
| 173 | coarse: options.coarse, | |
| 174 | }); | |
| 175 | // Asked for with `paint` left on, so both canvases have their 2D recorder. | |
| 176 | const [scene, hud] = page.canvases as [StageCanvas, StageCanvas]; | |
| 326 | 177 | // The same switch on both sides of the seam: `webgl: false` is a browser with |
| 327 | 178 | // no context to give, whether the frame asks this page's `host` for a renderer |
| 328 | 179 | // or — as the committed bundle does — asks the canvas itself. |
| 329 | const scene = canvas("scene", options.webgl === false ? null : glRecorder()); | |
| 330 | const hud = canvas("hud"); | |
| 331 | const buttons = PAGE_ACTIONS.map((action) => element({ action })); | |
| 332 | const elements = new Map<string, FakeElement>(PAGE_IDS.map((id) => [id, element({ id })])); | |
| 333 | elements.set("scene", scene); | |
| 334 | elements.set("hud", hud); | |
| 335 | for (const id of HIDDEN) (elements.get(id) as FakeElement).hidden = true; | |
| 336 | const frames: ((now: number) => void)[] = []; | |
| 180 | if (options.webgl !== false) scene.gl = glRecorder(); | |
| 181 | ||
| 337 | 182 | const posts: Post[] = []; |
| 338 | 183 | const draws: Drawn[] = []; |
| 339 | 184 | const sounds: string[] = []; |
| 340 | 185 | const store = new Map<string, string>(); |
| 341 | 186 | const ids: string[] = []; |
| 342 | let clock = 1000; | |
| 343 | 187 | let uuid = 0; |
| 344 | 188 | |
| 345 | const document: FakeDocument = { | |
| 346 | ...fake(), | |
| 347 | // The entry boots on import unless the page is still parsing; a test that | |
| 348 | // wants the other path sets this to "loading" before it installs the page. | |
| 349 | readyState: "complete", | |
| 350 | pointerLockElement: null as unknown, | |
| 351 | getElementById: (id: string): FakeElement | null => elements.get(id) ?? null, | |
| 352 | querySelectorAll: (_selector: string): FakeElement[] => buttons, | |
| 353 | createElement: (tag: string): FakeElement => element({ tag }), | |
| 354 | exitPointerLock(): void { | |
| 355 | document.pointerLockElement = null; | |
| 356 | }, | |
| 357 | }; | |
| 358 | const window = { | |
| 359 | ...fake(), | |
| 360 | devicePixelRatio: 1, | |
| 361 | requestAnimationFrame: (callback: (now: number) => void): number => frames.push(callback), | |
| 362 | cancelAnimationFrame: (): void => void frames.splice(0, frames.length), | |
| 363 | matchMedia: (_query: string): { matches: boolean } => ({ matches: options.coarse === true }), | |
| 364 | performance: { now: (): number => clock }, | |
| 365 | }; | |
| 366 | ||
| 367 | 189 | const audio: Audio = { |
| 368 | 190 | muted: false, |
| 369 | 191 | resume: () => void sounds.push("resume"), |
| @@ -379,8 +201,8 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 379 | 201 | |
| 380 | 202 | const stub: FakeStage = { |
| 381 | 203 | host: { |
| 382 | document, | |
| 383 | window, | |
| 204 | document: page.document, | |
| 205 | window: page.window, | |
| 384 | 206 | storage: { |
| 385 | 207 | getItem: (key) => store.get(key) ?? null, |
| 386 | 208 | setItem: (key, value) => void store.set(key, value), |
| @@ -398,19 +220,21 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 398 | 220 | createRenderer: (_canvas: Stage) => (options.webgl === false ? null : renderer), |
| 399 | 221 | createAudio: () => audio, |
| 400 | 222 | }, |
| 401 | document, window, scene, hud, buttons, frames, posts, draws, sounds, store, ids, | |
| 402 | by: (id) => elements.get(id) as FakeElement, | |
| 403 | step(ms) { | |
| 404 | clock += ms; | |
| 405 | const next = frames.shift(); | |
| 406 | next?.(clock); | |
| 407 | }, | |
| 223 | document: page.document, | |
| 224 | window: page.window, | |
| 225 | scene, | |
| 226 | hud, | |
| 227 | buttons: page.buttons, | |
| 228 | frames: page.frames, | |
| 229 | posts, draws, sounds, store, ids, | |
| 230 | by: page.by, | |
| 231 | step: page.step, | |
| 408 | 232 | lock(on) { |
| 409 | document.pointerLockElement = on ? scene : null; | |
| 410 | document.fire("pointerlockchange"); | |
| 233 | page.document.pointerLockElement = on ? scene : null; | |
| 234 | page.document.fire("pointerlockchange"); | |
| 411 | 235 | }, |
| 412 | 236 | hold(code, down) { |
| 413 | window.fire(down ? "keydown" : "keyup", { code }); | |
| 237 | page.window.fire(down ? "keydown" : "keyup", { code }); | |
| 414 | 238 | }, |
| 415 | 239 | press(code) { |
| 416 | 240 | stub.hold(code, true); |
| @@ -420,10 +244,14 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): | ||
| 420 | 244 | install() { |
| 421 | 245 | const globals = globalThis as Record<string, unknown>; |
| 422 | 246 | // Saved one key at a time, and whether it was there at all: `fetch` is |
| 423 | // Bun's own and the next test file wants it back exactly as it was. | |
| 247 | // Bun's own and the next test file wants it back exactly as it was. It is | |
| 248 | // put back by assignment rather than by `Object.defineProperty`, which | |
| 249 | // would be the same value and a permanent tax on every global lookup in | |
| 250 | // the rest of the run — `src/range/stage.test-helper.ts` says what that | |
| 251 | // measured. | |
| 424 | 252 | const was = GLOBALS.map((key) => ({ key, there: key in globals, value: globals[key] })); |
| 425 | globals.document = document; | |
| 426 | globals.window = window; | |
| 253 | globals.document = page.document; | |
| 254 | globals.window = page.window; | |
| 427 | 255 | globals.localStorage = stub.host.storage; |
| 428 | 256 | globals.fetch = (url: string, init: Parameters<Host["fetch"]>[1]) => stub.host.fetch(url, init); |
| 429 | 257 | return () => { |
src/engine/GUIDE.md +47 −6
| @@ -68,11 +68,11 @@ parameter. Controls want a `Host` — `{ surface, window, document }`, where | ||
| 68 | 68 | const controls = createControls(host, { keys: KEYS, requireLock: false }); |
| 69 | 69 | |
| 70 | 70 | |
| 71 | That host is the real browser in production and plain objects under test: the | |
| 72 | shooter's are in `src/client/stage.test-helper.ts`, the range's in | |
| 73 | `src/range/dom.ts`, and the example's are a few objects that keep a list of | |
| 74 | handlers at the top of `example.test.ts`. Nothing in the engine knows the | |
| 75 | difference, because those types are the members it reads and nothing wider. | |
| 71 | That host is the real browser in production and plain objects under test — and | |
| 72 | because the engine asks a game for a host, it hands one back: `fakePage()` is a | |
| 73 | host made of plain objects, and every game here boots on it. Nothing in the | |
| 74 | engine knows the difference, because those types are the members it reads and | |
| 75 | nothing wider. The page is the last section of this guide. | |
| 76 | 76 | |
| 77 | 77 | ### 3. A bindings table |
| 78 | 78 | |
| @@ -351,6 +351,47 @@ animates itself: a bob, a fade or a tint is a decision, and decisions are the | ||
| 351 | 351 | game's. One line a frame draws it, and `src/client/main.ts` and |
| 352 | 352 | `src/range/main.ts` are where those two lines live. |
| 353 | 353 | |
| 354 | ### page | |
| 355 | ||
| 356 | How you boot your entry in a test with no browser. `fakePage` hands back a | |
| 357 | `FakePage`: a `FakePage.surface`, a `FakePage.window` and a `FakePage.document` | |
| 358 | — which together are exactly the `Host` above — plus the frames your game asked | |
| 359 | for and a clock that moves only when you say so. Nothing in it does anything; | |
| 360 | everything in it records. | |
| 361 | ||
| 362 | What you bring is `PageOptions`, which is your game and not the engine's: | |
| 363 | `PageOptions.ids` is every id your page renders and your entry looks up, | |
| 364 | `PageOptions.canvases` says which of those are canvases (the first one is the | |
| 365 | surface), `PageOptions.hidden` is the ones the server renders already hidden, | |
| 366 | and `PageOptions.actions` is one `[data-action]` element per name. | |
| 367 | `paint: false` is a browser whose canvas gives no 2D context, and `coarse: true` | |
| 368 | is a phone. Then `FakePage.step(ms)` runs the oldest frame that was asked for, | |
| 369 | that many milliseconds after the last one — that is the whole clock, and there | |
| 370 | is no timer anywhere in the file. | |
| 371 | ||
| 372 | Everything on it reads back. A `FakeTarget` — the base of every element, of the | |
| 373 | `FakeWindow` and of the `FakeDocument` — has `FakeTarget.fire` to deliver an | |
| 374 | event to exactly the handlers listening for it, and `FakeTarget.listening`, the | |
| 375 | set of types with a handler attached right now, which is how you check that a | |
| 376 | game let go of the page when it stopped. A `FakeElement` remembers what was | |
| 377 | hidden, written and focused on it. A `FakeCanvas` counts the times it was asked | |
| 378 | for the pointer and hands out a `FakePaint` for a 2D context: every call, in | |
| 379 | order, with its arguments — `["fillText", "held nothing", 8, 20]` — so a test of | |
| 380 | what was drawn reads a list rather than a screen. It is a `Paint`, so anything | |
| 381 | you draw through `overlay` draws onto one. | |
| 382 | ||
| 383 | What it deliberately does not know is what a renderer or a sound device is. | |
| 384 | `FakeCanvas.gl` is an empty slot that `getContext("webgl2")` hands back, and the | |
| 385 | recorder you put in it is your game's: both games here keep one that writes down | |
| 386 | every GL call the engine's renderer made. The same goes for an `AudioContext`, | |
| 387 | for a `window.fetch`, and for installing any of this as a global — a page that | |
| 388 | knew about those would be one game's page wearing the engine's name. Your game's | |
| 389 | half lives beside your entry: `src/client/stage.test-helper.ts` and | |
| 390 | `src/range/stage.test-helper.ts` are the two to copy. | |
| 391 | ||
| 392 | It ships in no bundle. Nothing a game bundles imports it, and `page.test.ts` | |
| 393 | reads both committed bundles to say so. | |
| 394 | ||
| 354 | 395 | ## The seam, named as pairs |
| 355 | 396 | |
| 356 | 397 | Two games ship here, and every place they differ is a place the engine chose to |
| @@ -361,7 +402,7 @@ own tuning, and nothing else in this table at all. | ||
| 361 | 402 | | the game supplies | the shooter | the range | |
| 362 | 403 | | --- | --- | --- | |
| 363 | 404 | | an entry | `src/client/main.ts` | `src/range/main.ts` | |
| 364 | | a host | a stub page in `src/client/stage.test-helper.ts` | a stub page in `src/range/dom.ts` | | |
| 405 | | a host | `fakePage` plus the ids, the sound and the recorders in `src/client/stage.test-helper.ts` | `fakePage` plus the ids, the `AudioContext` and the recorder in `src/range/stage.test-helper.ts` | | |
| 365 | 406 | | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else | |
| 366 | 407 | | a tick rate | 64 Hz | 50 Hz | |
| 367 | 408 | | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate | |
src/engine/boundary.test.ts +6 −3
| @@ -237,14 +237,17 @@ test("the example imports the door and nothing else, which is what it is for", ( | ||
| 237 | 237 | * copy of the surface, read twice, rather than two that drift. |
| 238 | 238 | */ |
| 239 | 239 | export const SURFACE = [ |
| 240 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor", "Frames", | |
| 240 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor", | |
| 241 | "FakeCanvas", "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", | |
| 242 | "Frames", | |
| 241 | 243 | "Grid", "Hit", "Host", "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", |
| 242 | "Mat4", "Mesh", "MouseEventish", "Options", "Overlay", "Paint", "Renderer", "RendererAssets", | |
| 244 | "Mat4", "Mesh", "MouseEventish", "Options", "Overlay", "PageOptions", "Paint", "Renderer", | |
| 245 | "RendererAssets", | |
| 243 | 246 | "Scene", "SceneCapacity", "Shader", "SoundDevice", "Stick", "Surface", "Tone", |
| 244 | 247 | "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors", |
| 245 | 248 | "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf", "createControls", |
| 246 | 249 | "createLoop", "createRenderer", "createScene", "createSoundDevice", "cross", "deg", "dist", |
| 247 | "dist2d", "dot", "fbm", | |
| 250 | "dist2d", "dot", "fakePage", "fbm", | |
| 248 | 251 | "findPath", "finish", "hash", "inside", "length", "length2d", "lerp", "mat4", "mergeSolid", |
| 249 | 252 | "modelMatrix", "mul", "multiply", "nextInstance", "noise", "normalize", "overlay", "paintAtlas", |
| 250 | 253 | "perspective", "pushLine", |
src/engine/index.ts +16
| @@ -60,3 +60,19 @@ export type { Camera, Instance, Scene, SceneCapacity } from "./scene.ts"; | ||
| 60 | 60 | /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */ |
| 61 | 61 | export { createRenderer } from "./renderer.ts"; |
| 62 | 62 | export type { Renderer, RendererAssets } from "./renderer.ts"; |
| 63 | ||
| 64 | /** | |
| 65 | * A headless page: the host the parameters above already describe, made of | |
| 66 | * plain objects. A package that asks a game for a `surface`, a `window` and a | |
| 67 | * `document` should hand a fake one back, because otherwise every game writes | |
| 68 | * the same double — both games here did, and neither could import the other's, | |
| 69 | * so the copy was the thing a third game would have copied. It records rather | |
| 70 | * than does, it has no timer and touches no global, and it ships in no bundle: | |
| 71 | * `page.test.ts` reads both committed bundles to say so. What belongs to one | |
| 72 | * game — its ids, its sound, its renderer and its posts — stays in that game's | |
| 73 | * own helper, built on this. | |
| 74 | */ | |
| 75 | export { fakePage } from "./page.ts"; | |
| 76 | export type { | |
| 77 | FakeCanvas, FakeDocument, FakeElement, FakePage, FakePaint, FakeTarget, FakeWindow, PageOptions, | |
| 78 | } from "./page.ts"; |
src/engine/page.test.ts added +327
| @@ -0,0 +1,327 @@ | ||
| 1 | /** | |
| 2 | * The double itself, which is the thing neither game's stub ever had. Both | |
| 3 | * games boot their entry on this page and then assert about their own game, so | |
| 4 | * every one of these — an event reaching exactly the handlers that are | |
| 5 | * listening, a recorded call keeping its arguments, an element reading back | |
| 6 | * what was written on it — was only ever checked sideways, through a game that | |
| 7 | * happened to need it. | |
| 8 | * | |
| 9 | * Three things are pinned besides the behaviour: that the page is a host | |
| 10 | * `createControls` and `attachButtons` accept, with no cast and no browser; | |
| 11 | * that it is honest about being a double — no timer, no global, nothing thrown | |
| 12 | * for a member a game does not use; and that it is in neither committed bundle, | |
| 13 | * read out of the files in static/ the way `bundle.test.ts` reads them. | |
| 14 | */ | |
| 15 | ||
| 16 | import { expect, test } from "bun:test"; | |
| 17 | import { readFileSync, readdirSync } from "node:fs"; | |
| 18 | import { join, resolve } from "node:path"; | |
| 19 | import { attachButtons, createControls } from "./controls.ts"; | |
| 20 | import { overlay } from "./overlay.ts"; | |
| 21 | import { HEADLESS, fakePage, type FakePage } from "./page.ts"; | |
| 22 | ||
| 23 | const ENGINE = import.meta.dir; | |
| 24 | const ROOT = resolve(ENGINE, "..", ".."); | |
| 25 | ||
| 26 | /** A page shaped like a small game's: two canvases, a note, and two buttons. */ | |
| 27 | const demo = (): FakePage => | |
| 28 | fakePage({ | |
| 29 | ids: ["scene", "hud", "note", "fps"], | |
| 30 | canvases: ["scene", "hud"], | |
| 31 | hidden: ["note"], | |
| 32 | actions: ["jump", "fire"], | |
| 33 | }); | |
| 34 | ||
| 35 | // --- events ---------------------------------------------------------------- | |
| 36 | ||
| 37 | test("an event reaches every listener on that target, and nothing else", () => { | |
| 38 | const page = fakePage(); | |
| 39 | const seen: string[] = []; | |
| 40 | page.window.addEventListener("keydown", (event) => seen.push(`one:${event.code}`)); | |
| 41 | page.window.addEventListener("keydown", (event) => seen.push(`two:${event.code}`)); | |
| 42 | page.window.addEventListener("keyup", () => seen.push("up")); | |
| 43 | page.document.addEventListener("keydown", () => seen.push("document")); | |
| 44 | ||
| 45 | page.window.fire("keydown", { code: "KeyW" }); | |
| 46 | expect(seen).toEqual(["one:KeyW", "two:KeyW"]); | |
| 47 | ||
| 48 | // A type nobody listens for is delivered to nobody, and is not an exception. | |
| 49 | page.window.fire("pointerdown"); | |
| 50 | expect(seen).toEqual(["one:KeyW", "two:KeyW"]); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a removed handler hears nothing, and the ones beside it still do", () => { | |
| 54 | const page = fakePage(); | |
| 55 | const seen: string[] = []; | |
| 56 | const first = (): number => seen.push("first"); | |
| 57 | const second = (): number => seen.push("second"); | |
| 58 | page.window.addEventListener("keydown", first); | |
| 59 | page.window.addEventListener("keydown", second); | |
| 60 | ||
| 61 | page.window.removeEventListener("keydown", first); | |
| 62 | page.window.fire("keydown"); | |
| 63 | expect(seen).toEqual(["second"]); | |
| 64 | ||
| 65 | page.window.removeEventListener("keydown", second); | |
| 66 | page.window.fire("keydown"); | |
| 67 | expect(seen).toEqual(["second"]); | |
| 68 | ||
| 69 | // Removing one that was never added is nothing, not a throw. | |
| 70 | expect(() => page.window.removeEventListener("keyup", first)).not.toThrow(); | |
| 71 | }); | |
| 72 | ||
| 73 | test("every event carries a preventDefault, and the fields the test gave it", () => { | |
| 74 | const page = fakePage(); | |
| 75 | const events: Record<string, unknown>[] = []; | |
| 76 | page.surface.addEventListener("mousedown", (event) => events.push(event)); | |
| 77 | page.surface.fire("mousedown", { button: 2, movementX: 4 }); | |
| 78 | expect(typeof events[0]?.preventDefault).toBe("function"); | |
| 79 | expect(events[0]?.button).toBe(2); | |
| 80 | expect(events[0]?.movementX).toBe(4); | |
| 81 | // And an event nobody described is still an event with a preventDefault on it. | |
| 82 | page.surface.fire("contextmenu"); | |
| 83 | expect(typeof events[0]?.preventDefault).toBe("function"); | |
| 84 | }); | |
| 85 | ||
| 86 | test("`listening` is exactly the types with a handler attached", () => { | |
| 87 | const page = fakePage(); | |
| 88 | expect([...page.window.listening]).toEqual([]); | |
| 89 | ||
| 90 | const first = (): void => {}; | |
| 91 | const second = (): void => {}; | |
| 92 | page.window.addEventListener("keydown", first); | |
| 93 | page.window.addEventListener("keydown", second); | |
| 94 | page.window.addEventListener("blur", first); | |
| 95 | expect([...page.window.listening].sort()).toEqual(["blur", "keydown"]); | |
| 96 | // The two targets are two: what is listening on one is not listening on the other. | |
| 97 | expect(page.document.listening.size).toBe(0); | |
| 98 | ||
| 99 | // One of two handlers off leaves the type; the last one takes it away. | |
| 100 | page.window.removeEventListener("keydown", first); | |
| 101 | expect(page.window.listening.has("keydown")).toBe(true); | |
| 102 | page.window.removeEventListener("keydown", second); | |
| 103 | expect([...page.window.listening]).toEqual(["blur"]); | |
| 104 | }); | |
| 105 | ||
| 106 | // --- the host the engine's own parameters describe ------------------------- | |
| 107 | ||
| 108 | test("the page is what createControls asks for: a surface, a window, a document", () => { | |
| 109 | const page = demo(); | |
| 110 | const controls = createControls(page, { keys: { KeyW: "forward" }, mouse: { 0: "fire" } }); | |
| 111 | try { | |
| 112 | page.window.fire("keydown", { code: "KeyW" }); | |
| 113 | expect([...controls.held]).toEqual(["forward"]); | |
| 114 | page.window.fire("keyup", { code: "KeyW" }); | |
| 115 | expect([...controls.held]).toEqual([]); | |
| 116 | ||
| 117 | // The surface is the first canvas, which is what the pointer locks to. | |
| 118 | expect(page.surface).toBe(page.canvases[0] as (typeof page.canvases)[number]); | |
| 119 | expect(page.surface.id).toBe("scene"); | |
| 120 | page.document.pointerLockElement = page.surface; | |
| 121 | page.document.fire("pointerlockchange"); | |
| 122 | expect(controls.locked).toBe(true); | |
| 123 | page.window.fire("mousemove", { movementX: 10, movementY: 0 }); | |
| 124 | expect(controls.takeLook()[0]).toBeCloseTo(10 * 2.2 * 0.022, 6); | |
| 125 | } finally { | |
| 126 | controls.detach(); | |
| 127 | } | |
| 128 | // Detaching leaves the page as it was found, which this page can be asked. | |
| 129 | expect(page.window.listening.size).toBe(0); | |
| 130 | expect(page.document.listening.size).toBe(0); | |
| 131 | expect(page.surface.listening.size).toBe(0); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a page with no canvas still has a surface, and it belongs to no document", () => { | |
| 135 | const page = fakePage(); | |
| 136 | expect(page.canvases).toEqual([]); | |
| 137 | expect(page.surface.id).toBe(""); | |
| 138 | expect(page.surface.clientWidth).toBe(800); | |
| 139 | expect(page.document.getElementById("")).toBeNull(); | |
| 140 | // It is a host all the same: the controls attach to it and let go of it. | |
| 141 | const controls = createControls(page, { keys: {} }); | |
| 142 | expect(page.surface.listening.size).toBeGreaterThan(0); | |
| 143 | controls.detach(); | |
| 144 | expect(page.surface.listening.size).toBe(0); | |
| 145 | }); | |
| 146 | ||
| 147 | test("attachButtons finds the page's buttons and a press is the same action as a key", () => { | |
| 148 | const page = demo(); | |
| 149 | const controls = createControls(page, { keys: { KeyF: "fire" } }); | |
| 150 | try { | |
| 151 | attachButtons(page.document.querySelectorAll("[data-action]"), controls); | |
| 152 | expect(page.buttons.map((b) => b.dataset.action)).toEqual(["jump", "fire"]); | |
| 153 | page.buttons[1]?.fire("pointerdown"); | |
| 154 | expect([...controls.held]).toEqual(["fire"]); | |
| 155 | page.buttons[1]?.fire("pointerup"); | |
| 156 | expect([...controls.held]).toEqual([]); | |
| 157 | } finally { | |
| 158 | controls.detach(); | |
| 159 | } | |
| 160 | }); | |
| 161 | ||
| 162 | // --- elements -------------------------------------------------------------- | |
| 163 | ||
| 164 | test("an element reads back what was hidden, written and focused on it", () => { | |
| 165 | const page = demo(); | |
| 166 | const note = page.by("note"); | |
| 167 | // The page is served with this one hidden, which is what `hidden` is for. | |
| 168 | expect(note.hidden).toBe(true); | |
| 169 | expect(page.by("fps").hidden).toBe(false); | |
| 170 | ||
| 171 | note.hidden = false; | |
| 172 | expect(note.hidden).toBe(false); | |
| 173 | note.textContent = "30 fps · 33.3 ms"; | |
| 174 | expect(note.textContent).toBe("30 fps · 33.3 ms"); | |
| 175 | expect(note.focused).toBe(0); | |
| 176 | note.focus(); | |
| 177 | note.focus(); | |
| 178 | expect(note.focused).toBe(2); | |
| 179 | // An id the page was never given is nothing, not a throw. | |
| 180 | expect(page.document.getElementById("nobody")).toBeNull(); | |
| 181 | }); | |
| 182 | ||
| 183 | test("innerHTML remembers the markup and hands back the classes it named", () => { | |
| 184 | const page = demo(); | |
| 185 | const note = page.by("note"); | |
| 186 | note.innerHTML = `<div class="panel"><button class="buy">AK-47</button></div>`; | |
| 187 | expect(note.innerHTML).toContain("AK-47"); | |
| 188 | expect(note.children.length).toBe(2); | |
| 189 | expect(note.querySelector(".buy")?.className).toBe("buy"); | |
| 190 | expect(note.querySelector(".nothing")).toBeNull(); | |
| 191 | // Writing it again is a new set of children, not more of the old ones. | |
| 192 | note.innerHTML = `<p class="panel">empty</p>`; | |
| 193 | expect(note.children.length).toBe(1); | |
| 194 | ||
| 195 | const made = page.document.createElement("button"); | |
| 196 | made.textContent = "play"; | |
| 197 | note.appendChild(made); | |
| 198 | expect(note.children.length).toBe(2); | |
| 199 | expect(note.querySelector("button")?.textContent).toBe("play"); | |
| 200 | }); | |
| 201 | ||
| 202 | // --- the 2D recorder ------------------------------------------------------- | |
| 203 | ||
| 204 | test("a recorded 2D call keeps its name and its arguments, in the order they came", () => { | |
| 205 | const page = demo(); | |
| 206 | const paint = page.surface.paint; | |
| 207 | if (paint === null) throw new Error("this page was asked for a 2D context"); | |
| 208 | paint.fillStyle = "#123456"; | |
| 209 | paint.fillRect(1, 2, 3, 4); | |
| 210 | paint.fillText("held nothing", 8, 20); | |
| 211 | paint.beginPath(); | |
| 212 | paint.arc(10, 20, 30, 0, Math.PI); | |
| 213 | paint.stroke(); | |
| 214 | ||
| 215 | expect(paint.calls).toEqual([ | |
| 216 | ["fillRect", 1, 2, 3, 4], | |
| 217 | ["fillText", "held nothing", 8, 20], | |
| 218 | ["beginPath"], | |
| 219 | ["arc", 10, 20, 30, 0, Math.PI], | |
| 220 | ["stroke"], | |
| 221 | ]); | |
| 222 | // A style is a value it keeps, not a call it records. | |
| 223 | expect(paint.fillStyle).toBe("#123456"); | |
| 224 | expect(paint.texts).toEqual(["held nothing"]); | |
| 225 | }); | |
| 226 | ||
| 227 | test("the recorder is a Paint, so the engine's overlay draws onto one", () => { | |
| 228 | const page = demo(); | |
| 229 | const paint = page.canvases[1]?.paint; | |
| 230 | if (paint === null || paint === undefined) throw new Error("the hud was asked for a 2D context"); | |
| 231 | const draw = overlay(paint, 800, 450); | |
| 232 | paint.clearRect(0, 0, 800, 450); | |
| 233 | draw.text("tick 1", 8, 20); | |
| 234 | draw.crosshair(400, 225, 4, 10, "#fff"); | |
| 235 | expect(paint.texts).toEqual(["tick 1"]); | |
| 236 | expect(paint.calls[0]).toEqual(["clearRect", 0, 0, 800, 450]); | |
| 237 | expect(paint.calls.some((call) => call[0] === "stroke")).toBe(true); | |
| 238 | }); | |
| 239 | ||
| 240 | test("a browser with no 2D context hands back null rather than a recorder", () => { | |
| 241 | const page = fakePage({ ids: ["scene"], canvases: ["scene"], paint: false }); | |
| 242 | expect(page.surface.paint).toBeNull(); | |
| 243 | expect(page.surface.getContext("2d")).toBeNull(); | |
| 244 | // And WebGL2 is a slot the game fills: this file does not know what one is. | |
| 245 | expect(page.surface.getContext("webgl2")).toBeNull(); | |
| 246 | const recorder = { calls: [] as string[] }; | |
| 247 | page.surface.gl = recorder; | |
| 248 | expect(page.surface.getContext("webgl2")).toBe(recorder); | |
| 249 | expect(page.surface.getContext("bitmaprenderer")).toBeNull(); | |
| 250 | }); | |
| 251 | ||
| 252 | test("the canvas counts the times it was asked for the pointer", () => { | |
| 253 | const page = demo(); | |
| 254 | expect(page.surface.locks).toBe(0); | |
| 255 | page.surface.requestPointerLock(); | |
| 256 | page.surface.requestPointerLock(); | |
| 257 | expect(page.surface.locks).toBe(2); | |
| 258 | page.document.pointerLockElement = page.surface; | |
| 259 | page.document.exitPointerLock(); | |
| 260 | expect(page.document.pointerLockElement).toBeNull(); | |
| 261 | }); | |
| 262 | ||
| 263 | // --- the clock ------------------------------------------------------------- | |
| 264 | ||
| 265 | test("a frame waits in the queue until the test runs it, and the clock is the test's", () => { | |
| 266 | const page = demo(); | |
| 267 | const seen: number[] = []; | |
| 268 | const start = page.now(); | |
| 269 | page.window.requestAnimationFrame((now) => seen.push(now)); | |
| 270 | page.window.requestAnimationFrame((now) => seen.push(now)); | |
| 271 | expect(page.frames.length).toBe(2); | |
| 272 | expect(seen).toEqual([]); | |
| 273 | expect(page.window.performance.now()).toBe(start); | |
| 274 | ||
| 275 | page.step(16); | |
| 276 | expect(seen).toEqual([start + 16]); | |
| 277 | expect(page.frames.length).toBe(1); | |
| 278 | expect(page.now()).toBe(start + 16); | |
| 279 | ||
| 280 | // The oldest frame first, and a step with nothing queued only moves the clock. | |
| 281 | page.step(16); | |
| 282 | page.step(16); | |
| 283 | expect(seen).toEqual([start + 16, start + 32]); | |
| 284 | expect(page.now()).toBe(start + 48); | |
| 285 | ||
| 286 | // Cancelling drops what was queued. | |
| 287 | page.window.requestAnimationFrame((now) => seen.push(now)); | |
| 288 | page.window.cancelAnimationFrame(); | |
| 289 | expect(page.frames.length).toBe(0); | |
| 290 | }); | |
| 291 | ||
| 292 | test("a phone is a page that says so, and nothing else about it changes", () => { | |
| 293 | expect(fakePage().window.matchMedia("(pointer: coarse)").matches).toBe(false); | |
| 294 | expect(fakePage({ coarse: true }).window.matchMedia("(pointer: coarse)").matches).toBe(true); | |
| 295 | expect(fakePage().window.devicePixelRatio).toBe(1); | |
| 296 | expect(fakePage().document.readyState).toBe("complete"); | |
| 297 | }); | |
| 298 | ||
| 299 | // --- honest about being a double ------------------------------------------- | |
| 300 | ||
| 301 | test("the module has no timer of its own and touches no global", () => { | |
| 302 | const source = readFileSync(join(ENGINE, "page.ts"), "utf8"); | |
| 303 | // The comments come out first: this file is allowed to talk about them. | |
| 304 | const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); | |
| 305 | for (const forbidden of ["globalThis", "setTimeout", "setInterval", "Date.now", "window."]) | |
| 306 | expect(code).not.toContain(forbidden); | |
| 307 | // Installing a page as the globals an entry reads is the game's own business: | |
| 308 | // its helper does it, over the objects this one hands back. | |
| 309 | expect(code).toContain("export function fakePage"); | |
| 310 | }); | |
| 311 | ||
| 312 | test("neither committed bundle carries the double", () => { | |
| 313 | const dir = join(ROOT, "static"); | |
| 314 | const bundles = readdirSync(dir).filter((file) => file.endsWith(".js")); | |
| 315 | // Both games' entries, so this is not a test that passes on an empty directory. | |
| 316 | expect(bundles.length).toBeGreaterThanOrEqual(2); | |
| 317 | for (const file of bundles) { | |
| 318 | const code = readFileSync(join(dir, file), "utf8"); | |
| 319 | // The reader is alive: these files are what it thinks they are. | |
| 320 | expect(code.length).toBeGreaterThan(1000); | |
| 321 | expect(code).toContain("webgl2"); | |
| 322 | // And no byte of this module is in them — the sentence it carries, and the | |
| 323 | // one pattern only it has. | |
| 324 | expect(code).not.toContain(HEADLESS); | |
| 325 | expect(code).not.toContain(`class="([a-z-]+)"`); | |
| 326 | } | |
| 327 | }); |
src/engine/page.ts added +344
| @@ -0,0 +1,344 @@ | ||
| 1 | /** | |
| 2 | * A page made of plain objects: the handful of browser shapes a game's entry | |
| 3 | * and this package's own `createControls` actually touch, and a clock that only | |
| 4 | * moves when the test says so. A game asks this package for a host — a | |
| 5 | * `surface`, a `window`, a `document` — so this package hands one back, and an | |
| 6 | * entry can be booted and stepped in a process with no browser in it. | |
| 7 | * | |
| 8 | * It is behind the door for the same reason `Listens` is: both games needed it, | |
| 9 | * and neither may import the other's, so the copy they shared was the one thing | |
| 10 | * a third game would copy out of whichever directory was nearer. It ships in no | |
| 11 | * bundle — nothing a game bundles imports it, and `page.test.ts` reads the | |
| 12 | * committed bundles to say so. | |
| 13 | * | |
| 14 | * Everything here records rather than does. An element remembers what was | |
| 15 | * hidden, written and focused; a canvas's 2D context writes down every call it | |
| 16 | * was asked for, in order, with its arguments; a target remembers what is | |
| 17 | * listening on it and delivers an event to exactly those handlers. It is honest | |
| 18 | * about being a double: it has no timer, it touches no global, it throws for | |
| 19 | * nothing, and it knows what neither a renderer nor an `AudioContext` is — | |
| 20 | * `canvas.gl` is a slot a game fills with its own recorder, because what a | |
| 21 | * WebGL2 context means is the game's business and not this file's. | |
| 22 | * | |
| 23 | * What it does not have is what belongs to one game: its ids, its buttons' names, | |
| 24 | * its sounds, its posts. Those arrive as `PageOptions` or stay in the game's own | |
| 25 | * helper — `src/client/stage.test-helper.ts` and `src/range/stage.test-helper.ts` | |
| 26 | * are each the game's half, built on this one. | |
| 27 | */ | |
| 28 | ||
| 29 | import type { Paint } from "./overlay.ts"; | |
| 30 | ||
| 31 | /** | |
| 32 | * A sentence that appears in this file and in no other. A minifier renames | |
| 33 | * every identifier it can reach but keeps the bytes of a string, so | |
| 34 | * `page.test.ts` reads the committed bundles for this one: a double that ever | |
| 35 | * ends up in a file a browser downloads fails there. | |
| 36 | */ | |
| 37 | export const HEADLESS = "src/engine/page.ts: a double of a browser, in no bundle"; | |
| 38 | ||
| 39 | type Handler = (event: any) => void; | |
| 40 | ||
| 41 | /** Anything that can be listened to, and told what happened. */ | |
| 42 | export type FakeTarget = { | |
| 43 | /** Deliver an event to everything listening for `type` here, and to nothing else. */ | |
| 44 | fire(type: string, event?: unknown): void; | |
| 45 | /** | |
| 46 | * The event types with at least one handler attached right now. It is live: | |
| 47 | * the last `removeEventListener` for a type takes it back out, so a page that | |
| 48 | * was never booted has an empty one and a detached game leaves one behind. | |
| 49 | */ | |
| 50 | readonly listening: ReadonlySet<string>; | |
| 51 | addEventListener(type: string, handler: Handler, options?: { passive?: boolean }): void; | |
| 52 | removeEventListener(type: string, handler: Handler): void; | |
| 53 | }; | |
| 54 | ||
| 55 | function target(): FakeTarget { | |
| 56 | const handlers = new Map<string, Handler[]>(); | |
| 57 | const listening = new Set<string>(); | |
| 58 | return { | |
| 59 | listening, | |
| 60 | addEventListener(type, handler) { | |
| 61 | const list = handlers.get(type); | |
| 62 | if (list === undefined) handlers.set(type, [handler]); | |
| 63 | else list.push(handler); | |
| 64 | listening.add(type); | |
| 65 | }, | |
| 66 | removeEventListener(type, handler) { | |
| 67 | const list = handlers.get(type) ?? []; | |
| 68 | const at = list.indexOf(handler); | |
| 69 | if (at >= 0) list.splice(at, 1); | |
| 70 | if (list.length === 0) listening.delete(type); | |
| 71 | }, | |
| 72 | // A copy, because a handler may detach itself while this is delivering. | |
| 73 | // Every event carries a `preventDefault`, which is the one member the | |
| 74 | // engine's own listeners call on one they were given. | |
| 75 | fire(type, event = {}) { | |
| 76 | for (const handler of [...(handlers.get(type) ?? [])]) | |
| 77 | handler({ preventDefault() {}, ...(event as object) }); | |
| 78 | }, | |
| 79 | }; | |
| 80 | } | |
| 81 | ||
| 82 | export type FakeElement = FakeTarget & { | |
| 83 | id: string; | |
| 84 | tag: string; | |
| 85 | className: string; | |
| 86 | hidden: boolean; | |
| 87 | innerHTML: string; | |
| 88 | textContent: string | null; | |
| 89 | value: string; | |
| 90 | disabled: boolean; | |
| 91 | type: string; | |
| 92 | dataset: { action?: string }; | |
| 93 | /** How many times something asked for the focus. */ | |
| 94 | focused: number; | |
| 95 | readonly children: FakeElement[]; | |
| 96 | focus(): void; | |
| 97 | appendChild(child: FakeElement): void; | |
| 98 | querySelector(selector: string): FakeElement | null; | |
| 99 | }; | |
| 100 | ||
| 101 | /** | |
| 102 | * The one thing this page knows about markup: `class="grid"` in an `innerHTML` | |
| 103 | * makes a child you can find by `.grid`. That is what a panel built from a | |
| 104 | * string does — write its shell, then fill the grid it just wrote — and nothing | |
| 105 | * here parses anything else. | |
| 106 | */ | |
| 107 | const CLASS = /class="([a-z-]+)"/g; | |
| 108 | ||
| 109 | function element(options: { id?: string; tag?: string; action?: string; className?: string } = {}): FakeElement { | |
| 110 | const children: FakeElement[] = []; | |
| 111 | let html = ""; | |
| 112 | const self: FakeElement = { | |
| 113 | ...target(), | |
| 114 | id: options.id ?? "", | |
| 115 | tag: options.tag ?? "div", | |
| 116 | className: options.className ?? "", | |
| 117 | dataset: options.action === undefined ? {} : { action: options.action }, | |
| 118 | hidden: false, | |
| 119 | textContent: "", | |
| 120 | value: "", | |
| 121 | disabled: false, | |
| 122 | type: "", | |
| 123 | focused: 0, | |
| 124 | children, | |
| 125 | get innerHTML() { | |
| 126 | return html; | |
| 127 | }, | |
| 128 | set innerHTML(value: string) { | |
| 129 | html = value; | |
| 130 | children.length = 0; | |
| 131 | for (const [, name] of value.matchAll(CLASS)) children.push(element({ className: name as string })); | |
| 132 | }, | |
| 133 | focus() { | |
| 134 | self.focused += 1; | |
| 135 | }, | |
| 136 | appendChild(child) { | |
| 137 | children.push(child); | |
| 138 | }, | |
| 139 | querySelector(selector) { | |
| 140 | const hit = (e: FakeElement): boolean => | |
| 141 | selector.startsWith(".") ? e.className === selector.slice(1) : e.tag === selector; | |
| 142 | for (const child of children) { | |
| 143 | if (hit(child)) return child; | |
| 144 | const deeper = child.querySelector(selector); | |
| 145 | if (deeper !== null) return deeper; | |
| 146 | } | |
| 147 | return null; | |
| 148 | }, | |
| 149 | }; | |
| 150 | return self; | |
| 151 | } | |
| 152 | ||
| 153 | /** The members of `Paint` a recorder answers as the plain strings a game sets. */ | |
| 154 | type Styles = "fillStyle" | "strokeStyle" | "font" | "textAlign" | "textBaseline"; | |
| 155 | ||
| 156 | /** | |
| 157 | * Every 2D call, written down in order with its arguments: `["fillText", "hi", | |
| 158 | * 8, 20]`. It is a `Paint` — the engine's own 2D surface — so anything that | |
| 159 | * draws through `overlay` draws onto one of these, and a test reads a list | |
| 160 | * instead of a screen. The styles are strings rather than the `unknown` a real | |
| 161 | * context's are, so a game with a narrower surface of its own takes one too. | |
| 162 | */ | |
| 163 | export type FakePaint = Omit<Paint, Styles> & | |
| 164 | Record<Styles, string> & { | |
| 165 | /** Every call, oldest first. */ | |
| 166 | readonly calls: [string, ...unknown[]][]; | |
| 167 | /** The first argument of every `fillText`, which is the drawn words. */ | |
| 168 | readonly texts: string[]; | |
| 169 | }; | |
| 170 | ||
| 171 | function paintRecorder(): FakePaint { | |
| 172 | const calls: [string, ...unknown[]][] = []; | |
| 173 | const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]); | |
| 174 | return { | |
| 175 | calls, | |
| 176 | get texts() { | |
| 177 | return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1])); | |
| 178 | }, | |
| 179 | fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1, | |
| 180 | clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"), | |
| 181 | beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"), | |
| 182 | arc: note("arc"), fill: note("fill"), stroke: note("stroke"), | |
| 183 | }; | |
| 184 | } | |
| 185 | ||
| 186 | export type FakeCanvas = FakeElement & { | |
| 187 | clientWidth: number; | |
| 188 | clientHeight: number; | |
| 189 | width: number; | |
| 190 | height: number; | |
| 191 | /** The 2D recorder this canvas hands out, or null for a browser with none. */ | |
| 192 | paint: FakePaint | null; | |
| 193 | /** | |
| 194 | * What `getContext("webgl2")` answers with. It starts null — a browser with | |
| 195 | * no WebGL2, which is a browser a game has to keep working on — and a game | |
| 196 | * puts its own recorder here, because this file does not know what a GL | |
| 197 | * context is and should not. | |
| 198 | */ | |
| 199 | gl: unknown; | |
| 200 | /** How many times something asked for the pointer. */ | |
| 201 | locks: number; | |
| 202 | getContext(kind: string): unknown; | |
| 203 | requestPointerLock(): void; | |
| 204 | }; | |
| 205 | ||
| 206 | function canvasElement(id: string, paint: boolean): FakeCanvas { | |
| 207 | return { | |
| 208 | ...element({ id, tag: "canvas" }), | |
| 209 | clientWidth: 800, clientHeight: 450, width: 0, height: 0, | |
| 210 | paint: paint ? paintRecorder() : null, | |
| 211 | gl: null, | |
| 212 | locks: 0, | |
| 213 | getContext(kind) { | |
| 214 | if (kind === "2d") return this.paint; | |
| 215 | return kind === "webgl2" ? this.gl : null; | |
| 216 | }, | |
| 217 | requestPointerLock() { | |
| 218 | this.locks += 1; | |
| 219 | }, | |
| 220 | }; | |
| 221 | } | |
| 222 | ||
| 223 | export type FakeWindow = FakeTarget & { | |
| 224 | devicePixelRatio: number; | |
| 225 | /** Queue a frame. It runs when the test calls `step`, and never before. */ | |
| 226 | requestAnimationFrame(callback: (now: number) => void): number; | |
| 227 | /** Drop every queued frame. The handle is ignored: this page runs one loop. */ | |
| 228 | cancelAnimationFrame(handle?: number): void; | |
| 229 | /** Answers `coarse`, whatever is asked: a page is a phone or it is not. */ | |
| 230 | matchMedia(query: string): { matches: boolean }; | |
| 231 | performance: { now(): number }; | |
| 232 | }; | |
| 233 | ||
| 234 | export type FakeDocument = FakeTarget & { | |
| 235 | /** `"complete"`, so an entry boots on import; set `"loading"` for the other path. */ | |
| 236 | readyState: string; | |
| 237 | pointerLockElement: unknown; | |
| 238 | getElementById(id: string): FakeElement | null; | |
| 239 | /** The `[data-action]` buttons, whatever the selector says: there is one list. */ | |
| 240 | querySelectorAll(selector: string): FakeElement[]; | |
| 241 | createElement(tag: string): FakeElement; | |
| 242 | exitPointerLock(): void; | |
| 243 | }; | |
| 244 | ||
| 245 | /** What a game brings: its own ids, its own buttons, and the browser it is on. */ | |
| 246 | export type PageOptions = { | |
| 247 | /** Every id `getElementById` answers for. Anything else answers null. */ | |
| 248 | ids?: readonly string[]; | |
| 249 | /** Which of those ids are canvases. The first one is the `surface`. */ | |
| 250 | canvases?: readonly string[]; | |
| 251 | /** Which of those ids the page is served with `hidden` already set. */ | |
| 252 | hidden?: readonly string[]; | |
| 253 | /** One `[data-action]` element per name, in this order. */ | |
| 254 | actions?: readonly string[]; | |
| 255 | /** Give the canvases a 2D recorder. False is a browser that has no 2D context. */ | |
| 256 | paint?: boolean; | |
| 257 | /** What `matchMedia` answers. True is a phone. */ | |
| 258 | coarse?: boolean; | |
| 259 | }; | |
| 260 | ||
| 261 | export type FakePage = { | |
| 262 | /** | |
| 263 | * The element the pointer locks to and the fingers land on — the first | |
| 264 | * canvas, or a loose one belonging to no document when none was asked for. | |
| 265 | * With `window` and `document` beside it this is `createControls`'s host. | |
| 266 | */ | |
| 267 | surface: FakeCanvas; | |
| 268 | window: FakeWindow; | |
| 269 | document: FakeDocument; | |
| 270 | /** The canvases, in the order `canvases` named them. */ | |
| 271 | canvases: FakeCanvas[]; | |
| 272 | /** The `[data-action]` elements, in the order `actions` named them. */ | |
| 273 | buttons: FakeElement[]; | |
| 274 | /** The element with this id. A game asks for the ids it put in `ids`. */ | |
| 275 | by(id: string): FakeElement; | |
| 276 | /** The frames asked for but not yet run, oldest first. */ | |
| 277 | frames: ((now: number) => void)[]; | |
| 278 | /** Run the next frame, `ms` after the last one. Nothing else moves the clock. */ | |
| 279 | step(ms: number): void; | |
| 280 | /** What `performance.now()` reads right now, in milliseconds. */ | |
| 281 | now(): number; | |
| 282 | }; | |
| 283 | ||
| 284 | /** Where this page's clock starts. A browser's is not zero either. */ | |
| 285 | const START = 1000; | |
| 286 | ||
| 287 | /** | |
| 288 | * A page a game can boot on. Nothing here runs until a test says so: a frame | |
| 289 | * waits in `frames` until `step`, and the clock moves by exactly what `step` was | |
| 290 | * given. | |
| 291 | */ | |
| 292 | export function fakePage(options: PageOptions = {}): FakePage { | |
| 293 | const paint = options.paint ?? true; | |
| 294 | const canvasIds = new Set(options.canvases ?? []); | |
| 295 | const elements = new Map<string, FakeElement>( | |
| 296 | (options.ids ?? []).map((id) => [id, canvasIds.has(id) ? canvasElement(id, paint) : element({ id })]), | |
| 297 | ); | |
| 298 | for (const id of options.hidden ?? []) { | |
| 299 | const hide = elements.get(id); | |
| 300 | if (hide !== undefined) hide.hidden = true; | |
| 301 | } | |
| 302 | const canvases = [...canvasIds] | |
| 303 | .map((id) => elements.get(id)) | |
| 304 | .filter((found): found is FakeCanvas => found !== undefined); | |
| 305 | const buttons = (options.actions ?? []).map((action) => element({ action })); | |
| 306 | const frames: ((now: number) => void)[] = []; | |
| 307 | let clock = START; | |
| 308 | ||
| 309 | const document: FakeDocument = { | |
| 310 | ...target(), | |
| 311 | readyState: "complete", | |
| 312 | pointerLockElement: null as unknown, | |
| 313 | getElementById: (id) => elements.get(id) ?? null, | |
| 314 | querySelectorAll: (_selector) => buttons, | |
| 315 | createElement: (tag) => element({ tag }), | |
| 316 | exitPointerLock(): void { | |
| 317 | document.pointerLockElement = null; | |
| 318 | }, | |
| 319 | }; | |
| 320 | ||
| 321 | const window: FakeWindow = { | |
| 322 | ...target(), | |
| 323 | devicePixelRatio: 1, | |
| 324 | requestAnimationFrame: (callback) => frames.push(callback), | |
| 325 | cancelAnimationFrame: () => void frames.splice(0, frames.length), | |
| 326 | matchMedia: (_query) => ({ matches: options.coarse === true }), | |
| 327 | performance: { now: () => clock }, | |
| 328 | }; | |
| 329 | ||
| 330 | return { | |
| 331 | surface: canvases[0] ?? canvasElement("", paint), | |
| 332 | window, | |
| 333 | document, | |
| 334 | canvases, | |
| 335 | buttons, | |
| 336 | frames, | |
| 337 | by: (id) => elements.get(id) as FakeElement, | |
| 338 | step(ms) { | |
| 339 | clock += ms; | |
| 340 | frames.shift()?.(clock); | |
| 341 | }, | |
| 342 | now: () => clock, | |
| 343 | }; | |
| 344 | } |
src/range/boot.test.ts +17 −11
| @@ -1,13 +1,19 @@ | ||
| 1 | 1 | /** |
| 2 | * The entry itself, booted against the stub page in `dom.ts`: the ids it looks | |
| 2 | * The entry itself, booted against the engine's headless page: the ids it looks | |
| 3 | 3 | * up, the listeners it attaches, the frames it asks for and what it draws and |
| 4 | 4 | * plays when one runs. Nothing here is a browser, and nothing here is timed by |
| 5 | 5 | * a real clock — the test decides when a frame happens and how long it took. |
| 6 | * | |
| 7 | * The page comes from `src/engine/index.ts` through `stage.test-helper.ts`, | |
| 8 | * which adds the three things that are this game's and not the engine's: the | |
| 9 | * ids `src/pages/range.ts` renders, the GL recorder, and the counted | |
| 10 | * `AudioContext`. The range importing the engine is the whole point of the | |
| 11 | * range, so `src/independence.test.ts` has nothing to say about it. | |
| 6 | 12 | */ |
| 7 | 13 | |
| 8 | 14 | import { expect, test } from "bun:test"; |
| 9 | 15 | import { HIT, POP, TICK } from "./audio.ts"; |
| 10 | import { page, type FakePage, type Voice } from "./dom.ts"; | |
| 16 | import { page, type RangePage, type Voice } from "./stage.test-helper.ts"; | |
| 11 | 17 | |
| 12 | 18 | // The entry boots itself when it is imported, so it is imported into a page |
| 13 | 19 | // that is still loading: then every test below boots a fresh one on purpose. |
| @@ -18,14 +24,14 @@ const { boot } = await import("./main.ts"); | ||
| 18 | 24 | undoLoading(); |
| 19 | 25 | |
| 20 | 26 | /** A booted page, and the undo that puts the globals back. */ |
| 21 | function booted(options: { has2d?: boolean; webgl?: boolean } = {}): { p: FakePage; undo: () => void } { | |
| 27 | function booted(options: { has2d?: boolean; webgl?: boolean } = {}): { p: RangePage; undo: () => void } { | |
| 22 | 28 | const p = page(options); |
| 23 | 29 | const undo = p.install(); |
| 24 | 30 | boot(); |
| 25 | 31 | return { p, undo }; |
| 26 | 32 | } |
| 27 | 33 | |
| 28 | const hudTexts = (p: FakePage): string[] => p.hud.paint?.texts ?? []; | |
| 34 | const hudTexts = (p: RangePage): string[] => p.hud.paint?.texts ?? []; | |
| 29 | 35 | |
| 30 | 36 | test("it boots, asks for a frame, and draws the readout the loop produced", () => { |
| 31 | 37 | const { p, undo } = booted(); |
| @@ -150,13 +156,13 @@ test("a canvas with no context is a sentence on the page, not an exception", () | ||
| 150 | 156 | }); |
| 151 | 157 | |
| 152 | 158 | /** The fillRects of the last frame, less the sky and the ground: the plates. */ |
| 153 | function platesDrawn(p: FakePage, from: number): [number, ...unknown[]][] { | |
| 159 | function platesDrawn(p: RangePage, from: number): [number, ...unknown[]][] { | |
| 154 | 160 | const calls = (p.scene.paint?.calls ?? []).slice(from); |
| 155 | 161 | return calls.filter((c) => c[0] === "fillRect").slice(2) as [number, ...unknown[]][]; |
| 156 | 162 | } |
| 157 | 163 | |
| 158 | 164 | /** The most recent line the panel drew starting with `prefix`: this frame's. */ |
| 159 | const latest = (p: FakePage, prefix: string): string => | |
| 165 | const latest = (p: RangePage, prefix: string): string => | |
| 160 | 166 | [...hudTexts(p)].reverse().find((line) => line.startsWith(prefix)) ?? ""; |
| 161 | 167 | |
| 162 | 168 | /** Degrees of view per unit of mouse movement: the engine's 2.2 × 0.022. */ |
| @@ -265,15 +271,15 @@ test("the clock ends the run, the entry stops shooting and the best score stays" | ||
| 265 | 271 | }); |
| 266 | 272 | |
| 267 | 273 | /** How many plates the panel said were standing in the frame just drawn. */ |
| 268 | const standing = (p: FakePage): number => | |
| 274 | const standing = (p: RangePage): number => | |
| 269 | 275 | Number(/(\d+) up/.exec([...hudTexts(p)].reverse().find((line) => line.includes(" up")) ?? "")?.[1]); |
| 270 | 276 | |
| 271 | 277 | /** Every GL call the scene canvas was given, in order. */ |
| 272 | const glCalls = (p: FakePage): [string, ...unknown[]][] => | |
| 278 | const glCalls = (p: RangePage): [string, ...unknown[]][] => | |
| 273 | 279 | (p.scene.gl?.calls ?? []) as [string, ...unknown[]][]; |
| 274 | 280 | |
| 275 | 281 | /** What one frame asked the GL context for: the calls made after `from`. */ |
| 276 | const since = (p: FakePage, from: number): [string, ...unknown[]][] => glCalls(p).slice(from); | |
| 282 | const since = (p: RangePage, from: number): [string, ...unknown[]][] => glCalls(p).slice(from); | |
| 277 | 283 | |
| 278 | 284 | const named = (calls: [string, ...unknown[]][], name: string): [string, ...unknown[]][] => |
| 279 | 285 | calls.filter((call) => call[0] === name); |
| @@ -358,10 +364,10 @@ const nameOf = (voice: Voice): string => { | ||
| 358 | 364 | }; |
| 359 | 365 | |
| 360 | 366 | /** Every sound the page has made, named, oldest first. */ |
| 361 | const sounds = (p: FakePage): string[] => p.audio.voices.map(nameOf); | |
| 367 | const sounds = (p: RangePage): string[] => p.audio.voices.map(nameOf); | |
| 362 | 368 | |
| 363 | 369 | /** The sounds made since there were `from` of them. */ |
| 364 | const sincePlayed = (p: FakePage, from: number): string[] => sounds(p).slice(from); | |
| 370 | const sincePlayed = (p: RangePage, from: number): string[] => sounds(p).slice(from); | |
| 365 | 371 | |
| 366 | 372 | test("booting makes no sound, and a page nobody plays opens no AudioContext", () => { |
| 367 | 373 | const { p, undo } = booted(); |
src/range/bundle.test.ts +8 −6
| @@ -1,12 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The committed bundle, booted. `boot.test.ts` proves the range's *sources* run |
| 3 | * against the stub page in `dom.ts`; this proves the minified file in `static/` | |
| 3 | * against the engine's headless page; this proves the minified file in `static/` | |
| 4 | 4 | * does, which is the file a browser actually downloads. A bundling mistake — a |
| 5 | 5 | * mangled name the entry needed, a tree shake that took the boot line with it — |
| 6 | 6 | * fails here rather than in the preview. |
| 7 | 7 | * |
| 8 | * Nothing under `src/range/` is imported except `dom.ts`, which ships in no | |
| 9 | * bundle: the thing under test is the artifact, found on disk by name. Finding | |
| 8 | * Nothing under `src/range/` is imported except `stage.test-helper.ts`, which | |
| 9 | * ships in no bundle — and neither does the engine's page under it, which | |
| 10 | * `src/engine/page.test.ts` checks by reading this very file off disk. The thing | |
| 11 | * under test is the artifact, found on disk by name. Finding | |
| 10 | 12 | * it and importing it afresh is `scripts/bundle.test-helper.ts`, shared with the |
| 11 | 13 | * shooter's `src/client/bundle.test.ts` — the same file, the same trick, one |
| 12 | 14 | * copy of it. |
| @@ -14,7 +16,7 @@ | ||
| 14 | 16 | |
| 15 | 17 | import { afterAll, expect, test } from "bun:test"; |
| 16 | 18 | import { discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts"; |
| 17 | import { page, type FakePage } from "./dom.ts"; | |
| 19 | import { page, type RangePage } from "./stage.test-helper.ts"; | |
| 18 | 20 | |
| 19 | 21 | afterAll(discardBundleCopies); |
| 20 | 22 | |
| @@ -28,7 +30,7 @@ const HAD_DOCUMENT = "document" in globalThis; | ||
| 28 | 30 | */ |
| 29 | 31 | async function booted( |
| 30 | 32 | options: { has2d?: boolean; webgl?: boolean } = {}, |
| 31 | ): Promise<{ p: FakePage; undo: () => void }> { | |
| 33 | ): Promise<{ p: RangePage; undo: () => void }> { | |
| 32 | 34 | const p = page(options); |
| 33 | 35 | const undo = p.install(); |
| 34 | 36 | try { |
| @@ -40,7 +42,7 @@ async function booted( | ||
| 40 | 42 | return { p, undo }; |
| 41 | 43 | } |
| 42 | 44 | |
| 43 | const hudTexts = (p: FakePage): string[] => p.hud.paint?.texts ?? []; | |
| 45 | const hudTexts = (p: RangePage): string[] => p.hud.paint?.texts ?? []; | |
| 44 | 46 | |
| 45 | 47 | test("the committed bundle boots the page and draws what the loop produced", async () => { |
| 46 | 48 | const { p, undo } = await booted(); |
src/range/dom.ts → src/range/stage.test-helper.ts renamed +63 −152
| @@ -1,86 +1,34 @@ | ||
| 1 | 1 | /** |
| 2 | * A document made of plain objects: the handful of DOM shapes `main.ts` and the | |
| 3 | * engine's controls actually touch, and nothing else. It exists so the entry | |
| 4 | * can be booted and stepped in a test with no browser — the same trick | |
| 5 | * `src/engine/controls.ts` plays with its `Listens` parameter, one level up. | |
| 6 | * | |
| 7 | * Two recorders hang off a canvas here: the 2D one the readout's panel draws | |
| 8 | * onto, and — when a test asks for `webgl` — a WebGL2 one that writes down every | |
| 9 | * call the engine's renderer makes, so the drawn game is checked by reading a | |
| 10 | * list rather than a screen. | |
| 2 | * The range's half of a stub page. The page itself is the engine's — | |
| 3 | * `fakePage` behind the door hands back the document, the window, the elements, | |
| 4 | * the 2D recorder and the clock, because both games needed exactly that and | |
| 5 | * neither may import the other's copy of it. What is left here is what is | |
| 6 | * genuinely this game's: the ids `src/pages/range.ts` renders, a recorder for | |
| 7 | * every GL call the engine's renderer makes, a counter for the `AudioContext` | |
| 8 | * the engine's sound device opens, and the line that installs all of it as the | |
| 9 | * globals `main.ts` reads for itself. | |
| 11 | 10 | * |
| 12 | 11 | * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it. |
| 13 | 12 | */ |
| 14 | 13 | |
| 15 | import type { Paint } from "./readout.ts"; | |
| 14 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts"; | |
| 16 | 15 | |
| 17 | type Handler = (event: any) => void; | |
| 16 | /** Every id `src/pages/range.ts` puts on the page that the entry looks up. */ | |
| 17 | export const RANGE_IDS = ["scene", "hud", "note", "touch", "look", "fps"]; | |
| 18 | 18 | |
| 19 | export type Fake = { | |
| 20 | /** Deliver an event to everything listening for it here. */ | |
| 21 | fire(type: string, event?: unknown): void; | |
| 22 | readonly listening: ReadonlySet<string>; | |
| 23 | addEventListener(type: string, handler: Handler): void; | |
| 24 | removeEventListener(type: string, handler: Handler): void; | |
| 25 | }; | |
| 26 | ||
| 27 | /** Anything that can be listened to, and told what happened. */ | |
| 28 | export function fake(): Fake { | |
| 29 | const handlers = new Map<string, Handler[]>(); | |
| 30 | return { | |
| 31 | listening: new Set<string>(), | |
| 32 | addEventListener(type, handler) { | |
| 33 | const list = handlers.get(type); | |
| 34 | if (list === undefined) handlers.set(type, [handler]); | |
| 35 | else list.push(handler); | |
| 36 | (this.listening as Set<string>).add(type); | |
| 37 | }, | |
| 38 | removeEventListener(type, handler) { | |
| 39 | const list = handlers.get(type) ?? []; | |
| 40 | const at = list.indexOf(handler); | |
| 41 | if (at >= 0) list.splice(at, 1); | |
| 42 | }, | |
| 43 | fire(type, event = {}) { | |
| 44 | for (const handler of [...(handlers.get(type) ?? [])]) handler({ preventDefault() {}, ...(event as object) }); | |
| 45 | }, | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | export type FakeElement = Fake & { | |
| 50 | id: string; | |
| 51 | hidden: boolean; | |
| 52 | innerHTML: string; | |
| 53 | textContent: string; | |
| 54 | dataset: { action?: string }; | |
| 55 | }; | |
| 56 | ||
| 57 | export function element(id: string, action?: string): FakeElement { | |
| 58 | return { ...fake(), id, hidden: false, innerHTML: "", textContent: "", dataset: action === undefined ? {} : { action } }; | |
| 59 | } | |
| 60 | ||
| 61 | /** Every 2D call `readout.ts` makes, written down in order. */ | |
| 62 | export type Recorder = Paint & { calls: [string, ...unknown[]][]; texts: string[] }; | |
| 63 | ||
| 64 | export function recorder(): Recorder { | |
| 65 | const calls: [string, ...unknown[]][] = []; | |
| 66 | const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]); | |
| 67 | return { | |
| 68 | calls, | |
| 69 | get texts() { | |
| 70 | return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1])); | |
| 71 | }, | |
| 72 | fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textBaseline: "", | |
| 73 | clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"), | |
| 74 | beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), stroke: note("stroke"), | |
| 75 | } as Recorder; | |
| 76 | } | |
| 19 | /** The `[data-action]` buttons the shell renders for this game. */ | |
| 20 | export const RANGE_ACTIONS = ["duck", "jump", "fire"]; | |
| 77 | 21 | |
| 78 | 22 | /** |
| 79 | 23 | * Every GL call the engine's renderer makes, written down in order, and every |
| 80 | 24 | * enum it names — numbered, so a recorded call reads back. It is the same trick |
| 81 | * the `Recorder` above plays for 2D and `renderer.test.ts` plays inside the | |
| 25 | * the engine's `FakePaint` plays for 2D and `renderer.test.ts` plays inside the | |
| 82 | 26 | * engine: not a fake GPU, only a list of what was asked for. A test reads it to |
| 83 | 27 | * see that the room and the plates were drawn; nothing here checks a pixel. |
| 28 | * | |
| 29 | * It stays here rather than behind the door because a page that knew what a | |
| 30 | * renderer was would be this game's page wearing the engine's name: the engine's | |
| 31 | * canvas has an empty `gl` slot, and this is what the range puts in it. | |
| 84 | 32 | */ |
| 85 | 33 | export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] }; |
| 86 | 34 | |
| @@ -205,50 +153,9 @@ export function audioCounter(): FakeAudio { | ||
| 205 | 153 | }; |
| 206 | 154 | } |
| 207 | 155 | |
| 208 | export type FakeCanvas = FakeElement & { | |
| 209 | clientWidth: number; | |
| 210 | clientHeight: number; | |
| 211 | width: number; | |
| 212 | height: number; | |
| 213 | paint: Recorder | null; | |
| 214 | gl: GlRecorder | null; | |
| 215 | locks: number; | |
| 216 | getContext(kind: string): Recorder | GlRecorder | null; | |
| 217 | requestPointerLock(): void; | |
| 218 | }; | |
| 219 | ||
| 220 | /** | |
| 221 | * A canvas whose 2D context is a recorder — or nothing at all when `has2d` is | |
| 222 | * false — and whose WebGL2 context is a GL recorder only when `webgl` is asked | |
| 223 | * for. It is off by default because most of these tests are about the game and | |
| 224 | * not about the draw, and because a browser with no WebGL2 is a real browser | |
| 225 | * this page has to keep working on. | |
| 226 | */ | |
| 227 | export function canvas(id: string, has2d = true, webgl = false): FakeCanvas { | |
| 228 | return { | |
| 229 | ...element(id), | |
| 230 | clientWidth: 800, clientHeight: 450, width: 0, height: 0, | |
| 231 | paint: has2d ? recorder() : null, | |
| 232 | gl: webgl ? glRecorder() : null, | |
| 233 | locks: 0, | |
| 234 | getContext(kind) { | |
| 235 | if (kind === "webgl2") return this.gl; | |
| 236 | return kind === "2d" ? this.paint : null; | |
| 237 | }, | |
| 238 | requestPointerLock() { | |
| 239 | this.locks += 1; | |
| 240 | }, | |
| 241 | }; | |
| 242 | } | |
| 243 | ||
| 244 | export type FakePage = { | |
| 245 | document: Fake & { | |
| 246 | readyState: string; | |
| 247 | pointerLockElement: unknown; | |
| 248 | getElementById(id: string): FakeElement | null; | |
| 249 | querySelectorAll(selector: string): FakeElement[]; | |
| 250 | }; | |
| 251 | window: Fake & { devicePixelRatio: number }; | |
| 156 | export type RangePage = { | |
| 157 | document: FakeDocument; | |
| 158 | window: FakeWindow; | |
| 252 | 159 | scene: FakeCanvas; |
| 253 | 160 | hud: FakeCanvas; |
| 254 | 161 | /** The `AudioContext` this page would give the engine's sound device. */ |
| @@ -263,55 +170,59 @@ export type FakePage = { | ||
| 263 | 170 | install(): () => void; |
| 264 | 171 | }; |
| 265 | 172 | |
| 173 | /** The globals the last lines of `main.ts` and the engine's two edges name. */ | |
| 174 | const GLOBALS = [ | |
| 175 | "document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia", | |
| 176 | "performance", "AudioContext", | |
| 177 | ]; | |
| 178 | ||
| 266 | 179 | /** |
| 267 | 180 | * The whole page the range's entry expects: the stage's canvases, the overlay, |
| 268 | * the touch pads and a clock that only moves when the test says so. | |
| 181 | * the touch pads and a clock that only moves when the test says so. WebGL2 is | |
| 182 | * off by default, because most of these tests are about the game and not about | |
| 183 | * the draw, and because a browser with no WebGL2 is a real browser this page has | |
| 184 | * to keep working on; `has2d: false` is a canvas that gives no context at all. | |
| 269 | 185 | */ |
| 270 | export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePage { | |
| 271 | const scene = canvas("scene", options.has2d ?? true, options.webgl ?? false); | |
| 272 | const hud = canvas("hud", options.has2d ?? true); | |
| 273 | const buttons = [element("", "duck"), element("", "jump"), element("", "fire")]; | |
| 274 | const elements = new Map<string, FakeElement>([ | |
| 275 | ["scene", scene], ["hud", hud], ["note", element("note")], ["touch", element("touch")], | |
| 276 | ["look", element("look")], ["fps", element("fps")], | |
| 277 | ]); | |
| 278 | const frames: ((now: number) => void)[] = []; | |
| 186 | export function page(options: { has2d?: boolean; webgl?: boolean } = {}): RangePage { | |
| 187 | const fake = fakePage({ | |
| 188 | ids: RANGE_IDS, | |
| 189 | canvases: ["scene", "hud"], | |
| 190 | actions: RANGE_ACTIONS, | |
| 191 | paint: options.has2d ?? true, | |
| 192 | }); | |
| 193 | const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas]; | |
| 194 | if (options.webgl === true) scene.gl = glRecorder(); | |
| 279 | 195 | const audio = audioCounter(); |
| 280 | let clock = 1000; | |
| 281 | ||
| 282 | const document = { | |
| 283 | ...fake(), | |
| 284 | readyState: "complete", | |
| 285 | pointerLockElement: null as unknown, | |
| 286 | getElementById: (id: string) => elements.get(id) ?? null, | |
| 287 | querySelectorAll: (_selector: string) => buttons, | |
| 288 | }; | |
| 289 | const window = { ...fake(), devicePixelRatio: 1 }; | |
| 290 | 196 | |
| 291 | 197 | return { |
| 292 | document, window, scene, hud, buttons, frames, audio, | |
| 293 | by: (id) => elements.get(id) as FakeElement, | |
| 294 | step(ms) { | |
| 295 | clock += ms; | |
| 296 | const next = frames.shift(); | |
| 297 | next?.(clock); | |
| 298 | }, | |
| 198 | document: fake.document, | |
| 199 | window: fake.window, | |
| 200 | scene, | |
| 201 | hud, | |
| 202 | audio, | |
| 203 | buttons: fake.buttons, | |
| 204 | frames: fake.frames, | |
| 205 | by: fake.by, | |
| 206 | step: fake.step, | |
| 299 | 207 | install() { |
| 300 | 208 | const globals = globalThis as Record<string, unknown>; |
| 209 | // The undo is a snapshot of the global object's own enumerable properties, | |
| 210 | // which is what this file has always taken. A global that is not one of | |
| 211 | // those — `performance` is not — is deleted rather than put back, and that | |
| 212 | // is deliberate: restoring a native global with `Object.defineProperty` | |
| 213 | // costs every global lookup in every file that runs after this one, and | |
| 214 | // measurably so. `bun test` takes 5 seconds with this line and 17 with the | |
| 215 | // honest-looking one. | |
| 301 | 216 | const before = { ...globals }; |
| 302 | const keys = [ | |
| 303 | "document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia", | |
| 304 | "performance", "AudioContext", | |
| 305 | ]; | |
| 306 | globals.document = document; | |
| 307 | globals.window = window; | |
| 217 | globals.document = fake.document; | |
| 218 | globals.window = fake.window; | |
| 308 | 219 | globals.AudioContext = audio.Ctor; |
| 309 | globals.requestAnimationFrame = (callback: (now: number) => void): number => frames.push(callback); | |
| 310 | globals.cancelAnimationFrame = (): void => void frames.splice(0, frames.length); | |
| 311 | globals.matchMedia = (): { matches: boolean } => ({ matches: false }); | |
| 312 | globals.performance = { now: () => clock }; | |
| 220 | globals.requestAnimationFrame = fake.window.requestAnimationFrame; | |
| 221 | globals.cancelAnimationFrame = fake.window.cancelAnimationFrame; | |
| 222 | globals.matchMedia = fake.window.matchMedia; | |
| 223 | globals.performance = fake.window.performance; | |
| 313 | 224 | return () => { |
| 314 | for (const key of keys) | |
| 225 | for (const key of GLOBALS) | |
| 315 | 226 | if (key in before) globals[key] = before[key]; |
| 316 | 227 | else delete globals[key]; |
| 317 | 228 | }; |
static/app.63bb6ab307.js → static/app.f34f5d20d9.js renamed +5 −5
Diff not shown — 139,854 bytes of diff (limit 100,000). +5 −5
View filestatic/range.72363b5535.js → static/range.74f7561b23.js renamed +3 −3
Diff not shown — a line 15,449 characters wide (limit 2,000). +3 −3
View file
new above
offline