- Replace if/else wrapping in WCProcCall.generateOwnCode with branch-aware generation via _branchDescendants, _emitBranch, and _generateCallWithError - Shared blocks (reachable from both error and success paths) now appear only once after the if-block, avoiding deeply nested code - Add 8 browser tests covering merge, decision passthrough, value style, sequential calls, and shared-only error paths - Increase template block spacing (16px -> 40px) for readability - Add error WCEnd blocks to console and SDL3 templates with error-hook connections to all failure-mode proc calls - Hide editable WCProcCall input fields behind field-display spans, matching existing algorithm-block convention
792 lines
31 KiB
JavaScript
792 lines
31 KiB
JavaScript
// ── 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 = '<div class="apptest-header">Running tests...</div>';
|
|
|
|
// 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 = `<span class="apptest-icon">${type === "pass" ? "✓" : "✗"}</span> <span class="apptest-name">${name}</span>` +
|
|
(detail ? `<span class="apptest-detail">${detail}</span>` : "");
|
|
panel.appendChild(row);
|
|
}
|
|
},
|
|
|
|
_ensurePanel() {
|
|
let panel = $id("apptest-panel");
|
|
if (panel) return panel;
|
|
panel = document.createElement("div");
|
|
panel.id = "apptest-panel";
|
|
panel.innerHTML = `
|
|
<div class="apptest-toolbar">
|
|
<span class="apptest-title">Test Suite</span>
|
|
<button class="apptest-copy" onclick="APP_TEST_SUITE._copyReport()">Copy</button>
|
|
<button class="apptest-close" onclick="document.getElementById('apptest-panel').remove()">x</button>
|
|
</div>
|
|
<div class="apptest-body"></div>`;
|
|
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;
|