Files
pgames-manager/frontend/tests/screens.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

274 lines
12 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";
import { parseHTML } from "linkedom";
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, HTMLInputElement: window.HTMLInputElement,
HTMLImageElement: window.HTMLImageElement, Image: window.Image });
window.setTimeout = setTimeout;
window.clearTimeout = clearTimeout;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 1);
const compiled = await build({
stdin: { contents: `export * from "./python"; export * from "./screens";`,
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
});
const { Python, ScreenManager, userBuiltins } =
await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const fixture = JSON.parse(readFileSync(new URL("./fixtures/screens.json", import.meta.url), "utf8"));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
function setup(extra = {}) {
const store = { money: 20, inventory: ["sword", "shield"], can_cancel: true, ...extra };
const builtins = {};
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]; },
});
const calls = { jump: [], play: [], notify: [], language: [] };
const host = {
py, baseSize: 24,
fileUrl: (p) => `/f/${p}`,
image: (value) => { const e = document.createElement("img"); e.setAttribute("src", `/f/${value}`); return e; },
text: (el, value) => { el.textContent = value.replace(/\[(\w+)\]/g, (_m, n) => String(py.lookup(n))); },
fontFamily: () => null,
play: (...a) => calls.play.push(a), stop() {}, notify: (m) => calls.notify.push(m),
jump: (l) => calls.jump.push(l), call() {}, quit() {}, mainMenu() {},
openMenu() {}, openUrl() {}, setLanguage: (c) => calls.language.push(c),
transform: (n) => fixture.transforms[n] ?? null,
atl: (n) => fixture.atl[n] ?? null,
};
const manager = new ScreenManager(host, fixture.screens, fixture.styles);
Object.assign(builtins, userBuiltins(py), manager.builtins());
document.body.append(manager.layer);
return { manager, store, calls, py };
}
const texts = (root) => [...root.querySelectorAll(".pg-stext")].map((e) => e.textContent);
const buttons = (root) => [...root.querySelectorAll(".pg-sbtn")];
const click = (el) => el.onclick({ stopPropagation() {}, preventDefault() {} });
const settle = () => wait(15);
test("a screen renders its widgets, conditions, loops and interpolated text", () => {
const { manager } = setup();
manager.show("hud", [2]);
const root = manager.layer;
assert.deepEqual(texts(root).slice(0, 2), ["Day 2", "Rich"]);
assert.deepEqual(buttons(root).map((b) => b.textContent), ["sword", "shield", "Menu", "Go"]);
const frame = root.querySelector(".pg-sframe");
assert.ok(frame, "frame rendered");
const label = root.querySelector(".pg-sbtn .pg-stext");
assert.equal(label.style.color, "#abc", "style hud_text applies through style_prefix");
assert.equal(label.style.fontSize, "30px");
assert.equal(root.querySelector(".pg-sbtn").style.background, "#123", "style hud_button applies");
});
test("ui.interact receives repeated Returns without hiding an existing screen", () => {
const { manager, py } = setup();
manager.show("hud");
const before = manager.snapshot();
const values = [];
const builtins = manager.builtins();
const cancel = manager.interact((value) => values.push(value));
manager.run(builtins.Return("tick"), null);
cancel();
assert.deepEqual(values, ["tick"]);
assert.deepEqual(manager.snapshot(), before);
manager.interact((value) => values.push(value));
manager.run(builtins.Return(0), null);
assert.deepEqual(values, ["tick", 0]);
const cancelled = manager.interact((value) => values.push(value));
cancelled();
manager.run(builtins.Return("stale"), null);
assert.deepEqual(values, ["tick", 0]);
manager.destroy();
});
test("gallery buttons honor unlock conditions and close back to the same screen", () => {
const { manager, py } = setup();
const builtins = manager.builtins();
const gallery = py.instantiate(builtins.Gallery, []);
const invoke = (name, ...args) => builtins.Gallery.find(name).value(gallery, ...args);
invoke("button", "art");
invoke("image", "art.png");
invoke("condition", "money > 10");
const value = invoke("make_button", "art", "thumb.png", "locked.png");
assert.equal(value.kw.sensitive, true);
manager.show("hud");
manager.run(value.kw.action, null);
assert.ok(manager.layer.querySelector(".pg-gallery img[src='/f/art.png']"));
const close = [...manager.layer.querySelectorAll(".pg-gallery button")].find((b) => b.textContent === "Закрыть");
click(close);
assert.equal(manager.layer.querySelector(".pg-gallery"), null);
assert.equal(manager.snapshot()[0].name, "hud");
py.scope.set("money", 0);
assert.equal(invoke("Action", "art"), null);
const locked = invoke("make_button", "art", "thumb.png", "locked.png");
assert.equal(locked.kw.sensitive, false);
assert.equal(locked.kw.idle, "locked.png");
manager.destroy();
});
test("conditions follow the game state when the screen refreshes", async () => {
const { manager, store } = setup();
manager.show("hud");
assert.ok(texts(manager.layer).includes("Rich"));
store.money = 1;
manager.refresh();
await settle();
assert.ok(texts(manager.layer).includes("Poor"));
});
test("actions change variables, screen variables and the selected state", async () => {
const { manager, store } = setup();
manager.show("hud");
click(buttons(manager.layer)[1]);
assert.equal(store.held, "shield");
assert.ok(!texts(manager.layer).includes("open!"));
click(buttons(manager.layer)[2]);
await settle();
assert.ok(texts(manager.layer).includes("open!"), "ToggleScreenVariable re-rendered the screen");
assert.ok(buttons(manager.layer)[2].classList.contains("pg-sselected"), "`selected open` marks the button");
});
test("Jump stops the remaining actions and goes to the label", () => {
const { manager, store, calls } = setup();
manager.show("hud");
click(buttons(manager.layer)[3]);
assert.equal(store.went, true);
assert.deepEqual(calls.jump, ["elsewhere"]);
});
test("call screen resolves with the Return value; a modal screen blocks what is below", async () => {
const { manager } = setup();
let result = "pending";
manager.show("shop", ["sword"], {}, { call: true, onReturn: (v) => { result = v; } });
const root = manager.layer;
assert.ok(root.querySelector(".pg-sinst").classList.contains("pg-smodal"));
const image = root.querySelector(".pg-simgbtn img");
assert.equal(image.getAttribute("src"), "/f/buy_idle.png", "auto \"buy_%s.png\" picks the idle image");
click(root.querySelector(".pg-simgbtn"));
assert.equal(result, "sword");
manager.hideCall("shop");
assert.equal(root.querySelector(".pg-sinst"), null);
});
test("insensitive buttons do nothing", () => {
const { manager } = setup({ can_cancel: false });
let result = "pending";
manager.show("shop", [], {}, { call: true, onReturn: (v) => { result = v; } });
const cancel = buttons(manager.layer)[1];
assert.ok(cancel.classList.contains("pg-sdisabled"));
click(cancel);
assert.equal(result, "pending");
});
test("timers fire, keys work, and hiding stops both", async () => {
const { manager, store } = setup();
manager.show("hud");
await wait(40);
assert.equal(store.ticked, true);
const press = () => {
const event = new window.Event("keydown");
event.key = "h";
window.dispatchEvent(event);
};
press();
assert.equal(store.hotkey, true);
store.hotkey = false;
manager.hide("hud");
press();
assert.equal(store.hotkey, false);
});
test("screens can be saved and restored with their arguments", () => {
const { manager } = setup();
manager.show("hud", [7]);
const saved = JSON.parse(JSON.stringify(manager.snapshot()));
manager.clear();
assert.equal(manager.layer.querySelector(".pg-sinst"), null);
manager.restore(saved);
assert.ok(texts(manager.layer).includes("Day 7"));
});
test("bars show a variable and unknown screens fail loudly", () => {
const { manager, store } = setup({ score: 5 });
manager.show("counter");
assert.equal(manager.layer.querySelector(".pg-sbar-fill").style.width, "50%");
store.score = 10;
assert.throws(() => manager.show("missing"), /missing/);
});
test("use with children transcludes them in the caller's scope; `has` nests the rest of the block", () => {
const { manager } = setup();
manager.show("deck");
const card = manager.layer.querySelector(".pg-sframe");
assert.ok(card.querySelector(".pg-svbox"), "`has vbox` created the box");
assert.deepEqual([...card.querySelectorAll(".pg-stext")].map((e) => e.textContent), ["Deck", "child one", "Pick"]);
assert.equal(card.querySelector(".pg-sbtn").textContent, "Pick");
});
test("on show runs once, on hide when the screen goes away", async () => {
const { manager, store } = setup();
manager.show("deck");
await settle();
assert.equal(store.shown, true);
store.shown = false;
manager.refresh();
await settle();
assert.equal(store.shown, false, "re-rendering does not repeat `on show`");
manager.hide("deck");
assert.equal(store.hid, true);
});
test("crop, glued properties, viewport grids, displayable values and extra actions", () => {
const { manager, calls } = setup();
manager.show("deck");
const root = manager.layer;
const pic = [...root.querySelectorAll(".pg-sadd")].find((e) => e.style.width === "30px");
assert.equal(pic.style.height, "40px", "crop (1, 2, 30, 40) shows a 30x40 window");
assert.equal(pic.firstChild.style.left, "-1px");
const small = [...root.querySelectorAll(".pg-sadd")].find((e) => e.style.width === "8px");
assert.ok(small, "Crop((0, 0, 8, 8), ...) works as a value");
const grid = root.querySelector(".pg-svpgrid");
assert.equal(grid.style.gridTemplateColumns, "repeat(2, auto)");
const x = [...root.querySelectorAll(".pg-stext")].find((e) => e.textContent === "x");
assert.equal(x.style.left, "calc(5px + 0px)", "pos(5, 6) without a space is understood");
const button = [...root.querySelectorAll(".pg-sbtn")].find((b) => b.textContent === "Language");
click(button);
assert.deepEqual(calls.language, ["ru"]);
assert.ok(manager.has("counter"));
});
test("animated transforms with parameters start from their arguments and do not replay", async () => {
const { manager } = setup();
manager.show("deck");
const dot = () => [...manager.layer.querySelectorAll(".pg-sadd")].find((e) => e.getAttribute("src") === "/f/dot.png");
assert.equal(dot().style.opacity, "0");
await wait(120);
assert.equal(dot().style.opacity, "1");
manager.refresh();
await settle();
assert.equal(dot().style.opacity, "1", "a finished animation stays finished after a refresh");
});
test("an `if` that only sets properties picks the image; decompiled comma actions run all parts", async () => {
const { manager, store } = setup();
manager.show("mood");
const picture = () => manager.layer.querySelector(".pg-simgbtn img").getAttribute("src");
assert.equal(picture(), "/f/rich.png");
store.money = 1;
manager.refresh();
await settle();
assert.equal(picture(), "/f/poor.png");
click(manager.layer.querySelector(".pg-simgbtn"));
assert.equal(store.mood_clicked, 1);
assert.equal(store.mood_done, true);
});