pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Merge pull request #33 from client-test-stands-still
Stop the frame test measuring a view nobody chose
1 file
changed +45
−10
src/client.test.ts +45 −10
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { expect, test } from "bun:test"; |
| 12 | ||
| 12 | 13 | import { mkdtempSync } from "node:fs"; |
| 13 | 14 | import { tmpdir } from "node:os"; |
| 14 | 15 | import { join } from "node:path"; |
| @@ -21,6 +22,15 @@ const base = `http://${server.hostname}:${server.port}`; | ||
| 21 | 22 | // ------------------------------------------------------------ the stub DOM |
| 22 | 23 | |
| 23 | 24 | const drawn: string[] = []; |
| 25 | /** | |
| 26 | * Every canvas the client asked the document for. The widest is its offscreen | |
| 27 | * world buffer, whose width is however many columns the frame budget settled | |
| 28 | * on; the other is the minimap's tile layer. Keeping them is what lets a test | |
| 29 | * bound the frame's cost as a fraction of the columns actually used, instead | |
| 30 | * of against a number somebody picked once. | |
| 31 | */ | |
| 32 | const canvases: Record<string, unknown>[] = []; | |
| 33 | const worldColumns = (): number => Math.max(0, ...canvases.map((c) => Number(c.width) || 0)); | |
| 24 | 34 | // Where the canvas was moved to: on the top-down view your own body is an |
| 25 | 35 | // arrow drawn about the origin, so the last translate of a frame is where |
| 26 | 36 | // this client thinks you are. |
| @@ -131,7 +141,11 @@ Object.assign(globalThis, { | ||
| 131 | 141 | getElementById: (id: string) => nodes.get(id) ?? null, |
| 132 | 142 | querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue), |
| 133 | 143 | querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons), |
| 134 | createElement: (tag: string) => element(tag), | |
| 144 | createElement: (tag: string) => { | |
| 145 | const node = element(tag); | |
| 146 | if (tag === "canvas") canvases.push(node); | |
| 147 | return node; | |
| 148 | }, | |
| 135 | 149 | addEventListener: noop, |
| 136 | 150 | pointerLockElement: null, |
| 137 | 151 | exitPointerLock: noop, |
| @@ -248,13 +262,26 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 248 | 262 | expect(row).not.toMatch(/\d/); |
| 249 | 263 | |
| 250 | 264 | // A frame of the first-person view draws the world into its own buffer and |
| 251 | // blits it, with the minimap's tiles blitted too. It is a picture, but it | |
| 252 | // is not one fill a screen column any more, nor one a map tile: the map has | |
| 253 | // 570 tiles and the canvas 900 columns, and the frame used to pay for both. | |
| 254 | // The minimap's tiles are the blit; the walls are a run a fill, and how | |
| 255 | // many runs a view has depends on where the seat happens to be standing — | |
| 256 | // a long corridor is many depths — so the bound is the columns, which no | |
| 257 | // view can reach, rather than the tile count, which a corridor can. | |
| 265 | // blits it, with the minimap's tiles blitted too. It is a picture, but it is | |
| 266 | // NOT one fill a screen column, nor one a map tile: the map has 570 tiles and | |
| 267 | // the buffer up to 960 columns, and the frame used to pay for both. The | |
| 268 | // minimap's tiles are the blit; the walls are a run a fill. | |
| 269 | // | |
| 270 | // That sentence is the whole assertion, and it is now written as one. It | |
| 271 | // used to demand more than 40 fills and fewer than 900, which failed about | |
| 272 | // one full-suite run in twenty-five: the loop above advances the SHARED | |
| 273 | // match until somebody dies — five to two hundred seconds of play — so the | |
| 274 | // view being counted was wherever that left the seat. Alone it draws 154 to | |
| 275 | // 236 fills; the run that failed drew 31, and a preview drew 525. Every one | |
| 276 | // of those is a correct frame of a different view. A bound on the count of | |
| 277 | // an unfixed view can only ever be luck. | |
| 278 | // | |
| 279 | // So the bounds are the two things true of EVERY view: it drew something, | |
| 280 | // and it cost fewer fills than the frame had columns. The column count is | |
| 281 | // read off the client's own offscreen buffer rather than hardcoded, because | |
| 282 | // the frame budget moves the world between 960 and 448 columns and a | |
| 283 | // constant cannot tell "a run a wall" at one rung from "a fill a column" at | |
| 284 | // another. | |
| 258 | 285 | const tally = () => ({ |
| 259 | 286 | fills: drawn.filter((op) => op.startsWith("fillRect")).length, |
| 260 | 287 | blits: drawn.filter((op) => op.startsWith("drawImage")).length, |
| @@ -263,9 +290,17 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 263 | 290 | drawn.length = 0; |
| 264 | 291 | runFrame(); |
| 265 | 292 | const fp = tally(); |
| 293 | drawn.length = 0; | |
| 294 | runFrame(); | |
| 295 | const again = tally(); | |
| 296 | ||
| 266 | 297 | expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles |
| 267 | expect(fp.fills).toBeGreaterThan(40); | |
| 268 | expect(fp.fills).toBeLessThan(900); | |
| 298 | expect(fp.fills).toBeGreaterThan(0); // it drew a world, not an empty buffer | |
| 299 | expect(fp.fills).toBeLessThan(worldColumns()); // run-batched, not per column | |
| 300 | // The same state drawn twice costs the same. A frame whose cost wanders | |
| 301 | // while nothing moves is a renderer bug, and it is the shape of thing the | |
| 302 | // old bound was mistaking for flakiness. | |
| 303 | expect(again.fills).toBe(fp.fills); | |
| 269 | 304 | |
| 270 | 305 | // Tab swaps that frame for the whole map. Its tiles are drawn once into a |
| 271 | 306 | // canvas of their own and blitted from then on: the first such frame pays |