Files
avcf/tests/node/test-pure.js
T
adrien b8e8ef9113 overhaul: modular architecture, tests, doc generation, warning system, and UX improvements
- Modular block files: split into blocks-algo, blocks-data, blocks-proc
- New modules: sections.js (labelled canvas areas), proc-browser.js (procedure tree modal), lib-loader.js (lazy library loading), templates.js (project templates), errno-data.js (errno table)
- Test infrastructure: browser test suite (28 tests) and Node pure-function tests (75 tests)
- Warning system: unified .block-warn for disconnected inputs, unconnected error hooks, and zero-exit-in-error-path
- Error path highlighting: red flow paths and block borders for error-only subgraphs
- Input hooks: hidden by default, shown during linking; outputs always at bottom
- Block editor modal: pencil icon, colour picker for accent, compact field labels
- Doc generation: standalone node script extracts JSDoc -> docs/API.md (124 declarations)
- Persistence: save/restore sections, errno selection, block colours
- Fixes: _segmentsIntersect bug, WCEnd crash from closest(this), drawpath guard on detached elements
2026-06-22 18:31:23 +02:00

675 lines
23 KiB
JavaScript

// ── Node.js test suite for pure functions ─────────────────────────────────
//
// Tests all pure (DOM-independent) utility functions used in the app.
// Run with: node tests/node/test-pure.js
//
// No external dependencies — uses only Node.js built-in assert.
//
const assert = require("assert");
// ═══════════════════════════════════════════════════════════════════════════
// Helpers — extracted from helpers.js (pure functions only)
// ═══════════════════════════════════════════════════════════════════════════
function splitValueAndUnit(str, defaultNum) {
const trimmed = (str || "").trim();
const parts = trimmed.split(/\s+/);
let numVal, unitSuffix;
if (parts.length < 2 || isNaN(parseFloat(parts[0]))) {
numVal = parseFloat(trimmed);
unitSuffix = null;
} else {
numVal = parseFloat(parts[0]);
unitSuffix = parts.slice(1).join(" ");
}
return { numVal: isNaN(numVal) ? defaultNum : numVal, unitSuffix };
}
function escapeString(str) {
return str
.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")
.replace(/\f/g, "\\f").replace(/\v/g, "\\v").replace(/\0/g, "\\0");
}
const C_KEYWORDS = new Set([
"auto","break","case","char","const","continue","default","do","double",
"else","enum","extern","float","for","goto","if","int","long","register",
"return","short","signed","sizeof","static","struct","switch","typedef",
"union","unsigned","void","volatile","while"
]);
function toCName(str) {
let s = (str || "").trim().toLowerCase();
s = s.replace(/[^a-z0-9_]/g, "_");
s = s.replace(/_+/g, "_");
s = s.replace(/^_|_$/g, "");
if (/^\d/.test(s)) s = "_" + s;
if (!s || C_KEYWORDS.has(s)) s = (s || "var") + "_";
return s;
}
function deriveNaturalName(techName) {
if (!techName) return "";
let s = techName;
s = s.replace(/^(SDL_|glfw|gl_?)/i, '');
const known = {
puts: "Print String",
printf: "Print Formatted",
getchar: "Get Char",
putchar: "Put Char",
perror: "Print Error",
malloc: "Allocate Memory",
atoi: "Parse Int",
atof: "Parse Float",
strlen: "String Length",
strcmp: "Compare Strings",
fabs: "Abs Float",
fmod: "Float Modulo",
};
if (known[s]) return known[s];
s = s.replace(/([a-z])([A-Z])/g, '$1 $2');
s = s.replace(/_/g, ' ');
s = s.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
s = s.replace(/\s+/g, ' ').trim();
return s.charAt(0).toUpperCase() + s.slice(1);
}
function commentPrefix(naturalName, comment) {
naturalName = (naturalName || "").trim();
comment = (comment || "").trim();
return comment && naturalName ? `// ${naturalName}: ${comment}\n` : comment ? `// ${comment}\n` : "";
}
// ═══════════════════════════════════════════════════════════════════════════
// Format spec helpers — extracted from blocks-proc.js
// ═══════════════════════════════════════════════════════════════════════════
const _FMT_SPEC_RE = /%(?:[+\- #0]*\d*(?:\.\d+)?)([diufFeEgGxXoscpaAn%])/g;
function _specDataType(spec) {
if ("di".includes(spec)) return "int";
if ("u".includes(spec)) return "int";
if ("fFeEgGaA".includes(spec)) return "double";
if ("s".includes(spec)) return "string";
return "int";
}
function _parseFormatSpecifiers(fmt) {
const specs = [];
let m;
_FMT_SPEC_RE.lastIndex = 0;
while ((m = _FMT_SPEC_RE.exec(fmt)) !== null) {
if (m[1] === "%") continue;
specs.push({ full: m[0], spec: m[1] });
}
return specs;
}
// ═══════════════════════════════════════════════════════════════════════════
// Errno helpers — from blocks-algo.js
// ═══════════════════════════════════════════════════════════════════════════
const ERRNO_LIST = [
{ code: 1, name: "EPERM", desc: "Operation not permitted" },
{ code: 2, name: "ENOENT", desc: "No such file or directory" },
{ code: 3, name: "ESRCH", desc: "No such process" },
];
function _buildErrnoItems() {
const items = [
{ code: 0, name: "success", desc: "success" },
{ code: 255, name: "error", desc: "generic error" },
];
for (const e of ERRNO_LIST) {
items.push({ code: e.code, name: e.name, desc: e.desc });
}
return items;
}
// ═══════════════════════════════════════════════════════════════════════════
// Geometry helpers — from sections.js
// ═══════════════════════════════════════════════════════════════════════════
function _shoelace(pts) {
if (pts.length < 3) return 0;
let s = 0;
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length;
s += pts[i].x * pts[j].y - pts[j].x * pts[i].y;
}
return Math.abs(s) / 2;
}
function _ptInPoly(px, py, pts) {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const xi = pts[i].x, yi = pts[i].y, xj = pts[j].x, yj = pts[j].y;
if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi)
inside = !inside;
}
return inside;
}
function _segmentsIntersect(a, b, c, d) {
const det = (b.y - a.y) * (d.x - c.x) - (b.x - a.x) * (d.y - c.y);
if (Math.abs(det) < 1) return false;
const t = ((c.y - a.y) * (d.x - c.x) - (c.x - a.x) * (d.y - c.y)) / det;
const u = ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / det;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
function _rectPolyOverlap(rx, ry, rw, rh, pts) {
const corners = [
{ x: rx, y: ry }, { x: rx + rw, y: ry },
{ x: rx + rw, y: ry + rh }, { x: rx, y: ry + rh }
];
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length;
for (const c of corners) {
if (_segmentsIntersect(pts[i], pts[j], { x: rx, y: ry }, c)) return true;
}
}
for (let i = 0; i < corners.length; i++) {
const j = (i + 1) % corners.length;
for (let k = 0; k < pts.length; k++) {
const l = (k + 1) % pts.length;
if (_segmentsIntersect(corners[i], corners[j], pts[k], pts[l])) return true;
}
}
for (const c of corners) { if (_ptInPoly(c.x, c.y, pts)) return true; }
for (const p of pts) { if (p.x >= rx && p.x <= rx + rw && p.y >= ry && p.y <= ry + rh) return true; }
return false;
}
// ═══════════════════════════════════════════════════════════════════════════
// Route helper — from draw.js
// ═══════════════════════════════════════════════════════════════════════════
function routeBetween(x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const adx = Math.abs(dx), ady = Math.abs(dy);
if (adx === 0 && ady === 0) return `L ${x2} ${y2}`;
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
const R45 = Math.PI / 4, R90 = Math.PI / 2, TOL = 5 * Math.PI / 180;
const normAngle = (() => {
const raw = Math.abs(Math.atan2(dy, dx));
return raw > R90 ? Math.PI - raw : raw;
})();
if (
Math.abs(normAngle) < TOL ||
Math.abs(normAngle - R45) < TOL ||
Math.abs(normAngle - R90) < TOL
) {
return `L ${x2} ${y2}`;
}
if (adx >= ady) {
const x1m = x1 + sx * ((adx - ady) / 2);
const x2m = x1m + sx * ady;
return `L ${x1m} ${y1} L ${x2m} ${y2} L ${x2} ${y2}`;
} else {
const y1m = y1 + sy * ((ady - adx) / 2);
const y2m = y1m + sy * adx;
return `L ${x1} ${y1m} L ${x2} ${y2m} L ${x2} ${y2}`;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; }
catch (e) { console.error(`FAIL: ${name}\n ${e.message}`); failed++; }
}
function group(name, fn) {
console.log(`\n── ${name} ──`);
fn();
}
// ── splitValueAndUnit ─────────────────────────────────────────────────────
group("splitValueAndUnit", () => {
test("parses number with unit", () => {
const r = splitValueAndUnit("5 km", 0);
assert.strictEqual(r.numVal, 5);
assert.strictEqual(r.unitSuffix, "km");
});
test("parses number with multi-word unit", () => {
const r = splitValueAndUnit("100 km/h", 0);
assert.strictEqual(r.numVal, 100);
assert.strictEqual(r.unitSuffix, "km/h");
});
test("parses bare number", () => {
const r = splitValueAndUnit("42", 0);
assert.strictEqual(r.numVal, 42);
assert.strictEqual(r.unitSuffix, null);
});
test("returns default for NaN", () => {
const r = splitValueAndUnit("abc", 10);
assert.strictEqual(r.numVal, 10);
assert.strictEqual(r.unitSuffix, null);
});
test("handles empty string", () => {
const r = splitValueAndUnit("", 5);
assert.strictEqual(r.numVal, 5);
assert.strictEqual(r.unitSuffix, null);
});
test("parses negative number with unit", () => {
const r = splitValueAndUnit("-3.5 m", 0);
assert.strictEqual(r.numVal, -3.5);
assert.strictEqual(r.unitSuffix, "m");
});
});
// ── escapeString ──────────────────────────────────────────────────────────
group("escapeString", () => {
test("escapes backslash", () => {
assert.strictEqual(escapeString("a\\b"), "a\\\\b");
});
test("escapes double quote", () => {
assert.strictEqual(escapeString('he"llo'), 'he\\"llo');
});
test("escapes newline", () => {
assert.strictEqual(escapeString("a\nb"), "a\\nb");
});
test("escapes carriage return", () => {
assert.strictEqual(escapeString("a\rb"), "a\\rb");
});
test("escapes tab", () => {
assert.strictEqual(escapeString("a\tb"), "a\\tb");
});
test("escapes form feed", () => {
assert.strictEqual(escapeString("a\fb"), "a\\fb");
});
test("escapes vertical tab", () => {
assert.strictEqual(escapeString("a\vb"), "a\\vb");
});
test("escapes null byte", () => {
assert.strictEqual(escapeString("a\0b"), "a\\0b");
});
test("passes through normal string", () => {
assert.strictEqual(escapeString("hello world"), "hello world");
});
test("handles empty string", () => {
assert.strictEqual(escapeString(""), "");
});
});
// ── toCName ───────────────────────────────────────────────────────────────
group("toCName", () => {
test("lowercases input", () => {
assert.strictEqual(toCName("Hello"), "hello");
});
test("replaces spaces with underscores", () => {
assert.strictEqual(toCName("my var"), "my_var");
});
test("replaces special chars with underscores", () => {
assert.strictEqual(toCName("x@y#z"), "x_y_z");
});
test("collapses consecutive underscores", () => {
assert.strictEqual(toCName("a___b"), "a_b");
});
test("strips leading/trailing underscores", () => {
assert.strictEqual(toCName("_hello_"), "hello");
});
test("prepends underscore for leading digit", () => {
assert.strictEqual(toCName("1var"), "_1var");
});
test("appends underscore for C keyword", () => {
assert.strictEqual(toCName("int"), "int_");
assert.strictEqual(toCName("return"), "return_");
assert.strictEqual(toCName("while"), "while_");
});
test("handles empty input", () => {
assert.strictEqual(toCName(""), "var_");
assert.strictEqual(toCName(null), "var_");
assert.strictEqual(toCName(undefined), "var_");
});
test("handles single char", () => {
assert.strictEqual(toCName("x"), "x");
});
test("handles input with only special chars", () => {
assert.strictEqual(toCName("@#$"), "var_");
});
});
// ── deriveNaturalName ─────────────────────────────────────────────────────
group("deriveNaturalName", () => {
test("strips SDL_ prefix", () => {
assert.strictEqual(deriveNaturalName("SDL_CreateWindow"), "Create Window");
});
test("strips glfw prefix", () => {
assert.strictEqual(deriveNaturalName("glfwInit"), "Init");
});
test("strips gl_ prefix", () => {
assert.strictEqual(deriveNaturalName("glClear"), "Clear");
});
test("uses known name map", () => {
assert.strictEqual(deriveNaturalName("puts"), "Print String");
assert.strictEqual(deriveNaturalName("printf"), "Print Formatted");
assert.strictEqual(deriveNaturalName("strlen"), "String Length");
});
test("converts snake_case", () => {
assert.strictEqual(deriveNaturalName("my_func"), "My func");
});
test("returns empty for null/undefined", () => {
assert.strictEqual(deriveNaturalName(null), "");
assert.strictEqual(deriveNaturalName(undefined), "");
assert.strictEqual(deriveNaturalName(""), "");
});
});
// ── commentPrefix ─────────────────────────────────────────────────────────
group("commentPrefix", () => {
test("generates full prefix with name and comment", () => {
assert.strictEqual(
commentPrefix("My Flag", "indicates status"),
"// My Flag: indicates status\n"
);
});
test("generates comment-only prefix", () => {
assert.strictEqual(commentPrefix("", "just a note"), "// just a note\n");
});
test("returns empty when no comment", () => {
assert.strictEqual(commentPrefix("My Flag", ""), "");
});
test("returns empty when both empty", () => {
assert.strictEqual(commentPrefix("", ""), "");
});
test("trims whitespace", () => {
assert.strictEqual(
commentPrefix(" name ", " desc "),
"// name: desc\n"
);
});
});
// ── _specDataType & _parseFormatSpecifiers ────────────────────────────────
group("Format specifiers", () => {
test("_specDataType maps d/i to int", () => {
assert.strictEqual(_specDataType("d"), "int");
assert.strictEqual(_specDataType("i"), "int");
});
test("_specDataType maps u to int", () => {
assert.strictEqual(_specDataType("u"), "int");
});
test("_specDataType maps fFeEgGaA to double", () => {
for (const c of "fFeEgGaA") {
assert.strictEqual(_specDataType(c), "double", `char: ${c}`);
}
});
test("_specDataType maps s to string", () => {
assert.strictEqual(_specDataType("s"), "string");
});
test("_specDataType defaults to int for unknown", () => {
assert.strictEqual(_specDataType("n"), "int");
});
test("_parseFormatSpecifiers finds %d, %f, %s", () => {
const r = _parseFormatSpecifiers("x=%d y=%f name=%s");
assert.strictEqual(r.length, 3);
assert.strictEqual(r[0].spec, "d");
assert.strictEqual(r[1].spec, "f");
assert.strictEqual(r[2].spec, "s");
});
test("_parseFormatSpecifiers handles width and precision", () => {
const r = _parseFormatSpecifiers("x=%10.2f");
assert.strictEqual(r.length, 1);
assert.strictEqual(r[0].spec, "f");
assert.strictEqual(r[0].full, "%10.2f");
});
test("_parseFormatSpecifiers handles %% (escaped percent)", () => {
const r = _parseFormatSpecifiers("50%% %d");
assert.strictEqual(r.length, 1);
assert.strictEqual(r[0].spec, "d");
});
test("_parseFormatSpecifiers returns empty for no matches", () => {
assert.deepStrictEqual(_parseFormatSpecifiers("hello"), []);
assert.deepStrictEqual(_parseFormatSpecifiers(""), []);
});
});
// ── _buildErrnoItems ──────────────────────────────────────────────────────
group("_buildErrnoItems", () => {
const items = _buildErrnoItems();
test("includes success (code 0) first", () => {
assert.strictEqual(items[0].code, 0);
assert.strictEqual(items[0].name, "success");
});
test("includes generic error (code 255) second", () => {
assert.strictEqual(items[1].code, 255);
assert.strictEqual(items[1].name, "error");
});
test("includes ERRNO_LIST items after defaults", () => {
assert.ok(items.length > 2);
assert.strictEqual(items[2].name, "EPERM");
});
});
// ── routeBetween ──────────────────────────────────────────────────────────
group("routeBetween", () => {
test("returns direct line for zero distance", () => {
assert.strictEqual(routeBetween(10, 10, 10, 10), "L 10 10");
});
test("returns direct line for horizontal move", () => {
const r = routeBetween(0, 0, 100, 0);
assert.strictEqual(r, "L 100 0");
});
test("returns direct line for vertical move", () => {
const r = routeBetween(0, 0, 0, 100);
assert.strictEqual(r, "L 0 100");
});
test("returns direct line for 45-degree move", () => {
const r = routeBetween(0, 0, 100, 100);
assert.strictEqual(r, "L 100 100");
});
test("routes 3-segment for non-45-degree move (wider than tall)", () => {
const r = routeBetween(0, 0, 200, 50);
assert.strictEqual(r, "L 75 0 L 125 50 L 200 50");
});
test("routes 3-segment for non-45-degree move (taller than wide)", () => {
const r = routeBetween(0, 0, 50, 200);
assert.strictEqual(r, "L 0 75 L 50 125 L 50 200");
});
});
// ── _shoelace (polygon area) ──────────────────────────────────────────────
group("_shoelace", () => {
test("returns 0 for empty array", () => {
assert.strictEqual(_shoelace([]), 0);
});
test("returns 0 for less than 3 points", () => {
assert.strictEqual(_shoelace([{x:0,y:0},{x:1,y:0}]), 0);
});
test("computes area of a 10x10 square", () => {
const pts = [{x:0,y:0},{x:10,y:0},{x:10,y:10},{x:0,y:10}];
assert.strictEqual(_shoelace(pts), 100);
});
test("computes area of a right triangle (3-4-5)", () => {
const pts = [{x:0,y:0},{x:3,y:0},{x:0,y:4}];
assert.strictEqual(_shoelace(pts), 6);
});
test("handles negative coordinates", () => {
const pts = [{x:-5,y:-5},{x:5,y:-5},{x:5,y:5},{x:-5,y:5}];
assert.strictEqual(_shoelace(pts), 100);
});
});
// ── _ptInPoly (point in polygon) ──────────────────────────────────────────
group("_ptInPoly", () => {
const sq = [{x:0,y:0},{x:10,y:0},{x:10,y:10},{x:0,y:10}];
test("returns true for point inside square", () => {
assert.ok(_ptInPoly(5, 5, sq));
});
test("returns false for point outside square (left)", () => {
assert.ok(!_ptInPoly(-1, 5, sq));
});
test("returns false for point outside square (above)", () => {
assert.ok(!_ptInPoly(5, -1, sq));
});
test("returns true for point on edge (boundary)", () => {
assert.ok(_ptInPoly(0, 5, sq));
});
test("handles concave L-shaped polygon", () => {
const L = [{x:0,y:0},{x:8,y:0},{x:8,y:3},{x:3,y:3},{x:3,y:8},{x:0,y:8}];
assert.ok(_ptInPoly(1, 1, L)); // inside L
assert.ok(!_ptInPoly(5, 5, L)); // in the notch
assert.ok(_ptInPoly(1, 5, L)); // inside, left arm
assert.ok(!_ptInPoly(-1, 5, L)); // outside, left of shape
});
});
// ── _segmentsIntersect ────────────────────────────────────────────────────
group("_segmentsIntersect", () => {
test("returns true for crossing segments", () => {
assert.ok(_segmentsIntersect({x:0,y:0},{x:10,y:10},{x:0,y:10},{x:10,y:0}));
});
test("returns false for parallel non-crossing segments", () => {
assert.ok(!_segmentsIntersect({x:0,y:0},{x:10,y:0},{x:0,y:10},{x:10,y:10}));
});
test("returns false for collinear non-overlapping segments", () => {
assert.ok(!_segmentsIntersect({x:0,y:0},{x:5,y:5},{x:6,y:6},{x:10,y:10}));
});
test("returns true when sharing endpoint", () => {
assert.ok(_segmentsIntersect({x:0,y:0},{x:5,y:5},{x:5,y:5},{x:10,y:0}));
});
test("returns false for disjoint segments", () => {
assert.ok(!_segmentsIntersect({x:0,y:0},{x:2,y:2},{x:8,y:8},{x:10,y:10}));
});
});
// ── _rectPolyOverlap (rectangle vs polygon) ───────────────────────────────
group("_rectPolyOverlap", () => {
const tri = [{x:0,y:0},{x:10,y:0},{x:5,y:10}];
test("returns true when rect overlaps polygon", () => {
assert.ok(_rectPolyOverlap(4, 2, 2, 2, tri));
});
test("returns false when rect is far from polygon", () => {
assert.ok(!_rectPolyOverlap(50, 50, 5, 5, tri));
});
test("returns true when rect contains a polygon vertex", () => {
assert.ok(_rectPolyOverlap(8, -2, 5, 5, tri));
});
test("returns true when polygon encloses a rect corner", () => {
assert.ok(_rectPolyOverlap(4, 1, 1, 1, tri));
});
test("returns false for rect outside with zero-size", () => {
assert.ok(!_rectPolyOverlap(100, 100, 0, 0, tri));
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Report
// ═══════════════════════════════════════════════════════════════════════════
console.log(`\n${"=".repeat(50)}`);
const total = passed + failed;
console.log(`Results: ${passed}/${total} passed`);
if (failed > 0) {
console.log(`FAILURES: ${failed} test(s) failed`);
process.exit(1);
} else {
console.log("All tests passed.");
}