Split The code is public; the app answers only to the crew.
The round's damage report is 64 lines nobody tests: drive drawReport with the recording context hud.test.ts already has #239
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/201-the-round-s-damage-report-is-64-lines-no → main 1 commit fork
3 files
changed +134
−44
src/client/hud.test.ts +1 −44
| @@ -11,54 +11,11 @@ import { drawHud } from "./hud.ts"; | ||
| 11 | 11 | import { TICK } from "./loop.ts"; |
| 12 | 12 | import { SHORT } from "./match.ts"; |
| 13 | 13 | import type { Motion } from "./motion.ts"; |
| 14 | import { type Call, recorder } from "./recorder.test-helper.ts"; | |
| 14 | 15 | import type { Camera } from "./renderer.ts"; |
| 15 | 16 | import { WEAPONS } from "./weapons.ts"; |
| 16 | 17 | import { applyInput, createWorld, eyePos, NO_INPUT, type Player, type World } from "./world.ts"; |
| 17 | 18 | |
| 18 | type Call = { | |
| 19 | name: string; args: number[]; text: string; alpha: number; | |
| 20 | stroke: string; fill: string; lineWidth: number; | |
| 21 | }; | |
| 22 | ||
| 23 | function recorder(): { ctx: CanvasRenderingContext2D; calls: Call[] } { | |
| 24 | const calls: Call[] = []; | |
| 25 | // The alpha in force when the call was made, snapshotted: a fade is a | |
| 26 | // property and not an argument, so it is the only way to see one. The | |
| 27 | // colours and the line width are read the same way and for the same | |
| 28 | // reason — a crosshair's colour is a property of the context, not of the | |
| 29 | // stroke that used it. | |
| 30 | const log = (name: string) => (...args: unknown[]): void => { | |
| 31 | calls.push({ | |
| 32 | name, | |
| 33 | args: args.filter((a) => typeof a === "number") as number[], | |
| 34 | text: args.filter((a) => typeof a === "string").join(" "), | |
| 35 | alpha: ctx.globalAlpha, | |
| 36 | stroke: typeof ctx.strokeStyle === "string" ? ctx.strokeStyle : "", | |
| 37 | fill: typeof ctx.fillStyle === "string" ? ctx.fillStyle : "", | |
| 38 | lineWidth: ctx.lineWidth, | |
| 39 | }); | |
| 40 | }; | |
| 41 | const ctx = { | |
| 42 | clearRect: log("clearRect"), fillRect: log("fillRect"), rect: log("rect"), | |
| 43 | beginPath: log("beginPath"), moveTo: log("moveTo"), lineTo: log("lineTo"), | |
| 44 | arc: log("arc"), stroke: log("stroke"), fill: log("fill"), | |
| 45 | fillText: log("fillText"), save: log("save"), restore: log("restore"), | |
| 46 | clip: log("clip"), translate: log("translate"), rotate: log("rotate"), | |
| 47 | drawImage: log("drawImage"), closePath: log("closePath"), | |
| 48 | measureText: () => ({ width: 10 }), | |
| 49 | createRadialGradient: (...args: unknown[]) => { | |
| 50 | calls.push({ | |
| 51 | name: "createRadialGradient", args: args as number[], text: "", alpha: 1, | |
| 52 | stroke: "", fill: "", lineWidth: 1, | |
| 53 | }); | |
| 54 | return { addColorStop: (): void => {} }; | |
| 55 | }, | |
| 56 | globalAlpha: 1, lineWidth: 1, fillStyle: "", strokeStyle: "", | |
| 57 | font: "", textAlign: "", textBaseline: "", | |
| 58 | }; | |
| 59 | return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; | |
| 60 | } | |
| 61 | ||
| 62 | 19 | const W = 1280, H = 720; |
| 63 | 20 | |
| 64 | 21 | /** The camera `main.ts` builds for the player being drawn, punch and all aside. */ |
src/client/recorder.test-helper.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * A recording 2D context: no browser, no canvas, just the calls it would | |
| 3 | * have made. Shared by every HUD test so there is one dialect of "fake | |
| 4 | * context", not one per file. | |
| 5 | */ | |
| 6 | ||
| 7 | export type Call = { | |
| 8 | name: string; args: number[]; text: string; alpha: number; | |
| 9 | stroke: string; fill: string; lineWidth: number; | |
| 10 | }; | |
| 11 | ||
| 12 | export function recorder(): { ctx: CanvasRenderingContext2D; calls: Call[] } { | |
| 13 | const calls: Call[] = []; | |
| 14 | // The alpha in force when the call was made, snapshotted: a fade is a | |
| 15 | // property and not an argument, so it is the only way to see one. The | |
| 16 | // colours and the line width are read the same way and for the same | |
| 17 | // reason — a crosshair's colour is a property of the context, not of the | |
| 18 | // stroke that used it. | |
| 19 | const log = (name: string) => (...args: unknown[]): void => { | |
| 20 | calls.push({ | |
| 21 | name, | |
| 22 | args: args.filter((a) => typeof a === "number") as number[], | |
| 23 | text: args.filter((a) => typeof a === "string").join(" "), | |
| 24 | alpha: ctx.globalAlpha, | |
| 25 | stroke: typeof ctx.strokeStyle === "string" ? ctx.strokeStyle : "", | |
| 26 | fill: typeof ctx.fillStyle === "string" ? ctx.fillStyle : "", | |
| 27 | lineWidth: ctx.lineWidth, | |
| 28 | }); | |
| 29 | }; | |
| 30 | const ctx = { | |
| 31 | clearRect: log("clearRect"), fillRect: log("fillRect"), rect: log("rect"), | |
| 32 | beginPath: log("beginPath"), moveTo: log("moveTo"), lineTo: log("lineTo"), | |
| 33 | arc: log("arc"), stroke: log("stroke"), fill: log("fill"), | |
| 34 | fillText: log("fillText"), save: log("save"), restore: log("restore"), | |
| 35 | clip: log("clip"), translate: log("translate"), rotate: log("rotate"), | |
| 36 | drawImage: log("drawImage"), closePath: log("closePath"), | |
| 37 | measureText: () => ({ width: 10 }), | |
| 38 | createRadialGradient: (...args: unknown[]) => { | |
| 39 | calls.push({ | |
| 40 | name: "createRadialGradient", args: args as number[], text: "", alpha: 1, | |
| 41 | stroke: "", fill: "", lineWidth: 1, | |
| 42 | }); | |
| 43 | return { addColorStop: (): void => {} }; | |
| 44 | }, | |
| 45 | globalAlpha: 1, lineWidth: 1, fillStyle: "", strokeStyle: "", | |
| 46 | font: "", textAlign: "", textBaseline: "", | |
| 47 | }; | |
| 48 | return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; | |
| 49 | } |
src/client/reportPanel.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * `drawReport` against a recording context: the same fake `hud.test.ts` | |
| 3 | * drives the rest of the HUD with, so the panel's arithmetic is pinned | |
| 4 | * without a browser. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { SHORT } from "./match.ts"; | |
| 9 | import { drawReport } from "./reportPanel.ts"; | |
| 10 | import { type Call, recorder } from "./recorder.test-helper.ts"; | |
| 11 | import { createWorld, type Player, type World } from "./world.ts"; | |
| 12 | ||
| 13 | const W = 1280, H = 720; | |
| 14 | ||
| 15 | const draw = (into: { ctx: CanvasRenderingContext2D }, world: World, view: Player = world.me): void => | |
| 16 | drawReport(into.ctx, world, view, W, H, 1); | |
| 17 | ||
| 18 | const texts = (calls: Call[]): string[] => calls.filter((c) => c.name === "fillText").map((c) => c.text); | |
| 19 | ||
| 20 | for (const phase of ["freeze", "live", "planted"] as const) { | |
| 21 | test(`nothing is drawn while the round is ${phase}`, () => { | |
| 22 | const world = createWorld(SHORT, "me", "ct", 5, 111); | |
| 23 | world.match.phase = phase; | |
| 24 | const frame = recorder(); | |
| 25 | draw(frame, world); | |
| 26 | expect(frame.calls.length).toBe(0); | |
| 27 | }); | |
| 28 | } | |
| 29 | ||
| 30 | test("the panel appears once the round is over", () => { | |
| 31 | const world = createWorld(SHORT, "me", "ct", 5, 111); | |
| 32 | world.match.phase = "over"; | |
| 33 | const frame = recorder(); | |
| 34 | draw(frame, world); | |
| 35 | expect(frame.calls.length).toBeGreaterThan(0); | |
| 36 | expect(frame.calls.some((c) => c.name === "fillRect")).toBe(true); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a round with no damage either way draws \"nothing\" twice", () => { | |
| 40 | const world = createWorld(SHORT, "me", "ct", 5, 111); | |
| 41 | world.match.phase = "over"; | |
| 42 | const frame = recorder(); | |
| 43 | draw(frame, world); | |
| 44 | expect(texts(frame.calls).filter((t) => t === "nothing").length).toBe(2); | |
| 45 | expect(texts(frame.calls)).toContain("Dealt"); | |
| 46 | expect(texts(frame.calls)).toContain("Took"); | |
| 47 | }); | |
| 48 | ||
| 49 | test("six opponents hit draws five lines, not six", () => { | |
| 50 | const world = createWorld(SHORT, "me", "ct", 6, 111); | |
| 51 | world.match.phase = "over"; | |
| 52 | const enemies = world.players.filter((p) => p.team === "t"); | |
| 53 | expect(enemies.length).toBe(6); | |
| 54 | for (const [i, enemy] of enemies.entries()) { | |
| 55 | world.damageLog[world.me.id][enemy.id] = { damage: 10 + i, hits: 1, killed: false }; | |
| 56 | } | |
| 57 | const frame = recorder(); | |
| 58 | draw(frame, world); | |
| 59 | const dealtLines = texts(frame.calls).filter((t) => t.includes(" dmg")); | |
| 60 | expect(dealtLines.length).toBe(5); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the panel's width never exceeds the canvas on a narrow screen", () => { | |
| 64 | const world = createWorld(SHORT, "me", "ct", 5, 111); | |
| 65 | world.match.phase = "over"; | |
| 66 | const narrowW = 360; | |
| 67 | const frame = recorder(); | |
| 68 | drawReport(frame.ctx, world, world.me, narrowW, H, 1); | |
| 69 | const panel = frame.calls.find((c) => c.name === "fillRect") as Call; | |
| 70 | const [x, , w] = panel.args; | |
| 71 | expect(w).toBeLessThanOrEqual(narrowW - 24); | |
| 72 | expect(x).toBeGreaterThanOrEqual(0); | |
| 73 | expect(x + w).toBeLessThanOrEqual(narrowW); | |
| 74 | }); | |
| 75 | ||
| 76 | test("the total line reads what report() says", () => { | |
| 77 | const world = createWorld(SHORT, "me", "ct", 5, 111); | |
| 78 | world.match.phase = "over"; | |
| 79 | const enemy = world.players.find((p) => p.team === "t") as Player; | |
| 80 | world.damageLog[world.me.id][enemy.id] = { damage: 42, hits: 2, killed: true }; | |
| 81 | const frame = recorder(); | |
| 82 | draw(frame, world); | |
| 83 | expect(texts(frame.calls)).toContain("Dealt 42 — took 0"); | |
| 84 | }); |