Files
pgames-manager/frontend/tests/atl.test.mjs
T
mrleo1nidandClaude Sonnet 5.5 ff82d882c0 feat: add ATL animation timelines and try/raise to the Ren'Py player
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>
2026-10-25 12:11:07 +03:00

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" }])), /вложенность/);
});