import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
async function load(name) {
const result = await build({ entryPoints: [fileURLToPath(new URL(`../src/player/${name}.ts`, import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString("base64")}`);
}
const { evaluateNani, naniText } = await load("naninovel");
const c = (value) => ["const", value];
const n = (name) => ["name", name];
const bin = (op, a, b) => ["bin", op, a, b];
test("Naninovel uses its own truth, concatenation and short-circuit semantics", () => {
const evaluate = (ast) => evaluateNani(ast, (key) => ({ points: 3, flag: "false" })[key]);
assert.equal(evaluate(bin("+", c("Score: "), n("points"))), "Score: 3");
assert.equal(evaluate(["if", n("flag"), c("yes"), c("no")]), "no");
assert.equal(evaluate(bin("&&", c(false), ["call", "Missing", []])), false);
assert.equal(evaluate(bin("||", c(true), ["call", "Missing", []])), true);
assert.throws(() => evaluate(["call", "Missing", []]), /функция/);
assert.throws(() => evaluate(bin("/", c(1), c(0))), /ноль/);
});
test("Naninovel translates complete templates before substituting expressions", () => {
const expressions = { points: n("points") };
assert.equal(naniText("Очки: {points}
Продолжить", expressions, () => 7), "{b}Очки: 7{/b}\nПродолжить");
assert.throws(() => naniText("{unexpected}", expressions, () => 1), /неизвестное выражение/);
assert.equal(naniText("{points}", expressions, () => "{b}"), "{{b}");
});
test("Naninovel AST runs through the shared store and survives JSON saves", async () => {
const { Python } = await load("python");
const store = { score: 2 };
const py = new Python({ get: (key) => Object.hasOwn(store, key) ? store[key] : undefined,
has: (key) => Object.hasOwn(store, key), set: (key, value) => { store[key] = value; }, del: (key) => { delete store[key]; } });
py.exec([{ op: "set", t: { k: "name", id: "score" }, v: { k: "nani", ast: bin("+", n("score"), c(4)) } }]);
assert.equal(JSON.parse(JSON.stringify(store)).score, 6);
});
test("the player presents translated Naninovel choices and jumps after selection", async () => {
const { Engine } = await load("engine");
class Element {
children = [];
style = {};
classList = { add() {}, remove() {} };
append(child) { this.children.push(child); }
set textContent(value) { this.children = value ? [value] : []; }
}
const previousDocument = globalThis.document;
globalThis.document = { createElement: () => new Element() };
try {
const engine = Object.create(Engine.prototype);
Object.assign(engine, { store: {}, game: { engine: "naninovel", naniExpressions: {} },
tr: { line: (_tid, text) => text === "Again" ? "Ещё раз" : text },
menuEl: new Element(), baseSize: 28, interactionStart() {}, setSkip() {},
advance() {}, jump(target) { this.chosenTarget = target; },
wait(setup) { return new Promise((resolve) => { setup(resolve); this.menuEl.children[0].onclick({ stopPropagation() {} }); }); } });
await engine.exec({ op: "nani_add_choice", what: "Again", tid: "choice-id", target: "nani:Opening", handler: "" });
assert.equal(JSON.parse(JSON.stringify(engine.store)).__nani_choices[0].tid, "choice-id");
await engine.exec({ op: "nani_menu" });
assert.equal(engine.menuEl.children[0].children[0], "Ещё раз");
assert.equal(engine.chosenTarget, "nani:Opening");
assert.deepEqual(engine.store.__nani_choices, []);
} finally {
globalThis.document = previousDocument;
}
});