Files
moonlit-engine/tests/render.test.js
T

123 lines
5.5 KiB
JavaScript
Raw Normal View History

2026-10-18 00:29:36 +00:00
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { AssetManifest } from "../src/render/assets.js";
import { HeadlessAudio } from "../src/render/audio.js";
import { RoomRenderer } from "../src/render/canvas.js";
import { HudModel } from "../src/render/hud.js";
import { Engine } from "../src/core/engine.js";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
// ---- assets ---------------------------------------------------------------
test("asset manifest resolves and degrades gracefully", () => {
const am = new AssetManifest(example.assets);
assert.equal(am.get("bg_jetty").file, "assets/img/jetty.png");
const missing = am.get("nope");
assert.equal(missing.missing, true);
assert.equal(am.get("nope").missing, true); // second call: no crash
});
test("animation frame cycling", () => {
const am = new AssetManifest([{ id: "p", file: "p.png", frames: { walk: [0, 1, 1, 0] }, anim: { walk: 100 } }]);
const a = am.get("p");
assert.equal(am.frameFor(a, "walk", 0), 0);
assert.equal(am.frameFor(a, "walk", 100), 1);
assert.equal(am.frameFor(a, "walk", 300), 0);
assert.equal(am.frameFor(a, "unknownAnim", 0), 0); // falls back
});
// ---- audio ----------------------------------------------------------------
test("headless audio records calls and preserves loop points", () => {
const au = new HeadlessAudio();
au.playMusic({ id: "calypso", file: "c.ogg", loopStartMs: 8460, loopEndMs: 76920 });
au.playSfx({ id: "click", file: "k.ogg" });
au.setVolume(2); // clamped
assert.deepEqual(au.calls[0], { op: "playMusic", id: "calypso", file: "c.ogg",
loopStartMs: 8460, loopEndMs: 76920 });
assert.equal(au.calls[1].op, "playSfx");
assert.equal(au.volume, 1);
au.stopMusic();
assert.equal(au.current, null);
});
// ---- canvas renderer (fake recording context) ------------------------------
function fakeCtx() {
const ops = [];
const noop = (name) => (...args) => ops.push({ op: name, args });
return { ops, drawImage: noop("drawImage"), fillRect: noop("fillRect"),
fillText: noop("fillText"), beginPath: noop("beginPath"),
lineTo: noop("lineTo"), moveTo: noop("moveTo"), closePath: noop("closePath"),
fill: noop("fill"), save: noop("save"), restore: noop("restore") };
}
test("renderer draws bg, placeholders, and exit labels", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const am = new AssetManifest(example.assets);
const rr = new RoomRenderer(e.compiled, am, new Map()); // no images loaded
const ctx = fakeCtx();
rr.draw(e, ctx, { timeMs: 0 });
assert.ok(ctx.ops.some((o) => o.op === "fillRect")); // bg placeholder
assert.ok(ctx.ops.some((o) => o.op === "fillText" && o.args[0] === "Tavern"));
// player + objects drawn as labelled placeholders
const labels = ctx.ops.filter((o) => o.op === "fillText").map((o) => o.args[0]);
assert.ok(labels.includes("player"));
assert.ok(labels.includes("post"));
assert.ok(labels.includes("mug"));
});
test("renderer uses loaded images when present", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const am = new AssetManifest(example.assets);
const images = new Map([["bg_jetty", { width: 640, height: 480 }]]);
const rr = new RoomRenderer(e.compiled, am, images);
const ctx = fakeCtx();
rr.draw(e, ctx, { timeMs: 0 });
assert.ok(ctx.ops.some((o) => o.op === "drawImage"));
});
test("object placeholder boxes match hotspot rects (see-what-you-click)", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map());
const ctx = fakeCtx();
rr.draw(e, ctx, { timeMs: 0 });
const rects = ctx.ops.filter((o) => o.op === "fillRect").map((o) => o.args);
// post hotspot {480,300,40,90} and mug {200,360,40,30} drawn exactly
assert.ok(rects.some((r) => r[0] === 480 && r[1] === 300 && r[2] === 40 && r[3] === 90),
`post box not at hotspot: ${JSON.stringify(rects)}`);
assert.ok(rects.some((r) => r[0] === 200 && r[1] === 360 && r[2] === 40 && r[3] === 30),
`mug box not at hotspot: ${JSON.stringify(rects)}`);
});
test("walkable debug draws polygons", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map());
const ctx = fakeCtx();
rr.draw(e, ctx, { timeMs: 0, debugWalkable: true });
assert.ok(ctx.ops.some((o) => o.op === "closePath"));
});
// ---- HUD model --------------------------------------------------------------
test("hud exposes verbs, inventory, dialogue choices, save gate", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const hud = new HudModel(e);
assert.equal(hud.verbs().length, 5);
e.selectVerb("look");
assert.equal(hud.verbs().find((v) => v.id === "look").selected, true);
assert.deepEqual(hud.inventory(), []);
e.selectVerb("use");
e.click({ kind: "object", id: "mug", tags: ["container"] });
assert.deepEqual(hud.inventory(), [{ id: "mug", name: "a suspicious mug" }]);
assert.equal(hud.canSave(), true);
e.goTo("tavern");
e.selectVerb("talk");
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
assert.equal(hud.inDialogue(), true);
assert.equal(hud.dialogueChoices().length, 2);
assert.equal(hud.canSave(), false, "no saving mid-dialogue");
});