Split monolithic test.sx into composable test specs: - test-framework.sx: deftest/defsuite macros + assertion helpers - test-eval.sx: core evaluator + primitives (81 tests) - test-parser.sx: parser + serializer + round-trips (39 tests) - test-router.sx: route matching from router.sx (18 tests) - test-render.sx: HTML adapter rendering (23 tests) Runners auto-discover specs and test whatever bootstrapped code is available. Usage: `run.js eval parser router` or just `run.js`. Legacy mode (`--legacy`) still runs monolithic test.sx. Router tests use bootstrapped functions (sx_ref.py / sx-browser.js) because the hand-written evaluator's flat-dict env model doesn't support set! mutation across lambda closure boundaries. JS: 161/161. Python: 159/161 (2 parser escape bugs found). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
247 lines
9.0 KiB
JavaScript
247 lines
9.0 KiB
JavaScript
// Run SX test specs against sx-browser.js.
|
|
//
|
|
// sx-browser.js parses and evaluates test specs — SX tests itself.
|
|
// This script provides only platform functions (error catching, reporting).
|
|
//
|
|
// Usage:
|
|
// node shared/sx/tests/run.js # run all available specs
|
|
// node shared/sx/tests/run.js eval # run only test-eval.sx
|
|
// node shared/sx/tests/run.js eval parser router # run specific specs
|
|
// node shared/sx/tests/run.js --legacy # run monolithic test.sx
|
|
|
|
Object.defineProperty(globalThis, "document", { value: undefined, writable: true });
|
|
var path = require("path");
|
|
var fs = require("fs");
|
|
var Sx = require(path.resolve(__dirname, "../../static/scripts/sx-browser.js"));
|
|
|
|
// --- Test state ---
|
|
var suiteStack = [];
|
|
var passed = 0, failed = 0, testNum = 0;
|
|
|
|
// --- Helpers ---
|
|
function isNil(x) { return x === Sx.NIL || x === null || x === undefined; }
|
|
|
|
function deepEqual(a, b) {
|
|
if (a === b) return true;
|
|
if (isNil(a) && isNil(b)) return true;
|
|
if (typeof a !== typeof b) return false;
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
if (a.length !== b.length) return false;
|
|
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
|
|
return true;
|
|
}
|
|
if (a && typeof a === "object" && b && typeof b === "object") {
|
|
var ka = Object.keys(a), kb = Object.keys(b);
|
|
if (ka.length !== kb.length) return false;
|
|
for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- Platform functions injected into the SX env ---
|
|
var env = {
|
|
// Error catching — calls an SX thunk, returns result dict
|
|
"try-call": function(thunk) {
|
|
try {
|
|
Sx.eval([thunk], env);
|
|
return { ok: true };
|
|
} catch(e) {
|
|
return { ok: false, error: e.message || String(e) };
|
|
}
|
|
},
|
|
|
|
// Test reporting
|
|
"report-pass": function(name) {
|
|
testNum++;
|
|
passed++;
|
|
var fullName = suiteStack.concat([name]).join(" > ");
|
|
console.log("ok " + testNum + " - " + fullName);
|
|
},
|
|
"report-fail": function(name, error) {
|
|
testNum++;
|
|
failed++;
|
|
var fullName = suiteStack.concat([name]).join(" > ");
|
|
console.log("not ok " + testNum + " - " + fullName);
|
|
console.log(" # " + error);
|
|
},
|
|
|
|
// Suite context
|
|
"push-suite": function(name) { suiteStack.push(name); },
|
|
"pop-suite": function() { suiteStack.pop(); },
|
|
|
|
// Primitives that sx-browser.js has internally but doesn't expose through env
|
|
"equal?": function(a, b) { return deepEqual(a, b); },
|
|
"eq?": function(a, b) { return a === b; },
|
|
"boolean?": function(x) { return typeof x === "boolean"; },
|
|
"string-length": function(s) { return String(s).length; },
|
|
"substring": function(s, start, end) { return String(s).slice(start, end); },
|
|
"string-contains?": function(s, needle) { return String(s).indexOf(needle) !== -1; },
|
|
"upcase": function(s) { return String(s).toUpperCase(); },
|
|
"downcase": function(s) { return String(s).toLowerCase(); },
|
|
"reverse": function(c) { return c ? c.slice().reverse() : []; },
|
|
"flatten": function(c) {
|
|
var r = [];
|
|
for (var i = 0; i < (c||[]).length; i++) {
|
|
if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);
|
|
else r.push(c[i]);
|
|
}
|
|
return r;
|
|
},
|
|
"has-key?": function(d, k) { return d && typeof d === "object" && k in d; },
|
|
"append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },
|
|
"for-each-indexed": function(f, coll) {
|
|
for (var i = 0; i < (coll||[]).length; i++) {
|
|
Sx.eval([f, i, coll[i]], env);
|
|
}
|
|
},
|
|
"dict-set!": function(d, k, v) { if (d) d[k] = v; },
|
|
"dict-has?": function(d, k) { return d && typeof d === "object" && k in d; },
|
|
"dict-get": function(d, k) { return d ? d[k] : undefined; },
|
|
"starts-with?": function(s, prefix) { return String(s).indexOf(prefix) === 0; },
|
|
"ends-with?": function(s, suffix) { var str = String(s); return str.indexOf(suffix) === str.length - suffix.length; },
|
|
"slice": function(s, start, end) { return end !== undefined ? s.slice(start, end) : s.slice(start); },
|
|
"inc": function(n) { return n + 1; },
|
|
"append!": function(arr, item) { if (Array.isArray(arr)) arr.push(item); },
|
|
"dict": function() { return {}; },
|
|
"for-each": function(f, coll) {
|
|
for (var i = 0; i < (coll||[]).length; i++) {
|
|
Sx.eval([f, coll[i]], env);
|
|
}
|
|
},
|
|
|
|
// --- Parser platform functions (for test-parser.sx) ---
|
|
"sx-parse": function(source) { return Sx.parseAll(source); },
|
|
"sx-serialize": function(val) {
|
|
// Basic serializer for test roundtrips
|
|
if (val === Sx.NIL || val === null || val === undefined) return "nil";
|
|
if (typeof val === "boolean") return val ? "true" : "false";
|
|
if (typeof val === "number") return String(val);
|
|
if (typeof val === "string") return '"' + val.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
// Check Symbol/Keyword BEFORE generic object — they are objects too
|
|
if (val && (val._sym || val._sx_symbol)) return val.name;
|
|
if (val && (val._kw || val._sx_keyword)) return ":" + val.name;
|
|
if (Array.isArray(val)) return "(" + val.map(function(x) { return env["sx-serialize"](x); }).join(" ") + ")";
|
|
if (val && typeof val === "object") {
|
|
var parts = [];
|
|
Object.keys(val).forEach(function(k) {
|
|
parts.push(":" + k);
|
|
parts.push(env["sx-serialize"](val[k]));
|
|
});
|
|
return "{" + parts.join(" ") + "}";
|
|
}
|
|
return String(val);
|
|
},
|
|
"make-symbol": function(name) { return Sx.sym ? Sx.sym(name) : { _sx_symbol: true, name: name, toString: function() { return name; } }; },
|
|
"make-keyword": function(name) { return Sx.kw ? Sx.kw(name) : { _sx_keyword: true, name: name, toString: function() { return name; } }; },
|
|
"symbol-name": function(s) { return s && s.name ? s.name : String(s); },
|
|
"keyword-name": function(k) { return k && k.name ? k.name : String(k); },
|
|
|
|
// --- Render platform function (for test-render.sx) ---
|
|
"render-html": function(sxSource) {
|
|
if (!Sx.renderToHtml) throw new Error("render-to-html not available — html adapter not bootstrapped");
|
|
var exprs = Sx.parseAll(sxSource);
|
|
var result = "";
|
|
for (var i = 0; i < exprs.length; i++) {
|
|
result += Sx.renderToHtml(exprs[i], env);
|
|
}
|
|
return result;
|
|
},
|
|
};
|
|
|
|
// --- Resolve which test specs to run ---
|
|
var refDir = path.resolve(__dirname, "../ref");
|
|
var args = process.argv.slice(2);
|
|
|
|
// Available spec modules and their platform requirements
|
|
var SPECS = {
|
|
"eval": { file: "test-eval.sx", needs: [] },
|
|
"parser": { file: "test-parser.sx", needs: ["sx-parse"] },
|
|
"router": { file: "test-router.sx", needs: [] },
|
|
"render": { file: "test-render.sx", needs: ["render-html"] },
|
|
};
|
|
|
|
function evalFile(filename) {
|
|
var filepath = path.resolve(refDir, filename);
|
|
if (!fs.existsSync(filepath)) {
|
|
console.log("# SKIP " + filename + " (file not found)");
|
|
return;
|
|
}
|
|
var src = fs.readFileSync(filepath, "utf8");
|
|
var exprs = Sx.parseAll(src);
|
|
for (var i = 0; i < exprs.length; i++) {
|
|
Sx.eval(exprs[i], env);
|
|
}
|
|
}
|
|
|
|
// Legacy mode — run monolithic test.sx
|
|
if (args[0] === "--legacy") {
|
|
console.log("TAP version 13");
|
|
evalFile("test.sx");
|
|
} else {
|
|
// Determine which specs to run
|
|
var specsToRun;
|
|
if (args.length > 0) {
|
|
specsToRun = args;
|
|
} else {
|
|
// Auto-discover: run all specs whose platform functions are available
|
|
specsToRun = Object.keys(SPECS);
|
|
}
|
|
|
|
console.log("TAP version 13");
|
|
|
|
// Always load framework first
|
|
evalFile("test-framework.sx");
|
|
|
|
// Load router.sx if testing router (it defines the functions being tested)
|
|
for (var si = 0; si < specsToRun.length; si++) {
|
|
var specName = specsToRun[si];
|
|
var spec = SPECS[specName];
|
|
if (!spec) {
|
|
console.log("# SKIP unknown spec: " + specName);
|
|
continue;
|
|
}
|
|
|
|
// Check platform requirements
|
|
var canRun = true;
|
|
for (var ni = 0; ni < spec.needs.length; ni++) {
|
|
if (!(spec.needs[ni] in env)) {
|
|
console.log("# SKIP " + specName + " (missing: " + spec.needs[ni] + ")");
|
|
canRun = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!canRun) continue;
|
|
|
|
// Load prerequisite spec modules
|
|
if (specName === "router") {
|
|
// Use bootstrapped router functions from sx-browser.js.
|
|
// The bare evaluator can't run router.sx faithfully because set!
|
|
// inside lambda closures doesn't propagate (dict copies, not cells).
|
|
if (Sx.splitPathSegments) {
|
|
env["split-path-segments"] = Sx.splitPathSegments;
|
|
env["parse-route-pattern"] = Sx.parseRoutePattern;
|
|
env["match-route-segments"] = Sx.matchRouteSegments;
|
|
env["match-route"] = Sx.matchRoute;
|
|
env["find-matching-route"] = Sx.findMatchingRoute;
|
|
env["make-route-segment"] = Sx.makeRouteSegment;
|
|
} else {
|
|
evalFile("router.sx");
|
|
}
|
|
}
|
|
|
|
console.log("# --- " + specName + " ---");
|
|
evalFile(spec.file);
|
|
}
|
|
}
|
|
|
|
// --- Summary ---
|
|
console.log("");
|
|
console.log("1.." + testNum);
|
|
console.log("# tests " + (passed + failed));
|
|
console.log("# pass " + passed);
|
|
if (failed > 0) {
|
|
console.log("# fail " + failed);
|
|
process.exit(1);
|
|
}
|