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(""); 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("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); });