// ── In-app test runner ───────────────────────────────────────────────── // // Accessible from the global toolbar "Tests" button. // Tests DOM-dependent functionality in the live browser context. // const APP_TEST_SUITE = { passed: 0, failed: 0, results: [], run() { this.passed = 0; this.failed = 0; this.results = []; const panel = this._ensurePanel(); panel.innerHTML = '
Running tests...
'; // Delay to let UI breathe setTimeout(() => this._runAll(), 50); }, _runAll() { this._group("Block creation", () => { this._test("create decision block", () => { const el = prog.newDecisionNode(); if (!el || el.tagName !== "WC-DECISION") throw Error("decision not created"); el.remove(); }); this._test("create assign block", () => { const el = prog.newNode("wc-assign"); if (!el || el.tagName !== "WC-ASSIGN") throw Error("assign not created"); el.remove(); }); this._test("create expression block", () => { const el = prog.newExpressionNode(); if (!el || el.tagName !== "WC-EXPRESSION") throw Error("expr not created"); el.remove(); }); this._test("create proccall block", () => { const el = prog.newProcCallNode(); if (!el || el.tagName !== "WC-PROCCALL") throw Error("proccall not created"); el.remove(); }); this._test("create boolean variable", () => { const el = prog.newBooleanNode(); if (!el || el.tagName !== "WC-BOOLEAN") throw Error("boolean not created"); el.remove(); }); this._test("create scalar variable", () => { const el = prog.newScalarNode(); if (!el || el.tagName !== "WC-SCALAR") throw Error("scalar not created"); el.remove(); }); this._test("create string variable", () => { const el = prog.newStringNode(); if (!el || el.tagName !== "WC-STRING") throw Error("string not created"); el.remove(); }); this._test("start and end blocks exist", () => { const start = $id("main-start"); const end = $id("main-end"); if (!start || start.tagName !== "WC-START") throw Error("start missing"); if (!end || end.tagName !== "WC-END") throw Error("end missing"); }); this._test("wcNextFlow and wcForeFlow initialized", () => { const el = prog.newDecisionNode(); if (!Array.isArray(el.wcNextFlow)) throw Error("wcNextFlow not array"); if (!Array.isArray(el.wcForeFlow)) throw Error("wcForeFlow not array"); el.remove(); }); }); this._group("Code generation", () => { this._test("generateCode returns without error", () => { prog.generateCode(); const out = $id("ir-code"); if (!out || !out.textContent) throw Error("no code output"); }); this._test("generated code contains main function", () => { prog.generateCode(); const code = $id("ir-code").textContent; if (!code.includes("int main()")) throw Error("missing int main()"); }); this._test("generated code contains return 0", () => { prog.generateCode(); const code = $id("ir-code").textContent; if (!code.includes("return 0")) throw Error("missing return 0"); }); }); this._group("Variable operations", () => { this._test("boolean has var-name and var-value", () => { const el = prog.newBooleanNode(); if (!el.querySelector(".var-name")) throw Error("missing var-name"); if (!el.querySelector(".var-value")) throw Error("missing var-value"); el.remove(); }); this._test("scalar has var-name, var-type, var-unit", () => { const el = prog.newScalarNode(); if (!el.querySelector(".var-name")) throw Error("missing var-name"); if (!el.querySelector(".var-type")) throw Error("missing var-type"); if (!el.querySelector(".var-unit")) throw Error("missing var-unit"); el.remove(); }); }); this._group("Data types", () => { this._test("toCName on helpers starts with lowercase", () => { if (typeof toCName !== "function") throw Error("toCName not global"); const r = toCName("Test Value"); if (r !== "test_value") throw Error("got: " + r); }); this._test("escapeString escapes quotes", () => { if (typeof escapeString !== "function") throw Error("escapeString not global"); const r = escapeString('say "hello"'); if (r !== 'say \\"hello\\"') throw Error("got: " + r); }); }); this._group("Persist/restore", () => { this._test("saveBlockData returns x/y", () => { if (typeof saveBlockData !== "function") throw Error("saveBlockData not global"); const el = prog.newDecisionNode(); el.style.left = "50px"; el.style.top = "100px"; const d = saveBlockData(el); if (d.x !== 50) throw Error("x wrong: " + d.x); if (d.y !== 100) throw Error("y wrong: " + d.y); el.remove(); }); }); this._group("Helpers", () => { this._test("refreshAll runs without error", () => { refreshAll(); }); this._test("findDataSource returns null for unconnected hook", () => { const el = prog.newDecisionNode(); const hook = el.querySelector(".decision-data-in"); const r = findDataSource(hook); if (r !== null) throw Error("expected null, got: " + r); el.remove(); }); this._test("splitValueAndUnit parses correctly", () => { if (typeof splitValueAndUnit !== "function") throw Error("not global"); const r = splitValueAndUnit("10 km/h", 0); if (r.numVal !== 10 || r.unitSuffix !== "km/h") throw Error("got: " + JSON.stringify(r)); }); }); this._group("Block linking and warnings", () => { this._test("_linkFlow creates SVG path element", () => { const start = $id("main-start"), end = $id("main-end"); const before = document.querySelectorAll("path.flow").length; _linkFlow(start, ".hook", end, ".hook"); const after = document.querySelectorAll("path.flow").length; for (const p of end.wcForeFlow) deleteLink(p); if (after <= before) throw Error("no path created"); }); this._test("linked block updates wcNextFlow and wcForeFlow", () => { const start = $id("main-start"), end = $id("main-end"); _linkFlow(start, ".hook", end, ".hook"); const ok = start.wcNextFlow.length > 0 && end.wcForeFlow.length > 0; for (const p of end.wcForeFlow) deleteLink(p); if (!ok) throw Error("flow arrays not updated"); }); this._test("deleteLink removes path and cleans arrays", () => { const start = $id("main-start"), end = $id("main-end"); _linkFlow(start, ".hook", end, ".hook"); const p = end.wcForeFlow[0]; deleteLink(p); if (start.wcNextFlow.length > 0) throw Error("wcNextFlow not cleaned"); if (end.wcForeFlow.length > 0) throw Error("wcForeFlow not cleaned"); if (document.body.contains(p)) throw Error("path still in DOM"); }); this._test("end block is disconnected after startup", () => { const end = $id("main-end"); end._checkLogicInput(); if (!end.classList.contains("disconnected")) throw Error("end should be disconnected"); }); this._test("linking then deleting restores disconnected warning", () => { const start = $id("main-start"), end = $id("main-end"); _linkFlow(start, ".hook", end, ".hook"); refreshBlockWarnings(); end.classList.remove("disconnected"); for (const p of end.wcForeFlow) deleteLink(p); refreshBlockWarnings(); if (!end.classList.contains("disconnected")) throw Error("should revert to disconnected"); }); this._test("new decision block shows disconnected warning", () => { const d = prog.newDecisionNode(); d._checkLogicInput(); const hasWarning = d.classList.contains("disconnected"); d.remove(); if (!hasWarning) throw Error("new decision should be disconnected"); }); }); this._group("Decision code generation", () => { this._test("decision with yes branch generates if statement", () => { const dec = prog.newDecisionNode(); const a = prog.newNode("wc-assign"); const start = $id("main-start"), end = $id("main-end"); const saved = [...start.wcNextFlow]; for (const p of saved) deleteLink(p); _linkFlow(start, ".hook", dec, "#hook-fore"); _linkFlow(dec, "#hook-out1", a, ".hook-in"); _linkFlow(a, "#hook-next", end, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; dec.delete(); a.delete(); _linkFlow(start, ".hook", end, ".hook"); if (!code.includes("if")) throw Error("expected if"); if (!code.includes("return 0")) throw Error("expected return 0"); }); this._test("decision with yes+no branches generates if/else", () => { const dec = prog.newDecisionNode(); const aYes = prog.newNode("wc-assign"); const aNo = prog.newNode("wc-assign"); const start = $id("main-start"), end = $id("main-end"); const saved = [...start.wcNextFlow]; for (const p of saved) deleteLink(p); _linkFlow(start, ".hook", dec, "#hook-fore"); _linkFlow(dec, "#hook-out1", aYes, ".hook-in"); _linkFlow(dec, "#hook-out0", aNo, ".hook-in"); _linkFlow(aYes, "#hook-next", end, ".hook"); _linkFlow(aNo, "#hook-next", end, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; dec.delete(); aYes.delete(); aNo.delete(); _linkFlow(start, ".hook", end, ".hook"); if (!code.includes("if")) throw Error("expected if"); if (!code.includes("else")) throw Error("expected else"); }); }); this._group("Workspace clearing", () => { this._test("_clearWorkspace removes all non-start/end blocks", () => { const d = prog.newDecisionNode(); const a = prog.newNode("wc-assign"); const blockCount = () => document.querySelectorAll("#main-program > [data-block]").length; _clearWorkspace({ keepStorage: true }); if (blockCount() > 2) throw Error("blocks remain after clear"); d.remove(); a.remove(); }); this._test("_clearWorkspace resets flow arrays", () => { const start = $id("main-start"), end = $id("main-end"); const d = prog.newDecisionNode(); _linkFlow(start, ".hook", d, "#hook-fore"); _linkFlow(d, "#hook-out1", end, ".hook"); _clearWorkspace({ keepStorage: true }); if (start.wcNextFlow.length > 0) throw Error("start.wcNextFlow not cleared"); if (end.wcForeFlow.length > 0) throw Error("end.wcForeFlow not cleared"); d.remove(); }); this._test("_clearWorkspace clears lib panes and tabs", () => { const beforeTabs = document.querySelectorAll(".lib-tab").length; const beforePanes = document.querySelectorAll(".lib-pane").length; _clearWorkspace({ keepStorage: true }); const afterTabs = document.querySelectorAll(".lib-tab").length; const afterPanes = document.querySelectorAll(".lib-pane").length; if (afterTabs > 0 || afterPanes > 0) throw Error("lib tabs/panes remain"); }); }); this._group("Edge cases", () => { this._test("generateCode with only start→end returns valid C", () => { const start = $id("main-start"), end = $id("main-end"); const saved = [...start.wcNextFlow]; for (const p of saved) deleteLink(p); _linkFlow(start, ".hook", end, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; if (!code.includes("int main()")) throw Error("missing main"); if (!code.includes("return 0")) throw Error("missing return 0"); if (!code.includes("exit")) throw Error("missing exit"); }); this._test("toCName handles edge cases correctly", () => { if (typeof toCName !== "function") throw Error("toCName not global"); if (toCName("int") !== "int_") throw Error("keyword suffix: " + toCName("int")); if (toCName("") !== "var_") throw Error("empty: " + toCName("")); if (toCName("1abc") !== "_1abc") throw Error("leading digit: " + toCName("1abc")); if (toCName("ABC_DEF") !== "abc_def") throw Error("case: " + toCName("ABC_DEF")); }); this._test("refreshBlockWarnings runs without error on empty workspace", () => { refreshBlockWarnings(); }); }); this._group("Sections", () => { this._test("saveSections returns empty array when no sections", () => { if (typeof saveSections !== "function") throw Error("saveSections not global"); const data = saveSections(); if (!Array.isArray(data)) throw Error("expected array"); }); this._test("blockOnSectionBorder returns false for isolated block", () => { if (typeof blockOnSectionBorder !== "function") throw Error("blockOnSectionBorder not global"); const d = prog.newDecisionNode(); d.style.left = "9999px"; d.style.top = "9999px"; const hit = blockOnSectionBorder(d); d.remove(); if (hit) throw Error("isolated block should not intersect"); }); this._test("saveSections + restoreSections round-trip preserves data", () => { const orig = window.LOGIC_SECTIONS; if (!orig) throw Error("LOGIC_SECTIONS not found"); orig.list.push({ id: "section-test-1", points: [{x:10,y:10},{x:100,y:10},{x:100,y:100}], title: "Test" }); const saved = saveSections(); orig.list = []; restoreSections(saved); const restored = saveSections(); orig.list = []; if (restored.length !== 1) throw Error("round-trip lost sections"); if (restored[0].title !== "Test") throw Error("title not preserved"); if (restored[0].points.length !== 3) throw Error("points not preserved"); }); }); this._group("END block errno selection", () => { this._test("end block has errno input field", () => { const end = $id("main-end"); if (!end) throw Error("end block missing"); if (!end._el || !end._el.input) throw Error("_el.input not set"); const input = end.querySelector(".end-errno-input"); if (!input) throw Error(".end-errno-input element missing"); }); this._test("end block selectByCode picks exit value", () => { const end = $id("main-end"); end._selectByCode(1); if (end._selectedCode !== 1) throw Error("code not set to 1: " + end._selectedCode); if (end.dataset.exitError !== "true") throw Error("exitError not set for non-zero"); const input = end.querySelector(".end-errno-input"); if (input.value.indexOf("1") !== 0) throw Error("input not updated: " + input.value); end._selectByCode(0); if (end._selectedCode !== 0) throw Error("code not reset to 0"); if (end.dataset.exitError !== "") throw Error("exitError not cleared for zero"); }); this._test("generateOwnCode returns return 0 for code 0", () => { const end = $id("main-end"); end._selectedCode = 0; const code = end.generateOwnCode(); if (!code.includes("return 0")) throw Error("expected return 0, got: " + code); }); this._test("generateOwnCode returns exit(N) for non-zero code", () => { const end = $id("main-end"); end._selectedCode = 42; const code = end.generateOwnCode(); if (!code.includes("exit(42)")) throw Error("expected exit(42), got: " + code); end._selectByCode(0); }); this._test("saveData/restoreData round-trips exit code", () => { const end = $id("main-end"); end._selectByCode(9); const saved = end.saveData(); if (saved.exitCode !== 9) throw Error("save failed: " + JSON.stringify(saved)); end._selectByCode(0); end.restoreData(saved); if (end._selectedCode !== 9) throw Error("restore failed: " + end._selectedCode); end._selectByCode(0); }); }); this._group("WCEnd editor modal", () => { this._test("selectByCode sets value attribute for clone", () => { const end = $id("main-end"); end._selectByCode(42); const input = end.querySelector(".end-errno-input"); const attr = input.getAttribute("value"); if (attr !== "42 error") throw Error("expected '42 error', got: '" + attr + "'"); end._selectByCode(0); const attr0 = input.getAttribute("value"); if (attr0 !== "0 success") throw Error("expected '0 success', got: '" + attr0 + "'"); }); this._test("editor modal defaults to blue for success end", () => { const end = $id("main-end"); end._selectByCode(0); showBlockEditor(end); const overlay = $id("block-editor-overlay"); if (!overlay) throw Error("no overlay"); const picker = overlay.querySelector(".block-editor-color-picker"); if (!picker) throw Error("no picker"); if (picker.value !== "#4a90d9") throw Error("expected blue (#4a90d9), got: " + picker.value); const cancel = overlay.querySelector(".block-editor-cancel"); if (cancel) cancel.click(); }); this._test("editor modal defaults to red for error end", () => { const end = $id("main-end"); end._selectByCode(42); showBlockEditor(end); const overlay = $id("block-editor-overlay"); if (!overlay) throw Error("no overlay"); const picker = overlay.querySelector(".block-editor-color-picker"); if (!picker) throw Error("no picker"); if (picker.value !== "#ef5350") throw Error("expected red (#ef5350), got: " + picker.value); const cancel = overlay.querySelector(".block-editor-cancel"); if (cancel) cancel.click(); end._selectByCode(0); }); this._test("editor modal clone shows correct errno value", () => { const end = $id("main-end"); end._selectByCode(9); showBlockEditor(end); const overlay = $id("block-editor-overlay"); if (!overlay) throw Error("no overlay"); const cloneInput = overlay.querySelector(".end-errno-input"); if (!cloneInput) throw Error("no clone input"); if (cloneInput.value !== "9 error") throw Error("expected '9 error', got: '" + cloneInput.value + "'"); const cancel = overlay.querySelector(".block-editor-cancel"); if (cancel) cancel.click(); end._selectByCode(0); }); }); this._group("Regression tests", () => { this._test("WCEnd click-outside uses contains() not closest(this)", () => { const end = $id("main-end"); if (!end) throw Error("end block missing"); // Show suggestions first end._el.suggestions.style.display = "block"; if (end._el.suggestions.style.display !== "block") throw Error("suggestions not shown"); // Click outside the end block const decoy = document.createElement("div"); document.body.appendChild(decoy); decoy.dispatchEvent(new MouseEvent("click", { bubbles: true })); document.body.removeChild(decoy); if (end._el.suggestions.style.display === "block") throw Error("suggestions not hidden after outside click"); }); this._test("drawpath handles detached path without crash", () => { const start = $id("main-start"), end = $id("main-end"); if (!start || !end) throw Error("start/end missing"); // Create a valid link first _linkFlow(start, ".hook", end, ".hook"); const paths = [...end.wcForeFlow]; if (paths.length === 0) throw Error("no path created"); const p = paths[0]; // Remove target block from DOM (detaches the hook) end.style.display = "none"; let threw = false; try { drawpath(p); } catch (e) { threw = true; } end.style.display = ""; // Clean up link for (const cp of end.wcForeFlow) deleteLink(cp); if (threw) throw Error("drawpath threw on detached hook"); }); this._test("drawpath handles disconnected path without crash", () => { const start = $id("main-start"), end = $id("main-end"); _linkFlow(start, ".hook", end, ".hook"); const p = end.wcForeFlow[0]; if (!p) throw Error("no path"); // Remove path from DOM p.remove(); let threw = false; try { drawpath(p); } catch (e) { threw = true; } // Delete the remaining reference in the arrays const idx = end.wcForeFlow.indexOf(p); if (idx !== -1) end.wcForeFlow.splice(idx, 1); const idx2 = start.wcNextFlow.indexOf(p); if (idx2 !== -1) start.wcNextFlow.splice(idx2, 1); delete p.toHook.dataset.connected; if (threw) throw Error("drawpath threw on disconnected path"); }); }); this._group("Error-path code generation", () => { /* Helper: save start→end links, clear them, return a restore function */ const _disconnectStartEnd = () => { const start = $id("main-start"), end = $id("main-end"); const saved = [...start.wcNextFlow]; for (const p of saved) deleteLink(p); return () => _linkFlow(start, ".hook", end, ".hook"); }; this._test("simple error path generates if without else", () => { const restore = _disconnectStartEnd(); const start = $id("main-start"), mainEnd = $id("main-end"); const call = prog.newProcCallNode(); const errEnd = prog.newNode("wc-end"); errEnd._selectedCode = 1; call._el.errHook.classList.add("visible"); call._cachedReturnStyle = "error"; _linkFlow(start, ".hook", call, ".hook-in"); _linkFlow(call, ".proccall-error-hook", errEnd, ".hook"); _linkFlow(call, ".hook-out", mainEnd, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; call.delete(); errEnd.delete(); restore(); if (!code.includes("if")) throw Error("expected if"); if (code.includes("else")) throw Error("unexpected else: " + code); }); this._test("error path emits exclusive success blocks after if", () => { const restore = _disconnectStartEnd(); const start = $id("main-start"), mainEnd = $id("main-end"); const call = prog.newProcCallNode(); const errEnd = prog.newNode("wc-end"); errEnd._selectedCode = 1; const okAssign = prog.newNode("wc-assign"); call._el.errHook.classList.add("visible"); call._cachedReturnStyle = "error"; _linkFlow(start, ".hook", call, ".hook-in"); _linkFlow(call, ".proccall-error-hook", errEnd, ".hook"); _linkFlow(call, ".hook-out", okAssign, ".hook-in"); _linkFlow(okAssign, "#hook-next", mainEnd, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; call.delete(); errEnd.delete(); okAssign.delete(); restore(); const ifIdx = code.indexOf("if"); const exitIdx = code.indexOf("exit(1)"); const returnIdx = code.indexOf("return 0"); if (ifIdx === -1) throw Error("missing if"); if (exitIdx === -1) throw Error("missing exit(1)"); if (returnIdx === -1) throw Error("missing return 0"); if (exitIdx > returnIdx) throw Error("exit after return (wrong order): " + code.substring(ifIdx, ifIdx + 100)); if (code.includes("else")) throw Error("unexpected else"); }); this._test("error path merging back omits shared blocks from branches", () => { const restore = _disconnectStartEnd(); const start = $id("main-start"), mainEnd = $id("main-end"); const call = prog.newProcCallNode(); const errAssign = prog.newNode("wc-assign"); const sharedAssign = prog.newNode("wc-assign"); call._el.errHook.classList.add("visible"); call._cachedReturnStyle = "error"; _linkFlow(start, ".hook", call, ".hook-in"); _linkFlow(call, ".proccall-error-hook", errAssign, ".hook-in"); _linkFlow(call, ".hook-out", sharedAssign, ".hook-in"); _linkFlow(errAssign, "#hook-next", sharedAssign, ".hook-in"); _linkFlow(sharedAssign, "#hook-next", mainEnd, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; call.delete(); errAssign.delete(); sharedAssign.delete(); restore(); if (code.includes("else")) throw Error("unexpected else"); /* The shared assign should appear ONLY ONCE after the if block */ const first = code.indexOf("wc-assign"); const last = code.lastIndexOf("wc-assign"); if (first === -1) throw Error("assign code missing"); if (first !== last) throw Error("assign appears twice (shared block duplicated)"); }); this._test("error path through decision avoids else", () => { const restore = _disconnectStartEnd(); const start = $id("main-start"), mainEnd = $id("main-end"); const call = prog.newProcCallNode(); const errEnd = prog.newNode("wc-end"); errEnd._selectedCode = 1; const dec = prog.newDecisionNode(); const okAssign = prog.newNode("wc-assign"); call._el.errHook.classList.add("visible"); call._cachedReturnStyle = "error"; _linkFlow(start, ".hook", call, ".hook-in"); _linkFlow(call, ".proccall-error-hook", dec, "#hook-fore"); _linkFlow(dec, "#hook-out1", errEnd, ".hook"); _linkFlow(call, ".hook-out", okAssign, ".hook-in"); _linkFlow(okAssign, "#hook-next", mainEnd, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; call.delete(); errEnd.delete(); dec.delete(); okAssign.delete(); restore(); if (code.includes("else")) throw Error("unexpected else"); if (!code.includes("exit(1)")) throw Error("missing exit in error branch"); }); this._test("no error path emits plain call without if", () => { const restore = _disconnectStartEnd(); const start = $id("main-start"), mainEnd = $id("main-end"); const call = prog.newProcCallNode(); // Don't add "visible" to errHook — no error path active _linkFlow(start, ".hook", call, ".hook-in"); _linkFlow(call, ".hook-out", mainEnd, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; call.delete(); restore(); if (code.includes("if")) throw Error("unexpected if"); }); this._test("error path with value return style avoids else", () => { const restore = _disconnectStartEnd(); const start = $id("main-start"), mainEnd = $id("main-end"); const call = prog.newProcCallNode(); const errEnd = prog.newNode("wc-end"); errEnd._selectedCode = 1; call._el.errHook.classList.add("visible"); call._cachedReturnStyle = "value"; _linkFlow(start, ".hook", call, ".hook-in"); _linkFlow(call, ".proccall-error-hook", errEnd, ".hook"); _linkFlow(call, ".hook-out", mainEnd, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; call.delete(); errEnd.delete(); restore(); if (!code.includes("if")) throw Error("expected if"); if (code.includes("else")) throw Error("unexpected else: " + code); }); this._test("sequential error-capable calls produce no else", () => { const restore = _disconnectStartEnd(); const start = $id("main-start"), mainEnd = $id("main-end"); const call1 = prog.newProcCallNode(); const call2 = prog.newProcCallNode(); const errEnd1 = prog.newNode("wc-end"); errEnd1._selectedCode = 1; const errEnd2 = prog.newNode("wc-end"); errEnd2._selectedCode = 2; call1._el.errHook.classList.add("visible"); call1._cachedReturnStyle = "error"; call2._el.errHook.classList.add("visible"); call2._cachedReturnStyle = "error"; _linkFlow(start, ".hook", call1, ".hook-in"); _linkFlow(call1, ".proccall-error-hook", errEnd1, ".hook"); _linkFlow(call1, ".hook-out", call2, ".hook-in"); _linkFlow(call2, ".proccall-error-hook", errEnd2, ".hook"); _linkFlow(call2, ".hook-out", mainEnd, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; call1.delete(); call2.delete(); errEnd1.delete(); errEnd2.delete(); restore(); const ifCount = (code.match(/if\s*\(/g) || []).length; if (ifCount !== 2) throw Error("expected 2 ifs, got " + ifCount); if (code.includes("else")) throw Error("unexpected else"); }); this._test("error path with only shared blocks emits no duplicate", () => { const restore = _disconnectStartEnd(); const start = $id("main-start"), mainEnd = $id("main-end"); const call = prog.newProcCallNode(); const shared = prog.newNode("wc-assign"); call._el.errHook.classList.add("visible"); call._cachedReturnStyle = "error"; _linkFlow(start, ".hook", call, ".hook-in"); _linkFlow(call, ".proccall-error-hook", shared, ".hook-in"); _linkFlow(call, ".hook-out", shared, ".hook-in"); _linkFlow(shared, "#hook-next", mainEnd, ".hook"); prog.generateCode(); const code = $id("ir-code").textContent; call.delete(); shared.delete(); restore(); if (code.includes("else")) throw Error("unexpected else"); const first = code.indexOf("wc-assign"); const last = code.lastIndexOf("wc-assign"); if (first === -1) throw Error("assign code missing"); if (first !== last) throw Error("shared assign appears twice"); }); }); this._group("Error handling", () => { this._test("showError displays overlay", () => { showError("test error"); const overlay = $id("error-overlay"); if (!overlay || !overlay.classList.contains("active")) throw Error("overlay not shown"); closeError(); if (overlay.classList.contains("active")) throw Error("overlay not hidden"); }); }); this._renderReport(); }, _group(name, fn) { this._addResult("group", name, ""); fn(); }, _test(name, fn) { try { fn(); this.passed++; this._addResult("pass", name, ""); } catch (e) { this.failed++; this._addResult("fail", name, e.message); } }, _addResult(type, name, detail) { this.results.push({ type, name, detail }); const panel = this._ensurePanel(); if (type === "group") { const h = document.createElement("div"); h.className = "apptest-group"; h.textContent = name; panel.appendChild(h); } else { const row = document.createElement("div"); row.className = "apptest-row apptest-" + type; row.innerHTML = `${type === "pass" ? "✓" : "✗"} ${name}` + (detail ? `${detail}` : ""); panel.appendChild(row); } }, _ensurePanel() { let panel = $id("apptest-panel"); if (panel) return panel; panel = document.createElement("div"); panel.id = "apptest-panel"; panel.innerHTML = `
Test Suite
`; document.body.appendChild(panel); return panel.querySelector(".apptest-body"); }, _copyReport() { let text = "Test Suite\n==========\n"; for (const r of this.results) { if (r.type === "group") { text += "\n" + r.name + "\n"; } else { text += " " + (r.type === "pass" ? "✓" : "✗") + " " + r.name; if (r.detail) text += " — " + r.detail; text += "\n"; } } const total = this.passed + this.failed; text += "\n── " + this.passed + "/" + total + " passed"; if (this.failed > 0) text += " — " + this.failed + " FAILURE(S)"; text += "\n"; navigator.clipboard.writeText(text).catch(() => {}); }, _renderReport() { const panel = this._ensurePanel(); const total = this.passed + this.failed; const summary = document.createElement("div"); summary.className = "apptest-summary " + (this.failed === 0 ? "apptest-pass" : "apptest-fail"); summary.textContent = `${this.passed}/${total} passed` + (this.failed > 0 ? ` — ${this.failed} FAILURE(S)` : " — all clear"); panel.insertBefore(summary, panel.firstChild); } }; window.APP_TEST_SUITE = APP_TEST_SUITE;