Python sandbox - try/except/else/finally, raise (incl. re-raise), assert, for/while ... else - Multi-generator and dict comprehensions - Builtin exception hierarchy; user exceptions subclass it - Inside try, missing keys/indexes, division by zero, attribute access on None and unknown names raise Python exceptions; lookups stay lenient elsewhere - int()/float() raise ValueError on non-numeric strings ATL - converter/atl.py: set/move (linear, ease, easein, easeout), pause, repeat, parallel, choice, block, on show/hide and named transforms become a timeline shipped as game.json "atl"; Move() becomes a linear slide - player/atl.ts: timeline runner wired into the renderer; `on hide` plays before the picture is removed - function, time, contains, matrixcolor and expressions still block the import Tests: exceptions/ATL fixtures and runtime tests, converter ATL tests; README and ARCHITECTURE updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
94 lines
3.9 KiB
JavaScript
94 lines
3.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { fileURLToPath } from "node:url";
|
|
import { test } from "node:test";
|
|
import { build } from "esbuild";
|
|
|
|
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4);
|
|
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/atl.ts", import.meta.url))],
|
|
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
|
const { AtlRunner } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
|
|
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const block = (steps, repeat = null) => ({ steps, repeat });
|
|
const set = (props) => ({ op: "set", props });
|
|
const move = (t, props, warp = "linear") => ({ op: "move", warp, t, props });
|
|
|
|
function runner(named = {}) {
|
|
const seen = [];
|
|
const host = {
|
|
block: (n) => named[n] ?? null,
|
|
props: () => null,
|
|
apply: (p) => seen.push({ ...p }),
|
|
};
|
|
const props = { xpos: 0.5, xanchor: 0.5 };
|
|
return { run: new AtlRunner(host, props), props, seen };
|
|
}
|
|
|
|
test("a linear move interpolates from the current value to the target", async () => {
|
|
const { run, props, seen } = runner();
|
|
await run.start(block([set({ xalign: 0 }), move(0.12, { xalign: 1 })]));
|
|
assert.equal(props.xalign, 1);
|
|
const values = seen.map((p) => p.xalign);
|
|
assert.ok(values.some((v) => v > 0 && v < 1), "has intermediate frames");
|
|
assert.deepEqual(values, [...values].sort((a, b) => a - b), "monotonic");
|
|
});
|
|
|
|
test("a move starts from the value implied by xpos when xalign was never set", async () => {
|
|
const { run, seen } = runner();
|
|
await run.start(block([move(0.05, { xalign: 1 })]));
|
|
assert.ok(seen[0].xalign > 0.4, `first frame ${seen[0].xalign} starts near xpos 0.5`);
|
|
});
|
|
|
|
test("tuples move component-wise and parallel blocks run together", async () => {
|
|
const { run, props } = runner();
|
|
const started = performance.now();
|
|
await run.start(block([{ op: "parallel", blocks: [
|
|
block([move(0.1, { pos: [1, 1] })]), block([move(0.1, { alpha: 0 })]),
|
|
] }]));
|
|
assert.deepEqual(props.pos, [1, 1]);
|
|
assert.equal(props.alpha, 0);
|
|
assert.ok(performance.now() - started < 250, "blocks overlapped instead of running one after another");
|
|
});
|
|
|
|
test("repeat loops until stopped, even when the body takes no time", async () => {
|
|
const { run, seen } = runner();
|
|
const done = run.start(block([set({ alpha: 0.5 })], true)); // would freeze the page without the frame yield
|
|
await wait(60);
|
|
run.stop();
|
|
await done;
|
|
assert.ok(seen.length > 1 && seen.length < 200);
|
|
const counted = runner();
|
|
const finished = counted.run.start(block([{ op: "pause", t: 0.01 }, set({ zoom: 2 })], 3));
|
|
await finished;
|
|
assert.equal(counted.seen.length, 3);
|
|
});
|
|
|
|
test("named transforms, choice and block steps are followed", async () => {
|
|
const named = { grow: block([move(0.03, { zoom: 2 })]) };
|
|
const { run, props } = runner(named);
|
|
await run.start(block([{ op: "use", name: "grow" }, { op: "choice", blocks: [{ w: 1, block: block([set({ alpha: 0.25 })]) }] },
|
|
{ op: "block", block: block([set({ yoffset: 5 })]) }]));
|
|
assert.equal(props.zoom, 2);
|
|
assert.equal(props.alpha, 0.25);
|
|
assert.equal(props.yoffset, 5);
|
|
});
|
|
|
|
test("on show runs at once and `on hide` runs only when the image leaves", async () => {
|
|
const { run, props } = runner();
|
|
const timeline = block([
|
|
{ op: "on", events: ["show"], block: block([set({ alpha: 1 })]) },
|
|
{ op: "on", events: ["hide"], block: block([move(0.05, { alpha: 0 })]) },
|
|
]);
|
|
void run.start(timeline);
|
|
await wait(30);
|
|
assert.equal(props.alpha, 1);
|
|
assert.equal(run.hasHideHandler(timeline), true);
|
|
await run.hide(timeline);
|
|
assert.equal(props.alpha, 0);
|
|
});
|
|
|
|
test("excessive nesting is refused instead of overflowing the stack", async () => {
|
|
const { run } = runner({ loop: { steps: [{ op: "use", name: "loop" }], repeat: null } });
|
|
await assert.rejects(run.start(block([{ op: "use", name: "loop" }])), /вложенность/);
|
|
});
|