// ── 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 = '
';
// 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("c-code");
if (!out || !out.textContent) throw Error("no code output");
});
this._test("generated code contains main function", () => {
prog.generateCode();
const code = $id("c-code").textContent;
if (!code.includes("int main()")) throw Error("missing int main()");
});
this._test("generated code contains return 0", () => {
prog.generateCode();
const code = $id("c-code").textContent;
if (!code.includes("return 0")) throw Error("missing return 0");
});
this._test("branchDescendants traverses past nested decision", () => {
const dA = prog.newDecisionNode();
const dB = prog.newDecisionNode();
const aX = prog.newNode("wc-assign");
dA.style.left = "200px"; dA.style.top = "50px";
dB.style.left = "400px"; dB.style.top = "50px";
aX.style.left = "600px"; aX.style.top = "50px";
const start = $id("main-start");
const end = $id("main-end");
const savedPaths = [...start.wcNextFlow];
for (const p of savedPaths) deleteLink(p);
_linkFlow(start, ".hook", dA, "#hook-fore");
_linkFlow(dA, "#hook-out1", dB, "#hook-fore");
_linkFlow(dB, "#hook-out1", aX, "#hook-fore");
const desc = dA._branchDescendants([dB]);
dA.delete(); dB.delete(); aX.delete();
if (!desc.has(aX)) throw Error("block after nested decision not reached");
});
});
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("c-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("c-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("c-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("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("c-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("c-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("c-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("c-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("c-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("c-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("c-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("c-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;