Named stores (PyNamespace: dotted access, functions that see their own store first, restored after a
save is loaded), real generators run step by step (next, send, yield as an expression), len/bool through
__len__/__bool__, os/calendar/itertools/pygame_sdl2 helpers, Skip and ToggleLocalVariable actions,
text tags {glitch} and {wave} and plain display of explode/gradient/ruby, SnowBlossom and anim.Blink,
RoundedCorners as a transform, transforms listed in `at`, ATL choice weights that are flags, and
renpy.image_size from the converter's picture sizes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
160 lines
7.6 KiB
JavaScript
160 lines
7.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { test } from "node:test";
|
|
import { build } from "esbuild";
|
|
|
|
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/python.ts", import.meta.url))],
|
|
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
|
const { Python, PyDict, userBuiltins, encodeValue, decodeValue, pyStr } =
|
|
await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
|
|
|
const ir = (name) => JSON.parse(readFileSync(new URL(`./fixtures/${name}.ir.json`, import.meta.url), "utf8"));
|
|
|
|
function runtime() {
|
|
const store = Object.create(null);
|
|
const builtins = {
|
|
any: (xs) => xs.some(Boolean), len: (x) => (x.map ? x.map.size : x.length), range: (n) => Array.from({ length: n }, (_, i) => i), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs],
|
|
};
|
|
const scope = {
|
|
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]; },
|
|
};
|
|
const py = new Python(scope);
|
|
Object.assign(builtins, userBuiltins(py));
|
|
return { py, store };
|
|
}
|
|
|
|
test("user classes, functions, closures and lambdas behave like CPython", () => {
|
|
const { py, store } = runtime();
|
|
py.exec(ir("classes"));
|
|
assert.equal(store.result_brave, true);
|
|
assert.equal(store.result_legend, true);
|
|
assert.equal(store.result_missing, false);
|
|
assert.equal(store.result_count, 2);
|
|
assert.equal(store.result_str, "Perk(x)");
|
|
assert.equal(store.result_hp, 0); // property setter clamps
|
|
assert.equal(store.result_role, "hero"); // **kwargs
|
|
assert.equal(store.result_static, "Bob");
|
|
assert.equal(store.result_cls, true); // classmethod gets the class
|
|
assert.equal(store.result_bump, 20); // *args / **kwargs call and `global`
|
|
assert.equal(store.total, 20);
|
|
assert.equal(store.result_fact, 720);
|
|
assert.equal(store.result_closure, 2); // nonlocal
|
|
assert.deepEqual(store.result_lambda, [3, 2, 1]);
|
|
assert.deepEqual(store.result_isinstance, [true, false, true]);
|
|
});
|
|
|
|
test("instances survive a save round trip once the class is defined again", () => {
|
|
const { py, store } = runtime();
|
|
py.exec(ir("classes"));
|
|
const saved = JSON.parse(JSON.stringify(encodeValue({ hero: store.hero, dict: new PyDict([["a", store.hero]]) })));
|
|
assert.equal(saved.hero.__inst__, "Hero");
|
|
assert.equal("Hero" in saved, false);
|
|
const restored = decodeValue(saved);
|
|
assert.equal(restored.hero.cls.name, "Hero");
|
|
store.hero = restored.hero;
|
|
assert.equal(py.eval({ k: "call", f: { k: "attr", o: { k: "name", id: "hero" }, a: "has_perk" },
|
|
a: [{ k: "const", v: "fast" }], kw: {} }), true);
|
|
});
|
|
|
|
test("the sandbox still refuses native members through user objects", () => {
|
|
const { py, store } = runtime();
|
|
py.exec(ir("classes"));
|
|
const hero = { k: "name", id: "hero" };
|
|
for (const key of ["constructor", "__proto__", "__class__", "__dict__", "prototype"]) {
|
|
assert.equal(py.eval({ k: "attr", o: hero, a: key }), null);
|
|
assert.throws(() => py.bind({ k: "attr", o: hero, a: key }, 1), /недоступ/);
|
|
}
|
|
assert.equal(Object.getPrototypeOf(store.hero.attrs), null);
|
|
assert.equal({}.injected, undefined);
|
|
});
|
|
|
|
test("unbounded recursion and bad calls fail with explicit errors", () => {
|
|
const { py } = runtime();
|
|
py.exec([{ op: "def", name: "loop", params: [], decos: [], body: [{ op: "return",
|
|
v: { k: "call", f: { k: "name", id: "loop" }, a: [], kw: {} } }] }]);
|
|
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "loop" }, a: [], kw: {} }), /рекурси/);
|
|
py.exec([{ op: "def", name: "one", params: [{ n: "x", d: null, kind: "p" }], decos: [], body: [] }]);
|
|
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "one" }, a: [], kw: {} }), /не хватает/);
|
|
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "one" },
|
|
a: [{ k: "const", v: 1 }, { k: "const", v: 2 }], kw: {} }), /слишком много/);
|
|
});
|
|
|
|
test("try/except/else/finally, raise, loop else, assert and multi-generator comprehensions", () => {
|
|
const { py, store } = runtime();
|
|
py.exec(ir("exceptions"));
|
|
assert.deepEqual(store.log, ["zero", "fin"]);
|
|
assert.equal(store.result_code, 7);
|
|
assert.equal(store.result_key, "keyerror"); // dict[...] is strict inside try
|
|
assert.equal(store.result_index, "indexerror");
|
|
assert.equal(store.result_value, "valueerror"); // int("abc")
|
|
assert.equal(store.result_generic, true);
|
|
assert.equal(store.result_zero, "zerodiv");
|
|
assert.equal(store.result_retry, 2); // return/continue pass through try
|
|
assert.equal(store.result_forelse, "none");
|
|
assert.equal(store.result_whileelse, 30);
|
|
assert.equal(store.result_reraise, "inner");
|
|
assert.deepEqual(store.result_pairs, [[1, 3], [1, 4], [2, 3], [2, 4]]);
|
|
assert.deepEqual([...store.result_dictcomp.map.entries()], [[2, 20]]);
|
|
assert.equal(store.result_assert, "boom");
|
|
});
|
|
|
|
test("lookups stay lenient outside try, uncaught exceptions are explicit errors", () => {
|
|
const { py, store } = runtime();
|
|
py.exec([{ op: "set", t: { k: "name", id: "d" }, v: { k: "dict", v: [] } }]);
|
|
assert.equal(py.eval({ k: "sub", o: { k: "name", id: "d" }, i: { k: "const", v: "missing" } }), null);
|
|
assert.throws(() => py.exec([{ op: "raise", v: { k: "name", id: "ValueError" } }]), /ValueError/);
|
|
assert.equal(py.strict, 0);
|
|
void store;
|
|
});
|
|
|
|
test("__hash__, __iter__, __getitem__ and __contains__ work on user classes", () => {
|
|
const { py, store } = runtime();
|
|
py.exec(ir("dunders"));
|
|
assert.equal(store.result_size, 1);
|
|
assert.equal(store.result_value, 2);
|
|
assert.deepEqual(store.result_loop, [6, 8, 10]);
|
|
assert.equal(store.result_item, 4);
|
|
assert.equal(store.result_in, true);
|
|
assert.equal(store.result_out, false);
|
|
});
|
|
|
|
test("generator functions hand out what they yield; iter and next walk a sequence", () => {
|
|
const { py, store } = runtime();
|
|
py.exec(ir("dunders"));
|
|
assert.deepEqual(store.result_found, [1, 2, 3, 4]);
|
|
assert.equal(store.result_first, 7);
|
|
assert.equal(store.result_second, 8);
|
|
assert.equal(store.result_default, "done");
|
|
assert.deepEqual([store.result_a, store.result_b, store.result_c], [1, 11, 10]); // send() answers the yield
|
|
});
|
|
|
|
test("named stores keep their names apart, are reached through dotted paths, and survive a save", () => {
|
|
const { py, store } = runtime();
|
|
const call = (f, ...a) => ({ k: "call", f, a, kw: {} });
|
|
const name = (id) => ({ k: "name", id });
|
|
const attr = (o, a) => ({ k: "attr", o, a });
|
|
const c = (v) => ({ k: "const", v });
|
|
py.exec([{ op: "in_store", ns: "phone.chat", body: [
|
|
{ op: "set", t: name("counter"), v: c(0) },
|
|
{ op: "def", name: "bump", params: [], body: [
|
|
{ op: "global", names: ["counter"] },
|
|
{ op: "set", t: name("counter"), v: { k: "bin", op: "+", l: name("counter"), r: c(1) } },
|
|
{ op: "return", v: name("counter") }] },
|
|
] }]);
|
|
assert.equal(store.counter, undefined, "the name stays inside the store");
|
|
assert.equal(py.eval(call(attr(attr(name("phone"), "chat"), "bump"))), 1);
|
|
assert.equal(py.eval(call(attr(attr(name("phone"), "chat"), "bump"))), 2);
|
|
py.assignPath("phone.chat.mode", "night");
|
|
assert.equal(py.eval(attr(attr(name("phone"), "chat"), "mode")), "night");
|
|
const saved = JSON.parse(JSON.stringify({ phone: store.phone }));
|
|
assert.equal(saved.phone.a.chat.a.counter, 2);
|
|
assert.equal(saved.phone.a.chat.a.bump, undefined, "functions are rebuilt by init, not saved");
|
|
py.assignPath("phone.chat.counter", 99);
|
|
py.restoreNamespaces({ phone: decodeValue(saved).phone });
|
|
assert.equal(py.eval(attr(attr(name("phone"), "chat"), "counter")), 2);
|
|
});
|