Files
pgames-manager/frontend/tests/screens.test.mjs
T
mrleo1nid 11d769f466
CI / frontend (push) Failing after 18s
CI / backend (push) Failing after 34s
fix: handle MIST movie timer actions and verify skipping
2026-10-26 22:41:19 +03:00

335 lines
15 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, HTMLVideoElement: window.HTMLVideoElement ?? class {}, 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("If accepts named true/false actions used by MIST's movie timer", () => {
const { manager, store } = setup();
try {
const { If, SetVariable } = manager.builtins();
const yes = SetVariable("branch", "yes"), no = SetVariable("branch", "no");
manager.run(If(true, { __kw: { true: yes, false: no } }), null);
assert.equal(store.branch, "yes");
manager.run(If(false, { __kw: { true: yes, false: no } }), null);
assert.equal(store.branch, "no");
assert.equal(If(true, yes, no), yes);
assert.equal(If(false, yes, no), no);
assert.equal(If(true, null, { __kw: { false: no } }), null);
assert.equal(If({ __kw: { expression: false, true: yes, false: no } }), no);
} finally { manager.destroy(); }
});
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);
});
test("a redraw keeps the playing video, and one held down button is not rebuilt under the pointer", async () => {
const movie = { w: "add", args: [{ k: "call", f: { k: "name", id: "Movie" }, a: [], kw: { play: { k: "const", v: "intro.webm" } } }],
props: {}, body: [] };
const { manager } = setup();
manager.screens.cutscene = { params: [], props: {}, defaults: [], body: [movie] };
manager.show("cutscene", [], {});
const first = manager.layer.querySelector("video");
assert.ok(first, "the video is on the screen");
for (let i = 0; i < 3; i++) { manager.refresh(); await wait(30); }
assert.equal(manager.layer.querySelector("video"), first, "the same <video> after redraws");
manager.hide("cutscene");
manager.show("cutscene", [], {});
assert.notEqual(manager.layer.querySelector("video"), first, "a screen shown again starts a new video");
// a redraw requested while a pointer is down waits for its release
window.dispatchEvent(new window.Event("pointerdown"));
const during = manager.layer.querySelector("video");
manager.refresh();
await wait(30);
assert.equal(manager.layer.querySelector("video"), during);
window.dispatchEvent(new window.Event("pointerup"));
await wait(30);
});
test("a redraw queued before pointerdown preserves the Return button until click", async () => {
const { manager } = setup();
const returned = [];
try {
manager.show("shop", ["skipped"], {}, { call: true, onReturn: (value) => returned.push(value) });
const button = buttons(manager.layer).find((element) => element.textContent === "Cancel");
manager.refresh(); // MIST's movie timer (or hover) queues the next frame.
window.dispatchEvent(new window.Event("pointerdown"));
await settle();
assert.equal(button.isConnected, true, "a queued redraw must also respect a held pointer");
window.dispatchEvent(new window.Event("pointerup"));
click(button);
assert.deepEqual(returned, [null]);
await settle();
assert.notEqual(buttons(manager.layer).find((element) => element.textContent === "Cancel"), button,
"the deferred redraw runs after the click");
} finally {
window.dispatchEvent(new window.Event("pointercancel"));
manager.destroy();
}
});