Files
pgames-manager/frontend/tests/renpy-runtime.test.mjs
T
mrleo1nid f9eb5c1212
CI / frontend (push) Failing after 15s
CI / backend (push) Failing after 37s
feat: improve RenPy conversion and support MIST runtime
2026-10-25 23:16:33 +03:00

169 lines
8.6 KiB
JavaScript

import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
import { parseHTML } from "linkedom";
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
Object.assign(globalThis, { window, document });
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4);
const compiled = await build({
stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules"; export * from "./pyparse";`,
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
});
const m = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const { parseExpression, parseStatements, Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const name = (id) => ({ k: "name", id });
const attr = (o, a) => ({ k: "attr", o, a });
const call = (f, ...a) => ({ k: "call", f, a: a.map((v) => (v && v.k ? v : { k: "const", v })), kw: {} });
function python(extra = {}) {
const store = {};
const builtins = { ...pythonModules(), ...colorMatrices, ...extra };
const py = new Python({
get: (n) => (n in store ? store[n] : builtins[n]),
has: (n) => n in store || n in builtins,
set: (n, v) => { store[n] = v; },
del: (n) => { delete store[n]; },
});
return { py, store };
}
test("interpreted instances can own arguments without exposing host function properties", () => {
const { py, store } = python();
Object.assign(store, m.userBuiltins(py));
store.entry = new m.PyInstance(new m.PyClass("Entry", [m.OBJECT]));
py.exec(parseStatements('entry.arguments = {"x": 1}'));
assert.equal(py.eval(parseExpression('entry.arguments["x"]')), 1);
assert.equal(py.eval(parseExpression('getattr(entry, "constructor", None)')), null);
py.exec(parseStatements('setattr(entry, "arguments", 2)'));
assert.equal(py.eval(parseExpression('entry.arguments')), 2);
store.host = () => 1;
assert.equal(py.eval(parseExpression('host.constructor')), null);
assert.equal(py.eval(parseExpression('host.arguments')), null);
assert.throws(() => py.exec(parseStatements('entry.__proto__ = 1')), /Служебный/);
assert.throws(() => py.exec(parseStatements('entry.constructor = 1')), /Служебный/);
});
test("colour matrices compose like Ren'Py's: the right one applies first", () => {
const brighter = colorMatrices.BrightnessMatrix(0.2);
const half = colorMatrices.TintMatrix("#808080");
const both = half.times(brighter); // brighten, then tint
assert.ok(Math.abs(both.rows[0][4] - 0.2 * (128 / 255)) < 1e-6);
assert.deepEqual(ColorMatrix.identity().times(brighter).rows, brighter.rows);
const gray = colorMatrices.SaturationMatrix(0);
assert.ok(Math.abs(gray.rows[0][0] - 0.2126) < 1e-9 && Math.abs(gray.rows[1][2] - 0.0722) < 1e-9);
assert.equal(legacyMatrices.tint(1, 0.5, 0).rows[1][1], 0.5);
assert.match(matrixFilter(both), /^url\(#pgm-\d+\)$/);
assert.equal(matrixFilter(both), matrixFilter(both), "equal matrices share one SVG filter");
assert.equal(document.querySelectorAll("feColorMatrix").length, 1);
});
test("the sandbox multiplies matrices with `*` and refuses other arithmetic on them", () => {
const { py, store } = python();
py.exec([{ op: "set", t: name("m"), v: { k: "bin", op: "*", l: call(name("TintMatrix"), "#ff0000"), r: call(name("BrightnessMatrix"), 0.1) } }]);
assert.ok(store.m instanceof ColorMatrix);
assert.throws(() => py.eval({ k: "bin", op: "+", l: name("m"), r: name("m") }), /перемножать/);
});
test("re, copy, defaultdict, datetime and string helpers behave like their Python versions", () => {
const { py, store } = python();
const re = (fn, ...args) => py.eval(call(attr(name("re"), fn), ...args));
assert.equal(re("sub", "(\\d+)", "<\\1>", "a12b3"), "a<12>b<3>");
assert.equal(re("match", "^ab", "abc").group(), "ab");
assert.equal(re("search", "(?P<n>\\d+)", "x42").group("n"), "42");
assert.deepEqual(re("findall", "\\d", "a1b2"), ["1", "2"]);
const original = new PyDict([["k", [1, 2]]]);
store.original = original;
const copied = py.eval(call(attr(name("copy"), "deepcopy"), name("original")));
copied.get("k").push(3);
assert.deepEqual(original.get("k"), [1, 2]);
store.counts = py.eval(call(attr(name("collections"), "defaultdict"), { k: "name", id: "list" }));
py.scope.set("list", () => []);
store.counts = py.eval(call(attr(name("collections"), "defaultdict"), name("list")));
py.eval(call(attr({ k: "sub", o: name("counts"), i: { k: "const", v: "a" } }, "append"), 1));
assert.deepEqual(store.counts.get("a"), [1]);
assert.equal(py.eval(call(attr({ k: "const", v: "7" }, "zfill"), 3)), "007");
assert.deepEqual(py.eval(call(attr({ k: "const", v: "a=b" }, "partition"), "=")), ["a", "=", "b"]);
const hour = py.eval(attr(call(attr(name("datetime"), "now")), "hour"));
assert.ok(hour >= 0 && hour < 24);
});
test("ATL image steps, finite repeat, time and function work in a timeline", async () => {
const shown = [];
const props = {};
const { py } = python();
py.scope.set("drift", (trans) => { trans.xpos = 0.75; return null; });
const host = {
block: () => null, props: () => null, apply: () => {},
eval: (expr) => py.eval(expr),
image: (img, transition) => shown.push([img.file, transition?.d]),
};
const runner = new AtlRunner(host, props);
const started = performance.now();
const completed = await runner.start({ repeat: 2, steps: [
{ op: "image", img: { file: "a.webp" }, with: { t: "dissolve", d: 0.2 } },
{ op: "pause", t: 0.02 },
{ op: "image", img: { file: "b.webp" }, with: null },
{ op: "time", t: 0.05 },
] });
assert.equal(completed, true);
assert.deepEqual(shown, [["a.webp", 0.2], ["b.webp", undefined], ["a.webp", 0.2], ["b.webp", undefined]]);
assert.ok(performance.now() - started >= 95, "`time 0.05` waits for the block clock, twice");
await new AtlRunner(host, props).start({ repeat: null, steps: [{ op: "function", f: { k: "name", id: "drift" } }] });
assert.equal(props.xpos, 0.75, "function f mutates the transform properties");
});
test("an animation stops by itself when its element leaves the page", async () => {
const box = document.createElement("div");
document.body.append(box);
let frames = 0;
const runner = new AtlRunner({ block: () => null, props: () => null, apply: () => { frames++; } }, {});
runner.watch = box;
const running = runner.start({ repeat: true, steps: [{ op: "move", warp: "linear", t: 0.02, props: { alpha: 0 } }] });
await wait(40);
box.remove();
assert.equal(await running, false, "stopped, not finished");
const seen = frames;
await wait(40);
assert.equal(frames, seen);
});
test("on hover handlers run when the button reports the event", async () => {
const props = { alpha: 1 };
const runner = new AtlRunner({ block: () => null, props: () => null, apply: () => {} }, props);
const block = { repeat: null, steps: [
{ op: "on", events: ["hover"], block: { repeat: null, steps: [{ op: "set", props: { alpha: 0.5 } }] } },
{ op: "on", events: ["idle"], block: { repeat: null, steps: [{ op: "set", props: { alpha: 1 } }] } },
] };
runner.event(block, "hover");
await wait(20);
assert.equal(props.alpha, 0.5);
runner.event(block, "idle");
await wait(20);
assert.equal(props.alpha, 1);
});
test("eval/exec of source built at run time use the sandbox's own rules", () => {
const { py, store } = python();
store.score = 7;
store.persistent = new PyDict();
const evaluate = (source) => py.eval(parseExpression(source));
assert.equal(evaluate("score * 2 + 1"), 15);
assert.equal(evaluate("'a' + 'b' if score > 5 else 'c'"), "ab");
assert.equal(evaluate("not (score < 3) and score in [7, 8]"), true);
assert.deepEqual(evaluate("[1, 2, (3, 4)][2]"), [3, 4]);
assert.equal(evaluate("score < 10 < 20"), true);
py.exec(parseStatements("score += 3; other = score ** 2\npersistent.seen = 1"));
assert.equal(store.score, 10);
assert.equal(store.other, 100);
assert.equal(store.persistent.get("seen"), 1);
assert.throws(() => parseExpression("score +"), /Синтаксическая ошибка/);
assert.throws(() => parseExpression("lambda: 1"), /не поддерживается/);
assert.throws(() => parseStatements("import os"), /не поддерживается/);
assert.equal(py.eval(parseExpression("__import__")), null, "dunder names stay unreachable");
});