Games write their own displayables with render(), renpy.Render, blit, fill, canvas() and renpy.redraw. render.ts keeps a tree of placed pieces (child renders, ordinary displayables, fills and drawing commands) and turns it into DOM elements that stay put between redraws; pointer events reach event() as pygame-style objects, and a returned value ends the interaction. Shaders and GPU properties are accepted and ignored. The converter whitelists the calls and methods and accepts `import pygame`. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
186 lines
8.8 KiB
JavaScript
186 lines
8.8 KiB
JavaScript
import { readFileSync } from "node:fs";
|
|
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { makeEngine } from "./helpers/renpy-engine.mjs";
|
|
|
|
const runtime = () => ({ game: { resolution: [640, 480], entry: "start", ui: {}, files: {},
|
|
defaults: [], init: [], characters: {}, channels: {}, images: { background: { file: "background.webp" } },
|
|
transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {} },
|
|
modules: [{ labels: [{ name: "start", body: [{ op: "return" }] }], anchors: {} }] });
|
|
|
|
test("runtime image declarations produce scaled thumbnails and survive saving", () => {
|
|
const { engine } = makeEngine(runtime());
|
|
try {
|
|
engine.newStore();
|
|
engine.fileUrl = (file) => `/mock/${file}`;
|
|
const image = engine.builtins.im.Scale(engine.builtins.ImageReference("background"), 384, 216);
|
|
engine.builtins.renpy.image("thumbnail", image);
|
|
const element = engine.screenImage("thumbnail");
|
|
assert.equal(element.style.width, "384px");
|
|
assert.equal(element.style.height, "216px");
|
|
assert.equal(element.src, "/mock/background.webp");
|
|
const saved = structuredClone(engine.snapshotState());
|
|
engine.newStore();
|
|
assert.equal(engine.resolveImage(["thumbnail"]), null);
|
|
engine.applySave(saved);
|
|
assert.deepEqual(engine.resolveImage(["thumbnail"]).def.props.size, [384, 216]);
|
|
const before = Object.getPrototypeOf(engine.game.images);
|
|
engine.builtins.renpy.image("__proto__", "safe.webp");
|
|
assert.equal(Object.getPrototypeOf(engine.game.images), before);
|
|
} finally { engine.destroy(); }
|
|
});
|
|
|
|
test("a missing original label stops execution instead of advancing into another branch", () => {
|
|
const { engine } = makeEngine(runtime());
|
|
try {
|
|
engine.cur = { label: "start", path: [0] };
|
|
assert.throws(() => engine.jump("missing_original_label"), /не найдена/);
|
|
assert.deepEqual(engine.cur, { label: "start", path: [0] });
|
|
engine.screens.run(engine.builtins.Jump("missing_original_label"), null);
|
|
assert.match(engine.notifyEl.textContent, /missing_original_label/);
|
|
assert.equal(engine.running, false);
|
|
engine.newStore();
|
|
assert.equal(engine.screens.layer.style.visibility, "");
|
|
} finally { engine.destroy(); }
|
|
});
|
|
|
|
test("native Gallery instances keep their methods and registration target after a save", () => {
|
|
const { engine } = makeEngine(runtime());
|
|
try {
|
|
engine.newStore();
|
|
const cls = engine.builtins.Gallery;
|
|
const gallery = engine.py.instantiate(cls, []);
|
|
cls.find("button").value(gallery, "art");
|
|
cls.find("image").value(gallery, "background");
|
|
engine.store.gallery = gallery;
|
|
const saved = structuredClone(engine.snapshotState());
|
|
engine.applySave(saved);
|
|
const restored = engine.store.gallery;
|
|
assert.equal(typeof engine.py.attrOf(restored, "make_button"), "function");
|
|
cls.find("condition").value(restored, "False");
|
|
assert.equal(cls.find("Action").value(restored, "art"), null);
|
|
} finally { engine.destroy(); }
|
|
});
|
|
|
|
test("an animated preview discarded before its first timer does not keep rendering", async () => {
|
|
const { engine } = makeEngine(runtime());
|
|
let rendered = 0;
|
|
try {
|
|
engine.fileUrl = (file) => { rendered++; return `/mock/${file}`; };
|
|
engine.renderer.displayable({ repeat: true, frames: [
|
|
{ img: { file: "a.webp" }, d: 0.01 }, { img: { file: "b.webp" }, d: 0.01 },
|
|
] });
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
assert.equal(rendered, 1);
|
|
} finally { engine.destroy(); }
|
|
});
|
|
|
|
test("random generators: choices, sample, seeded Random and frozenset", () => {
|
|
const { engine } = makeEngine(runtime());
|
|
try {
|
|
engine.newStore();
|
|
const { renpy, frozenset } = engine.builtins;
|
|
assert.deepEqual(frozenset([1, 2, 2, 3]), [1, 2, 3]);
|
|
const picked = renpy.random.choices(["a", "b"], { __kw: { weights: [0, 1], k: 5 } });
|
|
assert.deepEqual(picked, ["b", "b", "b", "b", "b"]);
|
|
const sample = renpy.random.sample([1, 2, 3, 4], 3);
|
|
assert.equal(new Set(sample).size, 3);
|
|
const first = renpy.random.Random("seed"), second = renpy.random.Random("seed");
|
|
assert.deepEqual([first.randint(1, 1000), first.randint(1, 1000)], [second.randint(1, 1000), second.randint(1, 1000)]);
|
|
assert.ok(renpy.random.randrange(0, 10, 5) % 5 === 0);
|
|
} finally { engine.destroy(); }
|
|
});
|
|
|
|
test("datetime date arithmetic, dir() and style.rebuild", () => {
|
|
const { engine } = makeEngine(runtime());
|
|
try {
|
|
engine.newStore();
|
|
const { datetime, renpy, dir } = engine.builtins;
|
|
const start = datetime.date(2025, 1, 31);
|
|
const next = start.__binop__("+", datetime.timedelta({ __kw: { days: 1 } }));
|
|
assert.deepEqual([next.year, next.month, next.day], [2025, 2, 1]);
|
|
assert.equal(next.weekday(), 5); // Saturday
|
|
assert.throws(() => datetime.date(2025, 2, 30), /ValueError/);
|
|
const back = next.__binop__("-", datetime.timedelta({ __kw: { days: 32 } }));
|
|
assert.deepEqual([back.year, back.month, back.day], [2024, 12, 31]);
|
|
assert.equal(next.__binop__("-", start).days, 1);
|
|
engine.store.known_name = 1;
|
|
assert.ok(dir().includes("known_name"));
|
|
assert.equal(renpy.style.rebuild(), null);
|
|
assert.throws(() => renpy.image_size("missing"), /IOError/);
|
|
} finally { engine.destroy(); }
|
|
});
|
|
|
|
test("File* actions work on slots of the game's own save screens", async () => {
|
|
const { engine } = makeEngine(runtime());
|
|
try {
|
|
engine.newStore();
|
|
const b = engine.builtins;
|
|
assert.equal(b.FileLoadable(1), false);
|
|
assert.equal(b.FileTime(1, { __kw: { empty: "—" } }), "—");
|
|
engine.slotCache.set("file:1-1", { title: "Привет", updated_at: "2026-01-02T03:04:05Z" });
|
|
assert.equal(b.FileLoadable(1), true);
|
|
assert.equal(b.FileSaveName(1), "Привет");
|
|
assert.match(b.FileTime(1), /^Jan 0[12], \d\d:\d\d$/);
|
|
assert.equal(b.FileScreenshot(1), null);
|
|
assert.equal(engine.screens.host.filePage, "1");
|
|
engine.screens.run(b.FilePage("3"), null);
|
|
assert.equal(b.FilePageName(), "3");
|
|
engine.screens.run(b.FilePageNext(), null);
|
|
assert.equal(b.FileCurrentPage(), "4");
|
|
engine.screens.run(b.FilePagePrevious(), null);
|
|
engine.screens.run(b.FilePagePrevious(), null);
|
|
engine.screens.run(b.FilePagePrevious(), null);
|
|
assert.equal(b.FileCurrentPage(), "1");
|
|
engine.screens.run(b.FilePagePrevious(), null);
|
|
assert.equal(b.FilePageName({ __kw: { auto: "A" } }), "quick" === b.FileCurrentPage() ? "Quick " : b.FileCurrentPage());
|
|
assert.equal(typeof engine.builtins.renpy.translation.translate_string, "function");
|
|
} finally { engine.destroy(); }
|
|
});
|
|
|
|
test("hashlib, re.fullmatch, type/id/vars, dict spread and small renpy helpers", () => {
|
|
const { engine } = makeEngine(runtime());
|
|
try {
|
|
engine.newStore();
|
|
const b = engine.builtins;
|
|
assert.equal(b.hashlib.md5("").hexdigest(), "d41d8cd98f00b204e9800998ecf8427e");
|
|
assert.equal(b.hashlib.md5("abc").hexdigest(), "900150983cd24fb0d6963f7d28e17f72");
|
|
assert.equal(b.hashlib.sha256("abc").hexdigest(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
|
const pattern = b.re.compile("[a-z]+");
|
|
assert.ok(pattern.fullmatch("abc"));
|
|
assert.equal(pattern.fullmatch("abc1"), null);
|
|
assert.equal(b.type(3), b.int);
|
|
assert.equal(b.type("x"), b.str);
|
|
assert.equal(b.id([]) === b.id([]), false);
|
|
const same = {};
|
|
assert.equal(b.id(same), b.id(same));
|
|
const spread = engine.py.eval({ k: "dict", v: [[{ k: "star", v: { k: "dict", v: [[{ k: "const", v: "a" }, { k: "const", v: 1 }]] } }, { k: "const", v: null }], [{ k: "const", v: "b" }, { k: "const", v: 2 }]] });
|
|
assert.deepEqual([...spread.map.entries()], [["a", 1], ["b", 2]]);
|
|
assert.equal(b.renpy.has_screen("nothing"), false);
|
|
assert.deepEqual(b.renpy.get_mouse_pos(), [0, 0]);
|
|
assert.equal(b.renpy.log("x"), null);
|
|
assert.equal(b.math.isfinite(1), true);
|
|
b.renpy.random.seed("a");
|
|
const one = b.renpy.random.randint(1, 1000000);
|
|
b.renpy.random.seed("a");
|
|
assert.equal(b.renpy.random.randint(1, 1000000), one);
|
|
assert.equal(typeof b.defaultdict, "function");
|
|
assert.equal(typeof b.LiveComposite, "function");
|
|
} finally { engine.destroy(); }
|
|
});
|
|
|
|
test("a game's renpy.Displayable subclass renders a tree of pieces through renpy.Render", () => {
|
|
const { engine } = makeEngine(runtime());
|
|
try {
|
|
engine.newStore();
|
|
engine.py.exec(JSON.parse(readFileSync(new URL("./fixtures/displayable.ir.json", import.meta.url), "utf8")));
|
|
const meter = engine.store.meter;
|
|
const tree = engine.screens.renderHost.renderOf(meter, 200, 100, 1.5, 1.5);
|
|
assert.deepEqual(tree.get_size(), [200, 20]);
|
|
assert.equal(tree.pieces.length, 3); // a fill, drawing commands and the picture
|
|
assert.equal(tree.pieces[0].fill, "#fff");
|
|
assert.equal(tree.pieces[1].commands[0].kind, "circle");
|
|
assert.deepEqual([tree.pieces[2].x, tree.pieces[2].y], [3, 4]);
|
|
} finally { engine.destroy(); }
|
|
});
|