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

88 lines
3.7 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 { validate, compile, SpecError } from "../src/core/loader.js";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
test("the shipped example validates clean", () => {
const { errors, warnings } = validate(example);
assert.deepEqual(errors, []);
// exit symmetry: jetty<->tavern, so no symmetry warnings either
assert.deepEqual(warnings, []);
});
test("compile indexes everything", () => {
const c = compile(example);
assert.equal(c.rooms.tavern.id, "tavern");
assert.equal(c.objects.mug.name, "a suspicious mug");
assert.equal(c.characters.player.room, "jetty");
assert.equal(c.verbs.look.label, "Look");
assert.ok(c.interactions.every((it) => typeof it._order === "number"));
});
test("every problem reported in one pass", () => {
const bad = structuredClone(example);
bad.meta = {}; // missing name
bad.startRoom = "nowhere"; // dangling start
bad.rooms[0].walkable = []; // no polygons
bad.characters[0].id = "walker"; // player missing
bad.interactions[0].verb = "telepathy"; // unknown verb
bad.interactions[1].noun = "ghost-mug"; // unknown noun
const { errors } = validate(bad);
assert.ok(errors.length >= 6, `expected >=6 errors, got ${errors.length}: ${errors.join("|")}`);
const joined = errors.join("\n");
for (const needle of ["meta.name", "startRoom", "walkable", "'player'", "telepathy", "ghost-mug"])
assert.ok(joined.includes(needle), `missing error about ${needle}: ${joined}`);
});
test("compile throws SpecError listing everything", () => {
assert.throws(() => compile({ meta: {} }), SpecError);
try { compile({ meta: {} }); } catch (e) {
assert.ok(e.errors.length >= 3);
}
});
test("dangling structural refs are errors, unknown flags are not checked", () => {
const bad = structuredClone(example);
bad.interactions.push({ verb: "use", noun: "mug", effects: [{ give: "nonexistent-item" }] });
bad.interactions.push({ verb: "look", noun: "post", effects: [{ playMusic: "nope-track" }] });
bad.interactions.push({ verb: "look", noun: "post", conditions: { flag: "createdAtRuntime" }, response: "ok" });
const { errors } = validate(bad);
const joined = errors.join("\n");
assert.ok(joined.includes("nonexistent-item"));
assert.ok(joined.includes("nope-track"));
assert.ok(!joined.includes("createdAtRuntime"));
});
test("dialogue nodes must connect", () => {
const bad = structuredClone(example);
bad.dialogues.barkeep_intro.nodes.greet.choices[0].to = "phantom";
const { errors } = validate(bad);
assert.ok(errors.some((e) => e.includes("phantom")));
});
test("scene steps validated", () => {
const bad = structuredClone(example);
bad.scenes.intro = [{ explode: true }, { goto: "nowhere" }, { slide: "no-such-asset" }];
const { errors } = validate(bad);
const joined = errors.join("\n");
assert.ok(joined.includes("explode"));
assert.ok(joined.includes("nowhere"));
assert.ok(joined.includes("no-such-asset"));
});
test("duplicate ids caught", () => {
const bad = structuredClone(example);
bad.rooms.push({ ...bad.rooms[0] });
const { errors } = validate(bad);
assert.ok(errors.some((e) => e.includes("duplicate id 'jetty'")));
});
test("missing return exit is a warning, not an error", () => {
const bad = structuredClone(example);
bad.rooms[1].exits = []; // tavern no longer leads back
const { errors, warnings } = validate(bad);
assert.deepEqual(errors, []);
assert.ok(warnings.some((w) => w.includes("no return exit")));
});