// ── 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."); }