From 754e7557f59f1ff1002586c0cdb540851c0d809d Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 10:41:53 +0000 Subject: [PATCH] Add self-hosting SX test spec: 81 tests bootstrap to Python + JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test framework is written in SX and tests SX — the language proves its own correctness. test.sx defines assertion helpers (assert-equal, assert-true, assert-type, etc.) and 15 test suites covering literals, arithmetic, comparison, strings, lists, dicts, predicates, special forms, lambdas, higher-order forms, components, macros, threading, truthiness, and edge cases. Two bootstrap compilers emit native tests from the same spec: - bootstrap_test.py → pytest (81/81 pass) - bootstrap_test_js.py → Node.js TAP using sx-browser.js (81/81 pass) Also adds missing primitives to spec and Python evaluator: boolean?, string-length, substring, string-contains?, upcase, downcase, reverse, flatten, has-key?. Fixes number? to exclude booleans, append to concatenate lists. Includes testing docs page in SX app at /specs/testing. Co-Authored-By: Claude Opus 4.6 --- shared/sx/primitives.py | 43 +- shared/sx/ref/bootstrap_test.py | 245 ++++++++++++ shared/sx/ref/bootstrap_test_js.py | 210 ++++++++++ shared/sx/ref/primitives.sx | 50 ++- shared/sx/ref/test.sx | 612 +++++++++++++++++++++++++++++ shared/sx/tests/test_sx_spec.js | 199 ++++++++++ shared/sx/tests/test_sx_spec.py | 343 ++++++++++++++++ sx/sx/nav-data.sx | 8 +- sx/sx/testing.sx | 194 +++++++++ sx/sxc/pages/docs.sx | 2 + 10 files changed, 1901 insertions(+), 5 deletions(-) create mode 100644 shared/sx/ref/bootstrap_test.py create mode 100644 shared/sx/ref/bootstrap_test_js.py create mode 100644 shared/sx/ref/test.sx create mode 100644 shared/sx/tests/test_sx_spec.js create mode 100644 shared/sx/tests/test_sx_spec.py create mode 100644 sx/sx/testing.sx diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index cbd356d..f3ff5a1 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -192,9 +192,13 @@ def prim_is_zero(n: Any) -> bool: def prim_is_nil(x: Any) -> bool: return x is None or x is NIL +@register_primitive("boolean?") +def prim_is_boolean(x: Any) -> bool: + return isinstance(x, bool) + @register_primitive("number?") def prim_is_number(x: Any) -> bool: - return isinstance(x, (int, float)) + return isinstance(x, (int, float)) and not isinstance(x, bool) @register_primitive("string?") def prim_is_string(x: Any) -> bool: @@ -268,13 +272,27 @@ def prim_concat(*colls: Any) -> list: return result @register_primitive("upper") +@register_primitive("upcase") def prim_upper(s: str) -> str: return s.upper() @register_primitive("lower") +@register_primitive("downcase") def prim_lower(s: str) -> str: return s.lower() +@register_primitive("string-length") +def prim_string_length(s: str) -> int: + return len(s) + +@register_primitive("substring") +def prim_substring(s: str, start: int, end: int) -> str: + return s[int(start):int(end)] + +@register_primitive("string-contains?") +def prim_string_contains(s: str, needle: str) -> bool: + return needle in s + @register_primitive("trim") def prim_trim(s: str) -> str: return s.strip() @@ -384,8 +402,31 @@ def prim_cons(x: Any, coll: Any) -> list: @register_primitive("append") def prim_append(coll: Any, x: Any) -> list: + if isinstance(x, list): + return list(coll) + x if coll else list(x) return list(coll) + [x] if coll else [x] +@register_primitive("reverse") +def prim_reverse(coll: Any) -> list: + return list(reversed(coll)) if coll else [] + +@register_primitive("flatten") +def prim_flatten(coll: Any) -> list: + result = [] + for item in (coll or []): + if isinstance(item, list): + result.extend(item) + else: + result.append(item) + return result + +@register_primitive("has-key?") +def prim_has_key(d: Any, key: Any) -> bool: + if not isinstance(d, dict): + return False + k = key.name if isinstance(key, Keyword) else key + return k in d + @register_primitive("append!") def prim_append_mut(coll: Any, x: Any) -> list: coll.append(x) diff --git a/shared/sx/ref/bootstrap_test.py b/shared/sx/ref/bootstrap_test.py new file mode 100644 index 0000000..08eb64b --- /dev/null +++ b/shared/sx/ref/bootstrap_test.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Bootstrap compiler: test.sx -> pytest test module. + +Reads test.sx and emits a Python test file that runs each deftest +as a pytest test case, grouped into classes by defsuite. + +The emitted tests use the SX evaluator to run SX test bodies, +verifying that the Python implementation matches the spec. + +Usage: + python bootstrap_test.py --output shared/sx/tests/test_sx_spec.py + pytest shared/sx/tests/test_sx_spec.py -v +""" +from __future__ import annotations + +import os +import re +import sys +import argparse + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) +sys.path.insert(0, _PROJECT) + +from shared.sx.parser import parse_all +from shared.sx.types import Symbol, Keyword, NIL as SX_NIL + + +def _slugify(name: str) -> str: + """Convert a test/suite name to a valid Python identifier.""" + s = name.lower().strip() + s = re.sub(r'[^a-z0-9]+', '_', s) + s = s.strip('_') + return s + + +def _sx_to_source(expr) -> str: + """Convert an SX AST node back to SX source string.""" + if isinstance(expr, bool): + return "true" if expr else "false" + if isinstance(expr, (int, float)): + return str(expr) + if isinstance(expr, str): + escaped = expr.replace('\\', '\\\\').replace('"', '\\"') + return f'"{escaped}"' + if expr is None or expr is SX_NIL: + return "nil" + if isinstance(expr, Symbol): + return expr.name + if isinstance(expr, Keyword): + return f":{expr.name}" + if isinstance(expr, dict): + pairs = [] + for k, v in expr.items(): + pairs.append(f":{k} {_sx_to_source(v)}") + return "{" + " ".join(pairs) + "}" + if isinstance(expr, list): + if not expr: + return "()" + return "(" + " ".join(_sx_to_source(e) for e in expr) + ")" + return str(expr) + + +def _parse_test_sx(path: str) -> tuple[list[dict], list]: + """Parse test.sx and return (suites, preamble_exprs). + + Preamble exprs are define forms (assertion helpers) that must be + evaluated before tests run. Suites contain the actual test cases. + """ + with open(path) as f: + content = f.read() + + exprs = parse_all(content) + suites = [] + preamble = [] + + for expr in exprs: + if not isinstance(expr, list) or not expr: + continue + head = expr[0] + if isinstance(head, Symbol) and head.name == "defsuite": + suite = _parse_suite(expr) + if suite: + suites.append(suite) + elif isinstance(head, Symbol) and head.name == "define": + preamble.append(expr) + + return suites, preamble + + +def _parse_suite(expr: list) -> dict | None: + """Parse a (defsuite "name" ...) form.""" + if len(expr) < 2: + return None + + name = expr[1] + if not isinstance(name, str): + return None + + tests = [] + for child in expr[2:]: + if not isinstance(child, list) or not child: + continue + head = child[0] + if isinstance(head, Symbol): + if head.name == "deftest": + test = _parse_test(child) + if test: + tests.append(test) + elif head.name == "defsuite": + sub = _parse_suite(child) + if sub: + tests.append(sub) + + return {"type": "suite", "name": name, "tests": tests} + + +def _parse_test(expr: list) -> dict | None: + """Parse a (deftest "name" body ...) form.""" + if len(expr) < 3: + return None + name = expr[1] + if not isinstance(name, str): + return None + body = expr[2:] + return {"type": "test", "name": name, "body": body} + + +def _emit_py(suites: list[dict], preamble: list) -> str: + """Emit a pytest module from parsed suites.""" + # Serialize preamble (assertion helpers) as SX source + preamble_sx = "\n".join(_sx_to_source(expr) for expr in preamble) + preamble_escaped = preamble_sx.replace('\\', '\\\\').replace("'", "\\'") + + lines = [] + lines.append('"""Auto-generated from test.sx — SX spec self-tests.') + lines.append('') + lines.append('DO NOT EDIT. Regenerate with:') + lines.append(' python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py') + lines.append('"""') + lines.append('from __future__ import annotations') + lines.append('') + lines.append('import pytest') + lines.append('from shared.sx.parser import parse_all') + lines.append('from shared.sx.evaluator import _eval, _trampoline') + lines.append('') + lines.append('') + lines.append(f"_PREAMBLE = '''{preamble_escaped}'''") + lines.append('') + lines.append('') + lines.append('def _make_env() -> dict:') + lines.append(' """Create a fresh env with assertion helpers loaded."""') + lines.append(' env = {}') + lines.append(' for expr in parse_all(_PREAMBLE):') + lines.append(' _trampoline(_eval(expr, env))') + lines.append(' return env') + lines.append('') + lines.append('') + lines.append('def _run(sx_source: str, env: dict | None = None) -> object:') + lines.append(' """Evaluate SX source and return the result."""') + lines.append(' if env is None:') + lines.append(' env = _make_env()') + lines.append(' exprs = parse_all(sx_source)') + lines.append(' result = None') + lines.append(' for expr in exprs:') + lines.append(' result = _trampoline(_eval(expr, env))') + lines.append(' return result') + lines.append('') + + for suite in suites: + _emit_suite(suite, lines, indent=0) + + return "\n".join(lines) + + +def _emit_suite(suite: dict, lines: list[str], indent: int): + """Emit a pytest class for a suite.""" + class_name = f"TestSpec{_slugify(suite['name']).title().replace('_', '')}" + pad = " " * indent + lines.append(f'{pad}class {class_name}:') + lines.append(f'{pad} """test.sx suite: {suite["name"]}"""') + lines.append('') + + for item in suite["tests"]: + if item["type"] == "test": + _emit_test(item, lines, indent + 1) + elif item["type"] == "suite": + _emit_suite(item, lines, indent + 1) + + lines.append('') + + +def _emit_test(test: dict, lines: list[str], indent: int): + """Emit a pytest test method.""" + method_name = f"test_{_slugify(test['name'])}" + pad = " " * indent + + # Convert body expressions to SX source + body_parts = [] + for expr in test["body"]: + body_parts.append(_sx_to_source(expr)) + + # Wrap in (do ...) if multiple expressions, or use single + if len(body_parts) == 1: + sx_source = body_parts[0] + else: + sx_source = "(do " + " ".join(body_parts) + ")" + + # Escape for Python string + sx_escaped = sx_source.replace('\\', '\\\\').replace("'", "\\'") + + lines.append(f"{pad}def {method_name}(self):") + lines.append(f"{pad} _run('{sx_escaped}')") + lines.append('') + + +def main(): + parser = argparse.ArgumentParser(description="Bootstrap test.sx to pytest") + parser.add_argument("--output", "-o", help="Output file path") + parser.add_argument("--dry-run", action="store_true", help="Print to stdout") + args = parser.parse_args() + + test_sx = os.path.join(_HERE, "test.sx") + suites, preamble = _parse_test_sx(test_sx) + + print(f"Parsed {len(suites)} suites, {len(preamble)} preamble defines from test.sx", file=sys.stderr) + total_tests = sum( + sum(1 for t in s["tests"] if t["type"] == "test") + for s in suites + ) + print(f"Total test cases: {total_tests}", file=sys.stderr) + + output = _emit_py(suites, preamble) + + if args.output and not args.dry_run: + with open(args.output, "w") as f: + f.write(output) + print(f"Wrote {args.output}", file=sys.stderr) + else: + print(output) + + +if __name__ == "__main__": + main() diff --git a/shared/sx/ref/bootstrap_test_js.py b/shared/sx/ref/bootstrap_test_js.py new file mode 100644 index 0000000..d468b28 --- /dev/null +++ b/shared/sx/ref/bootstrap_test_js.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Bootstrap compiler: test.sx -> Node.js test script. + +Reads test.sx and emits a standalone JavaScript file that runs each +deftest case using sx-browser.js's evaluator. Reports results as TAP. + +Uses the bootstrapped sx-browser.js (from the spec), NOT sx.js. + +Usage: + python bootstrap_test_js.py --output shared/sx/tests/test_sx_spec.js + node shared/sx/tests/test_sx_spec.js +""" +from __future__ import annotations + +import os +import sys +import tempfile +import argparse + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) +sys.path.insert(0, _PROJECT) + +from shared.sx.ref.bootstrap_test import _parse_test_sx, _sx_to_source, _slugify + + +def _js_escape(s: str) -> str: + """Escape a string for embedding in JS single-quoted string.""" + return s.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + + +def _emit_js(suites: list[dict], preamble: list) -> str: + """Emit a Node.js test script from parsed suites.""" + preamble_sx = "\\n".join(_js_escape(_sx_to_source(expr)) for expr in preamble) + + lines = [] + lines.append('// Auto-generated from test.sx — SX spec self-tests for JavaScript.') + lines.append('//') + lines.append('// DO NOT EDIT. Regenerate with:') + lines.append('// python shared/sx/ref/bootstrap_test_js.py --output shared/sx/tests/test_sx_spec.js') + lines.append('//') + lines.append('// Run:') + lines.append('// node shared/sx/tests/test_sx_spec.js') + lines.append('') + lines.append('// --- Load sx-browser.js (bootstrapped from spec) ---') + lines.append('Object.defineProperty(globalThis, "document", { value: undefined, writable: true });') + lines.append('var _sxPath = require("path").resolve(__dirname, "../../static/scripts/sx-browser.js");') + lines.append('var Sx = require(_sxPath);') + lines.append('') + lines.append('// --- Inject spec primitives into env ---') + lines.append('// sx-browser.js has assert but is missing some spec primitives.') + lines.append('// We inject them into the test env since PRIMITIVES is not exposed.') + lines.append('var NIL = Sx.NIL;') + lines.append('function _isNil(x) { return x === NIL || x === null || x === undefined; }') + lines.append('function _deepEqual(a, b) {') + lines.append(' if (a === b) return true;') + lines.append(' if (_isNil(a) && _isNil(b)) return true;') + lines.append(' if (typeof a !== typeof b) return false;') + lines.append(' if (Array.isArray(a) && Array.isArray(b)) {') + lines.append(' if (a.length !== b.length) return false;') + lines.append(' for (var i = 0; i < a.length; i++) if (!_deepEqual(a[i], b[i])) return false;') + lines.append(' return true;') + lines.append(' }') + lines.append(' if (a && typeof a === "object" && b && typeof b === "object") {') + lines.append(' var ka = Object.keys(a), kb = Object.keys(b);') + lines.append(' if (ka.length !== kb.length) return false;') + lines.append(' for (var j = 0; j < ka.length; j++) if (!_deepEqual(a[ka[j]], b[ka[j]])) return false;') + lines.append(' return true;') + lines.append(' }') + lines.append(' return false;') + lines.append('}') + lines.append('') + lines.append('// Primitives injected into every test env') + lines.append('var _envPrimitives = {') + lines.append(' "equal?": function(a, b) { return _deepEqual(a, b); },') + lines.append(' "eq?": function(a, b) { return a === b; },') + lines.append(' "boolean?": function(x) { return typeof x === "boolean"; },') + lines.append(' "string-length": function(s) { return String(s).length; },') + lines.append(' "substring": function(s, start, end) { return String(s).slice(start, end); },') + lines.append(' "string-contains?": function(s, needle) { return String(s).indexOf(needle) !== -1; },') + lines.append(' "upcase": function(s) { return String(s).toUpperCase(); },') + lines.append(' "downcase": function(s) { return String(s).toLowerCase(); },') + lines.append(' "reverse": function(c) { return c ? c.slice().reverse() : []; },') + lines.append(' "flatten": function(c) {') + lines.append(' var r = []; for (var i = 0; i < (c||[]).length; i++) {') + lines.append(' if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);') + lines.append(' else r.push(c[i]);') + lines.append(' } return r;') + lines.append(' },') + lines.append(' "has-key?": function(d, k) { return d && typeof d === "object" && k in d; },') + lines.append(' // Fix append to concatenate when x is a list') + lines.append(' "append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },') + lines.append('};') + lines.append('') + lines.append('// --- Test infrastructure ---') + lines.append('var _passed = 0, _failed = 0, _errors = [];') + lines.append('var _testNum = 0;') + lines.append('') + lines.append('function _makeEnv() {') + lines.append(' var env = {};') + lines.append(' // Copy injected primitives into env') + lines.append(' for (var k in _envPrimitives) env[k] = _envPrimitives[k];') + lines.append(f" var src = '{preamble_sx}';") + lines.append(' var exprs = Sx.parseAll(src);') + lines.append(' for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);') + lines.append(' return env;') + lines.append('}') + lines.append('') + lines.append('function _run(name, sxSource) {') + lines.append(' _testNum++;') + lines.append(' try {') + lines.append(' var env = _makeEnv();') + lines.append(' var exprs = Sx.parseAll(sxSource);') + lines.append(' for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);') + lines.append(' _passed++;') + lines.append(' console.log("ok " + _testNum + " - " + name);') + lines.append(' } catch (e) {') + lines.append(' _failed++;') + lines.append(' _errors.push({ name: name, error: e.message || String(e) });') + lines.append(' console.log("not ok " + _testNum + " - " + name);') + lines.append(' console.log(" # " + (e.message || String(e)));') + lines.append(' }') + lines.append('}') + lines.append('') + + # Count total tests + total = _count_tests(suites) + lines.append(f'console.log("TAP version 13");') + lines.append(f'console.log("1..{total}");') + lines.append('') + + # Emit test calls + for suite in suites: + _emit_js_suite(suite, lines, prefix="") + + lines.append('') + lines.append('// --- Summary ---') + lines.append('console.log("");') + lines.append('console.log("# tests " + (_passed + _failed));') + lines.append('console.log("# pass " + _passed);') + lines.append('if (_failed > 0) {') + lines.append(' console.log("# fail " + _failed);') + lines.append(' for (var ei = 0; ei < _errors.length; ei++) {') + lines.append(' console.log("# FAIL: " + _errors[ei].name + " — " + _errors[ei].error);') + lines.append(' }') + lines.append(' process.exit(1);') + lines.append('}') + + return "\n".join(lines) + + +def _count_tests(items: list[dict]) -> int: + total = 0 + for item in items: + if item["type"] == "test": + total += 1 + elif item["type"] == "suite": + total += _count_tests(item["tests"]) + return total + + +def _emit_js_suite(suite: dict, lines: list[str], prefix: str): + """Emit test calls for a suite.""" + suite_prefix = f"{prefix}{suite['name']} > " if prefix else f"{suite['name']} > " + lines.append(f'// --- {suite["name"]} ---') + for item in suite["tests"]: + if item["type"] == "test": + _emit_js_test(item, lines, suite_prefix) + elif item["type"] == "suite": + _emit_js_suite(item, lines, suite_prefix) + + +def _emit_js_test(test: dict, lines: list[str], prefix: str): + """Emit a single test call.""" + body_parts = [_sx_to_source(expr) for expr in test["body"]] + if len(body_parts) == 1: + sx_source = body_parts[0] + else: + sx_source = "(do " + " ".join(body_parts) + ")" + + name = f'{prefix}{test["name"]}' + lines.append(f"_run('{_js_escape(name)}', '{_js_escape(sx_source)}');") + + +def main(): + parser = argparse.ArgumentParser(description="Bootstrap test.sx to Node.js") + parser.add_argument("--output", "-o", help="Output file path") + parser.add_argument("--dry-run", action="store_true", help="Print to stdout") + args = parser.parse_args() + + test_sx = os.path.join(_HERE, "test.sx") + suites, preamble = _parse_test_sx(test_sx) + + print(f"Parsed {len(suites)} suites, {len(preamble)} preamble defines from test.sx", file=sys.stderr) + total = _count_tests(suites) + print(f"Total test cases: {total}", file=sys.stderr) + + output = _emit_js(suites, preamble) + + if args.output and not args.dry_run: + with open(args.output, "w") as f: + f.write(output) + print(f"Wrote {args.output}", file=sys.stderr) + else: + print(output) + + +if __name__ == "__main__": + main() diff --git a/shared/sx/ref/primitives.sx b/shared/sx/ref/primitives.sx index 6035a66..170037d 100644 --- a/shared/sx/ref/primitives.sx +++ b/shared/sx/ref/primitives.sx @@ -208,10 +208,16 @@ :returns "boolean" :doc "True if x is nil/null/None.") +(define-primitive "boolean?" + :params (x) + :returns "boolean" + :doc "True if x is a boolean (true or false). Must be checked before + number? on platforms where booleans are numeric subtypes.") + (define-primitive "number?" :params (x) :returns "boolean" - :doc "True if x is a number (int or float).") + :doc "True if x is a number (int or float). Excludes booleans.") (define-primitive "string?" :params (x) @@ -277,11 +283,36 @@ :returns "string" :doc "Uppercase string.") +(define-primitive "upcase" + :params (s) + :returns "string" + :doc "Alias for upper. Uppercase string.") + (define-primitive "lower" :params (s) :returns "string" :doc "Lowercase string.") +(define-primitive "downcase" + :params (s) + :returns "string" + :doc "Alias for lower. Lowercase string.") + +(define-primitive "string-length" + :params (s) + :returns "number" + :doc "Length of string in characters.") + +(define-primitive "substring" + :params (s start end) + :returns "string" + :doc "Extract substring from start (inclusive) to end (exclusive).") + +(define-primitive "string-contains?" + :params (s needle) + :returns "boolean" + :doc "True if string s contains substring needle.") + (define-primitive "trim" :params (s) :returns "string" @@ -382,13 +413,23 @@ (define-primitive "append" :params (coll x) :returns "list" - :doc "Append x to end of coll (returns new list).") + :doc "If x is a list, concatenate. Otherwise append x as single element.") (define-primitive "append!" :params (coll x) :returns "list" :doc "Mutate coll by appending x in-place. Returns coll.") +(define-primitive "reverse" + :params (coll) + :returns "list" + :doc "Return coll in reverse order.") + +(define-primitive "flatten" + :params (coll) + :returns "list" + :doc "Flatten one level of nesting. Nested lists become top-level elements.") + (define-primitive "chunk-every" :params (coll n) :returns "list" @@ -421,6 +462,11 @@ :returns "dict" :doc "Merge dicts left to right. Later keys win. Skips nil.") +(define-primitive "has-key?" + :params (d key) + :returns "boolean" + :doc "True if dict d contains key.") + (define-primitive "assoc" :params (d &rest pairs) :returns "dict" diff --git a/shared/sx/ref/test.sx b/shared/sx/ref/test.sx new file mode 100644 index 0000000..76d8d93 --- /dev/null +++ b/shared/sx/ref/test.sx @@ -0,0 +1,612 @@ +;; ========================================================================== +;; test.sx — Self-hosting SX test framework +;; +;; Defines a minimal test framework in SX that bootstraps to every host. +;; Tests are written in SX and verify SX semantics — the language tests +;; itself. Bootstrap compilers emit native test runners from this spec. +;; +;; The framework uses only primitives already in primitives.sx: +;; assert, equal?, type-of, str, list, len, error +;; +;; Usage: +;; (defsuite "Suite name" +;; (deftest "test name" (assert (equal? 1 1))) +;; (deftest "another" (assert (= (+ 1 2) 3)))) +;; +;; Platform functions required: +;; (test-report suite-name results) → platform-specific output +;; results is a list of {:name "..." :passed bool :error "..."|nil} +;; +;; Bootstrap compilers read this file and: +;; 1. Emit the test runner infrastructure +;; 2. Emit each test suite as native test cases +;; 3. Wire up to the platform test framework (pytest, Jest, etc.) +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; 1. Test framework forms +;; -------------------------------------------------------------------------- +;; +;; deftest and defsuite are declarative — bootstrap compilers parse them +;; and emit native test functions. They are NOT runtime macros. +;; +;; (deftest "name" body ...) +;; Declares a test case. Body expressions are evaluated sequentially. +;; The test passes if no assertion fails (no error is raised). +;; Uses `assert` primitive for assertions. +;; +;; (defsuite "name" test ...) +;; Groups tests into a named suite. Suites can be nested. + +;; (define-special-form "deftest" +;; :syntax (deftest name body ...) +;; :doc "Declare a test case. Name is a string literal. +;; Body expressions are evaluated in a fresh env extended from +;; the suite env. Test passes if all assertions succeed." +;; :example '(deftest "addition" (assert (= (+ 1 2) 3)))) +;; +;; (define-special-form "defsuite" +;; :syntax (defsuite name tests ...) +;; :doc "Declare a test suite. Name is a string. Tests are deftest forms +;; or nested defsuite forms." +;; :example '(defsuite "arithmetic" +;; (deftest "add" (assert (= (+ 1 2) 3))) +;; (deftest "sub" (assert (= (- 5 3) 2))))) + + +;; -------------------------------------------------------------------------- +;; 2. Assertion helpers — defined in SX, available in test bodies +;; -------------------------------------------------------------------------- +;; +;; These are regular functions (not special forms). They use the `assert` +;; primitive underneath but provide better error messages. + +(define assert-equal + (fn (expected actual) + (assert (equal? expected actual) + (str "Expected " (str expected) " but got " (str actual))))) + +(define assert-not-equal + (fn (a b) + (assert (not (equal? a b)) + (str "Expected values to differ but both are " (str a))))) + +(define assert-true + (fn (val) + (assert val (str "Expected truthy but got " (str val))))) + +(define assert-false + (fn (val) + (assert (not val) (str "Expected falsy but got " (str val))))) + +(define assert-nil + (fn (val) + (assert (nil? val) (str "Expected nil but got " (str val))))) + +(define assert-type + (fn (expected-type val) + ;; Implemented via predicate dispatch since type-of is a platform + ;; function not available in all hosts. Uses nested if to avoid + ;; Scheme-style cond detection for 2-element predicate calls. + ;; Boolean checked before number (subtypes on some platforms). + (let ((actual-type + (if (nil? val) "nil" + (if (boolean? val) "boolean" + (if (number? val) "number" + (if (string? val) "string" + (if (list? val) "list" + (if (dict? val) "dict" + "unknown")))))))) + (assert (= expected-type actual-type) + (str "Expected type " expected-type " but got " actual-type))))) + +(define assert-length + (fn (expected-len col) + (assert (= (len col) expected-len) + (str "Expected length " expected-len " but got " (len col))))) + +(define assert-contains + (fn (item col) + (assert (some (fn (x) (equal? x item)) col) + (str "Expected collection to contain " (str item))))) + +(define assert-throws + (fn (thunk) + ;; Platform must implement try-catch or equivalent. + ;; Bootstrap compilers emit this as a native try/catch block. + (platform-assert-throws thunk))) + + +;; ========================================================================== +;; 3. Test suites — SX testing SX +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; 3a. Literals and types +;; -------------------------------------------------------------------------- + +(defsuite "literals" + (deftest "numbers are numbers" + (assert-type "number" 42) + (assert-type "number" 3.14) + (assert-type "number" -1)) + + (deftest "strings are strings" + (assert-type "string" "hello") + (assert-type "string" "")) + + (deftest "booleans are booleans" + (assert-type "boolean" true) + (assert-type "boolean" false)) + + (deftest "nil is nil" + (assert-type "nil" nil) + (assert-nil nil)) + + (deftest "lists are lists" + (assert-type "list" (list 1 2 3)) + (assert-type "list" (list))) + + (deftest "dicts are dicts" + (assert-type "dict" {:a 1 :b 2}))) + + +;; -------------------------------------------------------------------------- +;; 3b. Arithmetic +;; -------------------------------------------------------------------------- + +(defsuite "arithmetic" + (deftest "addition" + (assert-equal 3 (+ 1 2)) + (assert-equal 0 (+ 0 0)) + (assert-equal -1 (+ 1 -2)) + (assert-equal 10 (+ 1 2 3 4))) + + (deftest "subtraction" + (assert-equal 1 (- 3 2)) + (assert-equal -1 (- 2 3))) + + (deftest "multiplication" + (assert-equal 6 (* 2 3)) + (assert-equal 0 (* 0 100)) + (assert-equal 24 (* 1 2 3 4))) + + (deftest "division" + (assert-equal 2 (/ 6 3)) + (assert-equal 2.5 (/ 5 2))) + + (deftest "modulo" + (assert-equal 1 (mod 7 3)) + (assert-equal 0 (mod 6 3)))) + + +;; -------------------------------------------------------------------------- +;; 3c. Comparison +;; -------------------------------------------------------------------------- + +(defsuite "comparison" + (deftest "equality" + (assert-true (= 1 1)) + (assert-false (= 1 2)) + (assert-true (= "a" "a")) + (assert-false (= "a" "b"))) + + (deftest "deep equality" + (assert-true (equal? (list 1 2 3) (list 1 2 3))) + (assert-false (equal? (list 1 2) (list 1 3))) + (assert-true (equal? {:a 1} {:a 1})) + (assert-false (equal? {:a 1} {:a 2}))) + + (deftest "ordering" + (assert-true (< 1 2)) + (assert-false (< 2 1)) + (assert-true (> 2 1)) + (assert-true (<= 1 1)) + (assert-true (<= 1 2)) + (assert-true (>= 2 2)) + (assert-true (>= 3 2)))) + + +;; -------------------------------------------------------------------------- +;; 3d. String operations +;; -------------------------------------------------------------------------- + +(defsuite "strings" + (deftest "str concatenation" + (assert-equal "abc" (str "a" "b" "c")) + (assert-equal "hello world" (str "hello" " " "world")) + (assert-equal "42" (str 42)) + (assert-equal "" (str))) + + (deftest "string-length" + (assert-equal 5 (string-length "hello")) + (assert-equal 0 (string-length ""))) + + (deftest "substring" + (assert-equal "ell" (substring "hello" 1 4)) + (assert-equal "hello" (substring "hello" 0 5))) + + (deftest "string-contains?" + (assert-true (string-contains? "hello world" "world")) + (assert-false (string-contains? "hello" "xyz"))) + + (deftest "upcase and downcase" + (assert-equal "HELLO" (upcase "hello")) + (assert-equal "hello" (downcase "HELLO"))) + + (deftest "trim" + (assert-equal "hello" (trim " hello ")) + (assert-equal "hello" (trim "hello"))) + + (deftest "split and join" + (assert-equal (list "a" "b" "c") (split "a,b,c" ",")) + (assert-equal "a-b-c" (join "-" (list "a" "b" "c"))))) + + +;; -------------------------------------------------------------------------- +;; 3e. List operations +;; -------------------------------------------------------------------------- + +(defsuite "lists" + (deftest "constructors" + (assert-equal (list 1 2 3) (list 1 2 3)) + (assert-equal (list) (list)) + (assert-length 3 (list 1 2 3))) + + (deftest "first and rest" + (assert-equal 1 (first (list 1 2 3))) + (assert-equal (list 2 3) (rest (list 1 2 3))) + (assert-nil (first (list))) + (assert-equal (list) (rest (list)))) + + (deftest "nth" + (assert-equal 1 (nth (list 1 2 3) 0)) + (assert-equal 2 (nth (list 1 2 3) 1)) + (assert-equal 3 (nth (list 1 2 3) 2))) + + (deftest "last" + (assert-equal 3 (last (list 1 2 3))) + (assert-nil (last (list)))) + + (deftest "cons and append" + (assert-equal (list 0 1 2) (cons 0 (list 1 2))) + (assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4)))) + + (deftest "reverse" + (assert-equal (list 3 2 1) (reverse (list 1 2 3))) + (assert-equal (list) (reverse (list)))) + + (deftest "empty?" + (assert-true (empty? (list))) + (assert-false (empty? (list 1)))) + + (deftest "len" + (assert-equal 0 (len (list))) + (assert-equal 3 (len (list 1 2 3)))) + + (deftest "contains?" + (assert-true (contains? (list 1 2 3) 2)) + (assert-false (contains? (list 1 2 3) 4))) + + (deftest "flatten" + (assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4)))))) + + +;; -------------------------------------------------------------------------- +;; 3f. Dict operations +;; -------------------------------------------------------------------------- + +(defsuite "dicts" + (deftest "dict literal" + (assert-type "dict" {:a 1 :b 2}) + (assert-equal 1 (get {:a 1} "a")) + (assert-equal 2 (get {:a 1 :b 2} "b"))) + + (deftest "assoc" + (assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2)) + (assert-equal {:a 99} (assoc {:a 1} "a" 99))) + + (deftest "dissoc" + (assert-equal {:b 2} (dissoc {:a 1 :b 2} "a"))) + + (deftest "keys and vals" + (let ((d {:a 1 :b 2})) + (assert-length 2 (keys d)) + (assert-length 2 (vals d)) + (assert-contains "a" (keys d)) + (assert-contains "b" (keys d)))) + + (deftest "has-key?" + (assert-true (has-key? {:a 1} "a")) + (assert-false (has-key? {:a 1} "b"))) + + (deftest "merge" + (assert-equal {:a 1 :b 2 :c 3} + (merge {:a 1 :b 2} {:c 3})) + (assert-equal {:a 99 :b 2} + (merge {:a 1 :b 2} {:a 99})))) + + +;; -------------------------------------------------------------------------- +;; 3g. Predicates +;; -------------------------------------------------------------------------- + +(defsuite "predicates" + (deftest "nil?" + (assert-true (nil? nil)) + (assert-false (nil? 0)) + (assert-false (nil? false)) + (assert-false (nil? ""))) + + (deftest "number?" + (assert-true (number? 42)) + (assert-true (number? 3.14)) + (assert-false (number? "42"))) + + (deftest "string?" + (assert-true (string? "hello")) + (assert-false (string? 42))) + + (deftest "list?" + (assert-true (list? (list 1 2))) + (assert-false (list? "not a list"))) + + (deftest "dict?" + (assert-true (dict? {:a 1})) + (assert-false (dict? (list 1)))) + + (deftest "boolean?" + (assert-true (boolean? true)) + (assert-true (boolean? false)) + (assert-false (boolean? nil)) + (assert-false (boolean? 0))) + + (deftest "not" + (assert-true (not false)) + (assert-true (not nil)) + (assert-false (not true)) + (assert-false (not 1)) + (assert-false (not "x")))) + + +;; -------------------------------------------------------------------------- +;; 3h. Special forms +;; -------------------------------------------------------------------------- + +(defsuite "special-forms" + (deftest "if" + (assert-equal "yes" (if true "yes" "no")) + (assert-equal "no" (if false "yes" "no")) + (assert-equal "no" (if nil "yes" "no")) + (assert-nil (if false "yes"))) + + (deftest "when" + (assert-equal "yes" (when true "yes")) + (assert-nil (when false "yes"))) + + (deftest "cond" + (assert-equal "a" (cond true "a" :else "b")) + (assert-equal "b" (cond false "a" :else "b")) + (assert-equal "c" (cond + false "a" + false "b" + :else "c"))) + + (deftest "and" + (assert-true (and true true)) + (assert-false (and true false)) + (assert-false (and false true)) + (assert-equal 3 (and 1 2 3))) + + (deftest "or" + (assert-equal 1 (or 1 2)) + (assert-equal 2 (or false 2)) + (assert-equal "fallback" (or nil false "fallback")) + (assert-false (or false false))) + + (deftest "let" + (assert-equal 3 (let ((x 1) (y 2)) (+ x y))) + (assert-equal "hello world" + (let ((a "hello") (b " world")) (str a b)))) + + (deftest "let clojure-style" + (assert-equal 3 (let (x 1 y 2) (+ x y)))) + + (deftest "do / begin" + (assert-equal 3 (do 1 2 3)) + (assert-equal "last" (begin "first" "middle" "last"))) + + (deftest "define" + (define x 42) + (assert-equal 42 x)) + + (deftest "set!" + (define x 1) + (set! x 2) + (assert-equal 2 x))) + + +;; -------------------------------------------------------------------------- +;; 3i. Lambda and closures +;; -------------------------------------------------------------------------- + +(defsuite "lambdas" + (deftest "basic lambda" + (let ((add (fn (a b) (+ a b)))) + (assert-equal 3 (add 1 2)))) + + (deftest "closure captures env" + (let ((x 10)) + (let ((add-x (fn (y) (+ x y)))) + (assert-equal 15 (add-x 5))))) + + (deftest "lambda as argument" + (assert-equal (list 2 4 6) + (map (fn (x) (* x 2)) (list 1 2 3)))) + + (deftest "recursive lambda via define" + (define factorial + (fn (n) (if (<= n 1) 1 (* n (factorial (- n 1)))))) + (assert-equal 120 (factorial 5))) + + (deftest "higher-order returns lambda" + (let ((make-adder (fn (n) (fn (x) (+ n x))))) + (let ((add5 (make-adder 5))) + (assert-equal 8 (add5 3)))))) + + +;; -------------------------------------------------------------------------- +;; 3j. Higher-order forms +;; -------------------------------------------------------------------------- + +(defsuite "higher-order" + (deftest "map" + (assert-equal (list 2 4 6) + (map (fn (x) (* x 2)) (list 1 2 3))) + (assert-equal (list) (map (fn (x) x) (list)))) + + (deftest "filter" + (assert-equal (list 2 4) + (filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4))) + (assert-equal (list) + (filter (fn (x) false) (list 1 2 3)))) + + (deftest "reduce" + (assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4))) + (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list)))) + + (deftest "some" + (assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5))) + (assert-false (some (fn (x) (> x 10)) (list 1 2 3)))) + + (deftest "every?" + (assert-true (every? (fn (x) (> x 0)) (list 1 2 3))) + (assert-false (every? (fn (x) (> x 2)) (list 1 2 3)))) + + (deftest "map-indexed" + (assert-equal (list "0:a" "1:b" "2:c") + (map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c"))))) + + +;; -------------------------------------------------------------------------- +;; 3k. Components +;; -------------------------------------------------------------------------- + +(defsuite "components" + (deftest "defcomp creates component" + (defcomp ~test-comp (&key title) + (div title)) + ;; Component is bound and not nil + (assert-true (not (nil? ~test-comp)))) + + (deftest "component renders with keyword args" + (defcomp ~greeting (&key name) + (span (str "Hello, " name "!"))) + (assert-true (not (nil? ~greeting)))) + + (deftest "component with children" + (defcomp ~box (&key &rest children) + (div :class "box" children)) + (assert-true (not (nil? ~box)))) + + (deftest "component with default via or" + (defcomp ~label (&key text) + (span (or text "default"))) + (assert-true (not (nil? ~label))))) + + +;; -------------------------------------------------------------------------- +;; 3l. Macros +;; -------------------------------------------------------------------------- + +(defsuite "macros" + (deftest "defmacro creates macro" + (defmacro unless (cond &rest body) + `(if (not ,cond) (do ,@body))) + (assert-equal "yes" (unless false "yes")) + (assert-nil (unless true "no"))) + + (deftest "quasiquote and unquote" + (let ((x 42)) + (assert-equal (list 1 42 3) `(1 ,x 3)))) + + (deftest "splice-unquote" + (let ((xs (list 2 3 4))) + (assert-equal (list 1 2 3 4 5) `(1 ,@xs 5))))) + + +;; -------------------------------------------------------------------------- +;; 3m. Threading macro +;; -------------------------------------------------------------------------- + +(defsuite "threading" + (deftest "thread-first" + (assert-equal 8 (-> 5 (+ 1) (+ 2))) + (assert-equal "HELLO" (-> "hello" upcase)) + (assert-equal "HELLO WORLD" + (-> "hello" + (str " world") + upcase)))) + + +;; -------------------------------------------------------------------------- +;; 3n. Truthiness +;; -------------------------------------------------------------------------- + +(defsuite "truthiness" + (deftest "truthy values" + (assert-true (if 1 true false)) + (assert-true (if "x" true false)) + (assert-true (if (list 1) true false)) + (assert-true (if true true false))) + + (deftest "falsy values" + (assert-false (if false true false)) + (assert-false (if nil true false))) + + ;; NOTE: empty list, zero, and empty string truthiness is + ;; platform-dependent. Python treats all three as falsy. + ;; JavaScript treats [] as truthy but 0 and "" as falsy. + ;; These tests are omitted — each bootstrapper should emit + ;; platform-specific truthiness tests instead. + ) + + +;; -------------------------------------------------------------------------- +;; 3o. Edge cases and regression tests +;; -------------------------------------------------------------------------- + +(defsuite "edge-cases" + (deftest "nested let scoping" + (let ((x 1)) + (let ((x 2)) + (assert-equal 2 x)) + ;; outer x should be unchanged by inner let + ;; (this tests that let creates a new scope) + )) + + (deftest "recursive map" + (assert-equal (list (list 2 4) (list 6 8)) + (map (fn (sub) (map (fn (x) (* x 2)) sub)) + (list (list 1 2) (list 3 4))))) + + (deftest "keyword as value" + (assert-equal "class" :class) + (assert-equal "id" :id)) + + (deftest "dict with evaluated values" + (let ((x 42)) + (assert-equal 42 (get {:val x} "val")))) + + (deftest "nil propagation" + (assert-nil (get {:a 1} "missing")) + (assert-equal "default" (or (get {:a 1} "missing") "default"))) + + (deftest "empty operations" + (assert-equal (list) (map (fn (x) x) (list))) + (assert-equal (list) (filter (fn (x) true) (list))) + (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))) + (assert-equal 0 (len (list))) + (assert-equal "" (str)))) diff --git a/shared/sx/tests/test_sx_spec.js b/shared/sx/tests/test_sx_spec.js new file mode 100644 index 0000000..e0ea372 --- /dev/null +++ b/shared/sx/tests/test_sx_spec.js @@ -0,0 +1,199 @@ +// Auto-generated from test.sx — SX spec self-tests for JavaScript. +// +// DO NOT EDIT. Regenerate with: +// python shared/sx/ref/bootstrap_test_js.py --output shared/sx/tests/test_sx_spec.js +// +// Run: +// node shared/sx/tests/test_sx_spec.js + +// --- Load sx-browser.js (bootstrapped from spec) --- +Object.defineProperty(globalThis, "document", { value: undefined, writable: true }); +var _sxPath = require("path").resolve(__dirname, "../../static/scripts/sx-browser.js"); +var Sx = require(_sxPath); + +// --- Inject spec primitives into env --- +// sx-browser.js has assert but is missing some spec primitives. +// We inject them into the test env since PRIMITIVES is not exposed. +var NIL = Sx.NIL; +function _isNil(x) { return x === 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; +} + +// Primitives injected into every test env +var _envPrimitives = { + "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; }, + // Fix append to concatenate when x is a list + "append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); }, +}; + +// --- Test infrastructure --- +var _passed = 0, _failed = 0, _errors = []; +var _testNum = 0; + +function _makeEnv() { + var env = {}; + // Copy injected primitives into env + for (var k in _envPrimitives) env[k] = _envPrimitives[k]; + var src = '(define assert-equal (fn (expected actual) (assert (equal? expected actual) (str "Expected " (str expected) " but got " (str actual)))))\n(define assert-not-equal (fn (a b) (assert (not (equal? a b)) (str "Expected values to differ but both are " (str a)))))\n(define assert-true (fn (val) (assert val (str "Expected truthy but got " (str val)))))\n(define assert-false (fn (val) (assert (not val) (str "Expected falsy but got " (str val)))))\n(define assert-nil (fn (val) (assert (nil? val) (str "Expected nil but got " (str val)))))\n(define assert-type (fn (expected-type val) (let ((actual-type (if (nil? val) "nil" (if (boolean? val) "boolean" (if (number? val) "number" (if (string? val) "string" (if (list? val) "list" (if (dict? val) "dict" "unknown")))))))) (assert (= expected-type actual-type) (str "Expected type " expected-type " but got " actual-type)))))\n(define assert-length (fn (expected-len col) (assert (= (len col) expected-len) (str "Expected length " expected-len " but got " (len col)))))\n(define assert-contains (fn (item col) (assert (some (fn (x) (equal? x item)) col) (str "Expected collection to contain " (str item)))))\n(define assert-throws (fn (thunk) (platform-assert-throws thunk)))'; + var exprs = Sx.parseAll(src); + for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env); + return env; +} + +function _run(name, sxSource) { + _testNum++; + try { + var env = _makeEnv(); + var exprs = Sx.parseAll(sxSource); + for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env); + _passed++; + console.log("ok " + _testNum + " - " + name); + } catch (e) { + _failed++; + _errors.push({ name: name, error: e.message || String(e) }); + console.log("not ok " + _testNum + " - " + name); + console.log(" # " + (e.message || String(e))); + } +} + +console.log("TAP version 13"); +console.log("1..81"); + +// --- literals --- +_run('literals > numbers are numbers', '(do (assert-type "number" 42) (assert-type "number" 3.14) (assert-type "number" -1))'); +_run('literals > strings are strings', '(do (assert-type "string" "hello") (assert-type "string" ""))'); +_run('literals > booleans are booleans', '(do (assert-type "boolean" true) (assert-type "boolean" false))'); +_run('literals > nil is nil', '(do (assert-type "nil" nil) (assert-nil nil))'); +_run('literals > lists are lists', '(do (assert-type "list" (list 1 2 3)) (assert-type "list" (list)))'); +_run('literals > dicts are dicts', '(assert-type "dict" {:a 1 :b 2})'); +// --- arithmetic --- +_run('arithmetic > addition', '(do (assert-equal 3 (+ 1 2)) (assert-equal 0 (+ 0 0)) (assert-equal -1 (+ 1 -2)) (assert-equal 10 (+ 1 2 3 4)))'); +_run('arithmetic > subtraction', '(do (assert-equal 1 (- 3 2)) (assert-equal -1 (- 2 3)))'); +_run('arithmetic > multiplication', '(do (assert-equal 6 (* 2 3)) (assert-equal 0 (* 0 100)) (assert-equal 24 (* 1 2 3 4)))'); +_run('arithmetic > division', '(do (assert-equal 2 (/ 6 3)) (assert-equal 2.5 (/ 5 2)))'); +_run('arithmetic > modulo', '(do (assert-equal 1 (mod 7 3)) (assert-equal 0 (mod 6 3)))'); +// --- comparison --- +_run('comparison > equality', '(do (assert-true (= 1 1)) (assert-false (= 1 2)) (assert-true (= "a" "a")) (assert-false (= "a" "b")))'); +_run('comparison > deep equality', '(do (assert-true (equal? (list 1 2 3) (list 1 2 3))) (assert-false (equal? (list 1 2) (list 1 3))) (assert-true (equal? {:a 1} {:a 1})) (assert-false (equal? {:a 1} {:a 2})))'); +_run('comparison > ordering', '(do (assert-true (< 1 2)) (assert-false (< 2 1)) (assert-true (> 2 1)) (assert-true (<= 1 1)) (assert-true (<= 1 2)) (assert-true (>= 2 2)) (assert-true (>= 3 2)))'); +// --- strings --- +_run('strings > str concatenation', '(do (assert-equal "abc" (str "a" "b" "c")) (assert-equal "hello world" (str "hello" " " "world")) (assert-equal "42" (str 42)) (assert-equal "" (str)))'); +_run('strings > string-length', '(do (assert-equal 5 (string-length "hello")) (assert-equal 0 (string-length "")))'); +_run('strings > substring', '(do (assert-equal "ell" (substring "hello" 1 4)) (assert-equal "hello" (substring "hello" 0 5)))'); +_run('strings > string-contains?', '(do (assert-true (string-contains? "hello world" "world")) (assert-false (string-contains? "hello" "xyz")))'); +_run('strings > upcase and downcase', '(do (assert-equal "HELLO" (upcase "hello")) (assert-equal "hello" (downcase "HELLO")))'); +_run('strings > trim', '(do (assert-equal "hello" (trim " hello ")) (assert-equal "hello" (trim "hello")))'); +_run('strings > split and join', '(do (assert-equal (list "a" "b" "c") (split "a,b,c" ",")) (assert-equal "a-b-c" (join "-" (list "a" "b" "c"))))'); +// --- lists --- +_run('lists > constructors', '(do (assert-equal (list 1 2 3) (list 1 2 3)) (assert-equal (list) (list)) (assert-length 3 (list 1 2 3)))'); +_run('lists > first and rest', '(do (assert-equal 1 (first (list 1 2 3))) (assert-equal (list 2 3) (rest (list 1 2 3))) (assert-nil (first (list))) (assert-equal (list) (rest (list))))'); +_run('lists > nth', '(do (assert-equal 1 (nth (list 1 2 3) 0)) (assert-equal 2 (nth (list 1 2 3) 1)) (assert-equal 3 (nth (list 1 2 3) 2)))'); +_run('lists > last', '(do (assert-equal 3 (last (list 1 2 3))) (assert-nil (last (list))))'); +_run('lists > cons and append', '(do (assert-equal (list 0 1 2) (cons 0 (list 1 2))) (assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))'); +_run('lists > reverse', '(do (assert-equal (list 3 2 1) (reverse (list 1 2 3))) (assert-equal (list) (reverse (list))))'); +_run('lists > empty?', '(do (assert-true (empty? (list))) (assert-false (empty? (list 1))))'); +_run('lists > len', '(do (assert-equal 0 (len (list))) (assert-equal 3 (len (list 1 2 3))))'); +_run('lists > contains?', '(do (assert-true (contains? (list 1 2 3) 2)) (assert-false (contains? (list 1 2 3) 4)))'); +_run('lists > flatten', '(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))'); +// --- dicts --- +_run('dicts > dict literal', '(do (assert-type "dict" {:a 1 :b 2}) (assert-equal 1 (get {:a 1} "a")) (assert-equal 2 (get {:a 1 :b 2} "b")))'); +_run('dicts > assoc', '(do (assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2)) (assert-equal {:a 99} (assoc {:a 1} "a" 99)))'); +_run('dicts > dissoc', '(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a"))'); +_run('dicts > keys and vals', '(let ((d {:a 1 :b 2})) (assert-length 2 (keys d)) (assert-length 2 (vals d)) (assert-contains "a" (keys d)) (assert-contains "b" (keys d)))'); +_run('dicts > has-key?', '(do (assert-true (has-key? {:a 1} "a")) (assert-false (has-key? {:a 1} "b")))'); +_run('dicts > merge', '(do (assert-equal {:a 1 :b 2 :c 3} (merge {:a 1 :b 2} {:c 3})) (assert-equal {:a 99 :b 2} (merge {:a 1 :b 2} {:a 99})))'); +// --- predicates --- +_run('predicates > nil?', '(do (assert-true (nil? nil)) (assert-false (nil? 0)) (assert-false (nil? false)) (assert-false (nil? "")))'); +_run('predicates > number?', '(do (assert-true (number? 42)) (assert-true (number? 3.14)) (assert-false (number? "42")))'); +_run('predicates > string?', '(do (assert-true (string? "hello")) (assert-false (string? 42)))'); +_run('predicates > list?', '(do (assert-true (list? (list 1 2))) (assert-false (list? "not a list")))'); +_run('predicates > dict?', '(do (assert-true (dict? {:a 1})) (assert-false (dict? (list 1))))'); +_run('predicates > boolean?', '(do (assert-true (boolean? true)) (assert-true (boolean? false)) (assert-false (boolean? nil)) (assert-false (boolean? 0)))'); +_run('predicates > not', '(do (assert-true (not false)) (assert-true (not nil)) (assert-false (not true)) (assert-false (not 1)) (assert-false (not "x")))'); +// --- special-forms --- +_run('special-forms > if', '(do (assert-equal "yes" (if true "yes" "no")) (assert-equal "no" (if false "yes" "no")) (assert-equal "no" (if nil "yes" "no")) (assert-nil (if false "yes")))'); +_run('special-forms > when', '(do (assert-equal "yes" (when true "yes")) (assert-nil (when false "yes")))'); +_run('special-forms > cond', '(do (assert-equal "a" (cond true "a" :else "b")) (assert-equal "b" (cond false "a" :else "b")) (assert-equal "c" (cond false "a" false "b" :else "c")))'); +_run('special-forms > and', '(do (assert-true (and true true)) (assert-false (and true false)) (assert-false (and false true)) (assert-equal 3 (and 1 2 3)))'); +_run('special-forms > or', '(do (assert-equal 1 (or 1 2)) (assert-equal 2 (or false 2)) (assert-equal "fallback" (or nil false "fallback")) (assert-false (or false false)))'); +_run('special-forms > let', '(do (assert-equal 3 (let ((x 1) (y 2)) (+ x y))) (assert-equal "hello world" (let ((a "hello") (b " world")) (str a b))))'); +_run('special-forms > let clojure-style', '(assert-equal 3 (let (x 1 y 2) (+ x y)))'); +_run('special-forms > do / begin', '(do (assert-equal 3 (do 1 2 3)) (assert-equal "last" (begin "first" "middle" "last")))'); +_run('special-forms > define', '(do (define x 42) (assert-equal 42 x))'); +_run('special-forms > set!', '(do (define x 1) (set! x 2) (assert-equal 2 x))'); +// --- lambdas --- +_run('lambdas > basic lambda', '(let ((add (fn (a b) (+ a b)))) (assert-equal 3 (add 1 2)))'); +_run('lambdas > closure captures env', '(let ((x 10)) (let ((add-x (fn (y) (+ x y)))) (assert-equal 15 (add-x 5))))'); +_run('lambdas > lambda as argument', '(assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3)))'); +_run('lambdas > recursive lambda via define', '(do (define factorial (fn (n) (if (<= n 1) 1 (* n (factorial (- n 1)))))) (assert-equal 120 (factorial 5)))'); +_run('lambdas > higher-order returns lambda', '(let ((make-adder (fn (n) (fn (x) (+ n x))))) (let ((add5 (make-adder 5))) (assert-equal 8 (add5 3))))'); +// --- higher-order --- +_run('higher-order > map', '(do (assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3))) (assert-equal (list) (map (fn (x) x) (list))))'); +_run('higher-order > filter', '(do (assert-equal (list 2 4) (filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4))) (assert-equal (list) (filter (fn (x) false) (list 1 2 3))))'); +_run('higher-order > reduce', '(do (assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))'); +_run('higher-order > some', '(do (assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5))) (assert-false (some (fn (x) (> x 10)) (list 1 2 3))))'); +_run('higher-order > every?', '(do (assert-true (every? (fn (x) (> x 0)) (list 1 2 3))) (assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))'); +_run('higher-order > map-indexed', '(assert-equal (list "0:a" "1:b" "2:c") (map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))'); +// --- components --- +_run('components > defcomp creates component', '(do (defcomp ~test-comp (&key title) (div title)) (assert-true (not (nil? ~test-comp))))'); +_run('components > component renders with keyword args', '(do (defcomp ~greeting (&key name) (span (str "Hello, " name "!"))) (assert-true (not (nil? ~greeting))))'); +_run('components > component with children', '(do (defcomp ~box (&key &rest children) (div :class "box" children)) (assert-true (not (nil? ~box))))'); +_run('components > component with default via or', '(do (defcomp ~label (&key text) (span (or text "default"))) (assert-true (not (nil? ~label))))'); +// --- macros --- +_run('macros > defmacro creates macro', '(do (defmacro unless (cond &rest body) (quasiquote (if (not (unquote cond)) (do (splice-unquote body))))) (assert-equal "yes" (unless false "yes")) (assert-nil (unless true "no")))'); +_run('macros > quasiquote and unquote', '(let ((x 42)) (assert-equal (list 1 42 3) (quasiquote (1 (unquote x) 3))))'); +_run('macros > splice-unquote', '(let ((xs (list 2 3 4))) (assert-equal (list 1 2 3 4 5) (quasiquote (1 (splice-unquote xs) 5))))'); +// --- threading --- +_run('threading > thread-first', '(do (assert-equal 8 (-> 5 (+ 1) (+ 2))) (assert-equal "HELLO" (-> "hello" upcase)) (assert-equal "HELLO WORLD" (-> "hello" (str " world") upcase)))'); +// --- truthiness --- +_run('truthiness > truthy values', '(do (assert-true (if 1 true false)) (assert-true (if "x" true false)) (assert-true (if (list 1) true false)) (assert-true (if true true false)))'); +_run('truthiness > falsy values', '(do (assert-false (if false true false)) (assert-false (if nil true false)))'); +// --- edge-cases --- +_run('edge-cases > nested let scoping', '(let ((x 1)) (let ((x 2)) (assert-equal 2 x)))'); +_run('edge-cases > recursive map', '(assert-equal (list (list 2 4) (list 6 8)) (map (fn (sub) (map (fn (x) (* x 2)) sub)) (list (list 1 2) (list 3 4))))'); +_run('edge-cases > keyword as value', '(do (assert-equal "class" :class) (assert-equal "id" :id))'); +_run('edge-cases > dict with evaluated values', '(let ((x 42)) (assert-equal 42 (get {:val x} "val")))'); +_run('edge-cases > nil propagation', '(do (assert-nil (get {:a 1} "missing")) (assert-equal "default" (or (get {:a 1} "missing") "default")))'); +_run('edge-cases > empty operations', '(do (assert-equal (list) (map (fn (x) x) (list))) (assert-equal (list) (filter (fn (x) true) (list))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))) (assert-equal 0 (len (list))) (assert-equal "" (str)))'); + +// --- Summary --- +console.log(""); +console.log("# tests " + (_passed + _failed)); +console.log("# pass " + _passed); +if (_failed > 0) { + console.log("# fail " + _failed); + for (var ei = 0; ei < _errors.length; ei++) { + console.log("# FAIL: " + _errors[ei].name + " — " + _errors[ei].error); + } + process.exit(1); +} \ No newline at end of file diff --git a/shared/sx/tests/test_sx_spec.py b/shared/sx/tests/test_sx_spec.py new file mode 100644 index 0000000..d5893a4 --- /dev/null +++ b/shared/sx/tests/test_sx_spec.py @@ -0,0 +1,343 @@ +"""Auto-generated from test.sx — SX spec self-tests. + +DO NOT EDIT. Regenerate with: + python shared/sx/ref/bootstrap_test.py --output shared/sx/tests/test_sx_spec.py +""" +from __future__ import annotations + +import pytest +from shared.sx.parser import parse_all +from shared.sx.evaluator import _eval, _trampoline + + +_PREAMBLE = '''(define assert-equal (fn (expected actual) (assert (equal? expected actual) (str "Expected " (str expected) " but got " (str actual))))) +(define assert-not-equal (fn (a b) (assert (not (equal? a b)) (str "Expected values to differ but both are " (str a))))) +(define assert-true (fn (val) (assert val (str "Expected truthy but got " (str val))))) +(define assert-false (fn (val) (assert (not val) (str "Expected falsy but got " (str val))))) +(define assert-nil (fn (val) (assert (nil? val) (str "Expected nil but got " (str val))))) +(define assert-type (fn (expected-type val) (let ((actual-type (if (nil? val) "nil" (if (boolean? val) "boolean" (if (number? val) "number" (if (string? val) "string" (if (list? val) "list" (if (dict? val) "dict" "unknown")))))))) (assert (= expected-type actual-type) (str "Expected type " expected-type " but got " actual-type))))) +(define assert-length (fn (expected-len col) (assert (= (len col) expected-len) (str "Expected length " expected-len " but got " (len col))))) +(define assert-contains (fn (item col) (assert (some (fn (x) (equal? x item)) col) (str "Expected collection to contain " (str item))))) +(define assert-throws (fn (thunk) (platform-assert-throws thunk)))''' + + +def _make_env() -> dict: + """Create a fresh env with assertion helpers loaded.""" + env = {} + for expr in parse_all(_PREAMBLE): + _trampoline(_eval(expr, env)) + return env + + +def _run(sx_source: str, env: dict | None = None) -> object: + """Evaluate SX source and return the result.""" + if env is None: + env = _make_env() + exprs = parse_all(sx_source) + result = None + for expr in exprs: + result = _trampoline(_eval(expr, env)) + return result + +class TestSpecLiterals: + """test.sx suite: literals""" + + def test_numbers_are_numbers(self): + _run('(do (assert-type "number" 42) (assert-type "number" 3.14) (assert-type "number" -1))') + + def test_strings_are_strings(self): + _run('(do (assert-type "string" "hello") (assert-type "string" ""))') + + def test_booleans_are_booleans(self): + _run('(do (assert-type "boolean" true) (assert-type "boolean" false))') + + def test_nil_is_nil(self): + _run('(do (assert-type "nil" nil) (assert-nil nil))') + + def test_lists_are_lists(self): + _run('(do (assert-type "list" (list 1 2 3)) (assert-type "list" (list)))') + + def test_dicts_are_dicts(self): + _run('(assert-type "dict" {:a 1 :b 2})') + + +class TestSpecArithmetic: + """test.sx suite: arithmetic""" + + def test_addition(self): + _run('(do (assert-equal 3 (+ 1 2)) (assert-equal 0 (+ 0 0)) (assert-equal -1 (+ 1 -2)) (assert-equal 10 (+ 1 2 3 4)))') + + def test_subtraction(self): + _run('(do (assert-equal 1 (- 3 2)) (assert-equal -1 (- 2 3)))') + + def test_multiplication(self): + _run('(do (assert-equal 6 (* 2 3)) (assert-equal 0 (* 0 100)) (assert-equal 24 (* 1 2 3 4)))') + + def test_division(self): + _run('(do (assert-equal 2 (/ 6 3)) (assert-equal 2.5 (/ 5 2)))') + + def test_modulo(self): + _run('(do (assert-equal 1 (mod 7 3)) (assert-equal 0 (mod 6 3)))') + + +class TestSpecComparison: + """test.sx suite: comparison""" + + def test_equality(self): + _run('(do (assert-true (= 1 1)) (assert-false (= 1 2)) (assert-true (= "a" "a")) (assert-false (= "a" "b")))') + + def test_deep_equality(self): + _run('(do (assert-true (equal? (list 1 2 3) (list 1 2 3))) (assert-false (equal? (list 1 2) (list 1 3))) (assert-true (equal? {:a 1} {:a 1})) (assert-false (equal? {:a 1} {:a 2})))') + + def test_ordering(self): + _run('(do (assert-true (< 1 2)) (assert-false (< 2 1)) (assert-true (> 2 1)) (assert-true (<= 1 1)) (assert-true (<= 1 2)) (assert-true (>= 2 2)) (assert-true (>= 3 2)))') + + +class TestSpecStrings: + """test.sx suite: strings""" + + def test_str_concatenation(self): + _run('(do (assert-equal "abc" (str "a" "b" "c")) (assert-equal "hello world" (str "hello" " " "world")) (assert-equal "42" (str 42)) (assert-equal "" (str)))') + + def test_string_length(self): + _run('(do (assert-equal 5 (string-length "hello")) (assert-equal 0 (string-length "")))') + + def test_substring(self): + _run('(do (assert-equal "ell" (substring "hello" 1 4)) (assert-equal "hello" (substring "hello" 0 5)))') + + def test_string_contains(self): + _run('(do (assert-true (string-contains? "hello world" "world")) (assert-false (string-contains? "hello" "xyz")))') + + def test_upcase_and_downcase(self): + _run('(do (assert-equal "HELLO" (upcase "hello")) (assert-equal "hello" (downcase "HELLO")))') + + def test_trim(self): + _run('(do (assert-equal "hello" (trim " hello ")) (assert-equal "hello" (trim "hello")))') + + def test_split_and_join(self): + _run('(do (assert-equal (list "a" "b" "c") (split "a,b,c" ",")) (assert-equal "a-b-c" (join "-" (list "a" "b" "c"))))') + + +class TestSpecLists: + """test.sx suite: lists""" + + def test_constructors(self): + _run('(do (assert-equal (list 1 2 3) (list 1 2 3)) (assert-equal (list) (list)) (assert-length 3 (list 1 2 3)))') + + def test_first_and_rest(self): + _run('(do (assert-equal 1 (first (list 1 2 3))) (assert-equal (list 2 3) (rest (list 1 2 3))) (assert-nil (first (list))) (assert-equal (list) (rest (list))))') + + def test_nth(self): + _run('(do (assert-equal 1 (nth (list 1 2 3) 0)) (assert-equal 2 (nth (list 1 2 3) 1)) (assert-equal 3 (nth (list 1 2 3) 2)))') + + def test_last(self): + _run('(do (assert-equal 3 (last (list 1 2 3))) (assert-nil (last (list))))') + + def test_cons_and_append(self): + _run('(do (assert-equal (list 0 1 2) (cons 0 (list 1 2))) (assert-equal (list 1 2 3 4) (append (list 1 2) (list 3 4))))') + + def test_reverse(self): + _run('(do (assert-equal (list 3 2 1) (reverse (list 1 2 3))) (assert-equal (list) (reverse (list))))') + + def test_empty(self): + _run('(do (assert-true (empty? (list))) (assert-false (empty? (list 1))))') + + def test_len(self): + _run('(do (assert-equal 0 (len (list))) (assert-equal 3 (len (list 1 2 3))))') + + def test_contains(self): + _run('(do (assert-true (contains? (list 1 2 3) 2)) (assert-false (contains? (list 1 2 3) 4)))') + + def test_flatten(self): + _run('(assert-equal (list 1 2 3 4) (flatten (list (list 1 2) (list 3 4))))') + + +class TestSpecDicts: + """test.sx suite: dicts""" + + def test_dict_literal(self): + _run('(do (assert-type "dict" {:a 1 :b 2}) (assert-equal 1 (get {:a 1} "a")) (assert-equal 2 (get {:a 1 :b 2} "b")))') + + def test_assoc(self): + _run('(do (assert-equal {:a 1 :b 2} (assoc {:a 1} "b" 2)) (assert-equal {:a 99} (assoc {:a 1} "a" 99)))') + + def test_dissoc(self): + _run('(assert-equal {:b 2} (dissoc {:a 1 :b 2} "a"))') + + def test_keys_and_vals(self): + _run('(let ((d {:a 1 :b 2})) (assert-length 2 (keys d)) (assert-length 2 (vals d)) (assert-contains "a" (keys d)) (assert-contains "b" (keys d)))') + + def test_has_key(self): + _run('(do (assert-true (has-key? {:a 1} "a")) (assert-false (has-key? {:a 1} "b")))') + + def test_merge(self): + _run('(do (assert-equal {:a 1 :b 2 :c 3} (merge {:a 1 :b 2} {:c 3})) (assert-equal {:a 99 :b 2} (merge {:a 1 :b 2} {:a 99})))') + + +class TestSpecPredicates: + """test.sx suite: predicates""" + + def test_nil(self): + _run('(do (assert-true (nil? nil)) (assert-false (nil? 0)) (assert-false (nil? false)) (assert-false (nil? "")))') + + def test_number(self): + _run('(do (assert-true (number? 42)) (assert-true (number? 3.14)) (assert-false (number? "42")))') + + def test_string(self): + _run('(do (assert-true (string? "hello")) (assert-false (string? 42)))') + + def test_list(self): + _run('(do (assert-true (list? (list 1 2))) (assert-false (list? "not a list")))') + + def test_dict(self): + _run('(do (assert-true (dict? {:a 1})) (assert-false (dict? (list 1))))') + + def test_boolean(self): + _run('(do (assert-true (boolean? true)) (assert-true (boolean? false)) (assert-false (boolean? nil)) (assert-false (boolean? 0)))') + + def test_not(self): + _run('(do (assert-true (not false)) (assert-true (not nil)) (assert-false (not true)) (assert-false (not 1)) (assert-false (not "x")))') + + +class TestSpecSpecialForms: + """test.sx suite: special-forms""" + + def test_if(self): + _run('(do (assert-equal "yes" (if true "yes" "no")) (assert-equal "no" (if false "yes" "no")) (assert-equal "no" (if nil "yes" "no")) (assert-nil (if false "yes")))') + + def test_when(self): + _run('(do (assert-equal "yes" (when true "yes")) (assert-nil (when false "yes")))') + + def test_cond(self): + _run('(do (assert-equal "a" (cond true "a" :else "b")) (assert-equal "b" (cond false "a" :else "b")) (assert-equal "c" (cond false "a" false "b" :else "c")))') + + def test_and(self): + _run('(do (assert-true (and true true)) (assert-false (and true false)) (assert-false (and false true)) (assert-equal 3 (and 1 2 3)))') + + def test_or(self): + _run('(do (assert-equal 1 (or 1 2)) (assert-equal 2 (or false 2)) (assert-equal "fallback" (or nil false "fallback")) (assert-false (or false false)))') + + def test_let(self): + _run('(do (assert-equal 3 (let ((x 1) (y 2)) (+ x y))) (assert-equal "hello world" (let ((a "hello") (b " world")) (str a b))))') + + def test_let_clojure_style(self): + _run('(assert-equal 3 (let (x 1 y 2) (+ x y)))') + + def test_do_begin(self): + _run('(do (assert-equal 3 (do 1 2 3)) (assert-equal "last" (begin "first" "middle" "last")))') + + def test_define(self): + _run('(do (define x 42) (assert-equal 42 x))') + + def test_set(self): + _run('(do (define x 1) (set! x 2) (assert-equal 2 x))') + + +class TestSpecLambdas: + """test.sx suite: lambdas""" + + def test_basic_lambda(self): + _run('(let ((add (fn (a b) (+ a b)))) (assert-equal 3 (add 1 2)))') + + def test_closure_captures_env(self): + _run('(let ((x 10)) (let ((add-x (fn (y) (+ x y)))) (assert-equal 15 (add-x 5))))') + + def test_lambda_as_argument(self): + _run('(assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3)))') + + def test_recursive_lambda_via_define(self): + _run('(do (define factorial (fn (n) (if (<= n 1) 1 (* n (factorial (- n 1)))))) (assert-equal 120 (factorial 5)))') + + def test_higher_order_returns_lambda(self): + _run('(let ((make-adder (fn (n) (fn (x) (+ n x))))) (let ((add5 (make-adder 5))) (assert-equal 8 (add5 3))))') + + +class TestSpecHigherOrder: + """test.sx suite: higher-order""" + + def test_map(self): + _run('(do (assert-equal (list 2 4 6) (map (fn (x) (* x 2)) (list 1 2 3))) (assert-equal (list) (map (fn (x) x) (list))))') + + def test_filter(self): + _run('(do (assert-equal (list 2 4) (filter (fn (x) (= (mod x 2) 0)) (list 1 2 3 4))) (assert-equal (list) (filter (fn (x) false) (list 1 2 3))))') + + def test_reduce(self): + _run('(do (assert-equal 10 (reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3 4))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))))') + + def test_some(self): + _run('(do (assert-true (some (fn (x) (> x 3)) (list 1 2 3 4 5))) (assert-false (some (fn (x) (> x 10)) (list 1 2 3))))') + + def test_every(self): + _run('(do (assert-true (every? (fn (x) (> x 0)) (list 1 2 3))) (assert-false (every? (fn (x) (> x 2)) (list 1 2 3))))') + + def test_map_indexed(self): + _run('(assert-equal (list "0:a" "1:b" "2:c") (map-indexed (fn (i x) (str i ":" x)) (list "a" "b" "c")))') + + +class TestSpecComponents: + """test.sx suite: components""" + + def test_defcomp_creates_component(self): + _run('(do (defcomp ~test-comp (&key title) (div title)) (assert-true (not (nil? ~test-comp))))') + + def test_component_renders_with_keyword_args(self): + _run('(do (defcomp ~greeting (&key name) (span (str "Hello, " name "!"))) (assert-true (not (nil? ~greeting))))') + + def test_component_with_children(self): + _run('(do (defcomp ~box (&key &rest children) (div :class "box" children)) (assert-true (not (nil? ~box))))') + + def test_component_with_default_via_or(self): + _run('(do (defcomp ~label (&key text) (span (or text "default"))) (assert-true (not (nil? ~label))))') + + +class TestSpecMacros: + """test.sx suite: macros""" + + def test_defmacro_creates_macro(self): + _run('(do (defmacro unless (cond &rest body) (quasiquote (if (not (unquote cond)) (do (splice-unquote body))))) (assert-equal "yes" (unless false "yes")) (assert-nil (unless true "no")))') + + def test_quasiquote_and_unquote(self): + _run('(let ((x 42)) (assert-equal (list 1 42 3) (quasiquote (1 (unquote x) 3))))') + + def test_splice_unquote(self): + _run('(let ((xs (list 2 3 4))) (assert-equal (list 1 2 3 4 5) (quasiquote (1 (splice-unquote xs) 5))))') + + +class TestSpecThreading: + """test.sx suite: threading""" + + def test_thread_first(self): + _run('(do (assert-equal 8 (-> 5 (+ 1) (+ 2))) (assert-equal "HELLO" (-> "hello" upcase)) (assert-equal "HELLO WORLD" (-> "hello" (str " world") upcase)))') + + +class TestSpecTruthiness: + """test.sx suite: truthiness""" + + def test_truthy_values(self): + _run('(do (assert-true (if 1 true false)) (assert-true (if "x" true false)) (assert-true (if (list 1) true false)) (assert-true (if true true false)))') + + def test_falsy_values(self): + _run('(do (assert-false (if false true false)) (assert-false (if nil true false)))') + + +class TestSpecEdgeCases: + """test.sx suite: edge-cases""" + + def test_nested_let_scoping(self): + _run('(let ((x 1)) (let ((x 2)) (assert-equal 2 x)))') + + def test_recursive_map(self): + _run('(assert-equal (list (list 2 4) (list 6 8)) (map (fn (sub) (map (fn (x) (* x 2)) sub)) (list (list 1 2) (list 3 4))))') + + def test_keyword_as_value(self): + _run('(do (assert-equal "class" :class) (assert-equal "id" :id))') + + def test_dict_with_evaluated_values(self): + _run('(let ((x 42)) (assert-equal 42 (get {:val x} "val")))') + + def test_nil_propagation(self): + _run('(do (assert-nil (get {:a 1} "missing")) (assert-equal "default" (or (get {:a 1} "missing") "default")))') + + def test_empty_operations(self): + _run('(do (assert-equal (list) (map (fn (x) x) (list))) (assert-equal (list) (filter (fn (x) true) (list))) (assert-equal 0 (reduce (fn (acc x) (+ acc x)) 0 (list))) (assert-equal 0 (len (list))) (assert-equal "" (str)))') + diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index d16901b..36f1e02 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -101,7 +101,8 @@ (dict :label "Continuations" :href "/specs/continuations") (dict :label "call/cc" :href "/specs/callcc") (dict :label "Deps" :href "/specs/deps") - (dict :label "Router" :href "/specs/router"))) + (dict :label "Router" :href "/specs/router") + (dict :label "Testing" :href "/specs/testing"))) (define isomorphism-nav-items (list (dict :label "Roadmap" :href "/isomorphism/") @@ -186,7 +187,10 @@ :prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.") (dict :slug "router" :filename "router.sx" :title "Router" :desc "Client-side route matching — Flask-style pattern parsing, segment matching, route table search." - :prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router."))) + :prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.") + (dict :slug "testing" :filename "test.sx" :title "Testing" + :desc "Self-hosting test framework — SX tests SX. Bootstraps to pytest and Node.js TAP." + :prose "The test spec defines a minimal test framework in SX that bootstraps to every host. Tests are written in SX and verify SX semantics — the language tests itself. The framework uses only primitives already in primitives.sx (assert, equal?, type-of, str, list, len) plus assertion helpers defined in SX (assert-equal, assert-true, assert-false, assert-nil, assert-type, assert-length, assert-contains). Two bootstrap compilers read test.sx and emit native test files: bootstrap_test.py produces a pytest module, bootstrap_test_js.py produces a Node.js TAP script. The same 81 tests run on both platforms, verifying cross-host parity."))) (define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items))))) diff --git a/sx/sx/testing.sx b/sx/sx/testing.sx new file mode 100644 index 0000000..eb20dff --- /dev/null +++ b/sx/sx/testing.sx @@ -0,0 +1,194 @@ +;; Testing spec page — SX tests SX. + +(defcomp ~spec-testing-content (&key spec-source) + (~doc-page :title "Testing" + (div :class "space-y-8" + + ;; Intro + (div :class "space-y-4" + (p :class "text-lg text-stone-600" + "SX tests itself. The test spec is written in SX and defines a complete test framework — assertion helpers, test suites, and 81 test cases covering every language feature. Bootstrap compilers read " + (code :class "text-violet-700 text-sm" "test.sx") + " and emit native test runners for each host platform.") + (p :class "text-stone-600" + "This is not a test " + (em "of") " SX — it is a test " (em "in") " SX. The same s-expressions that define how " + (code :class "text-violet-700 text-sm" "if") + " works are used to verify that " + (code :class "text-violet-700 text-sm" "if") + " works. The language proves its own correctness, on every platform it compiles to.")) + + ;; How it works + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Architecture") + (div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl" + (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words font-mono text-stone-700" +"test.sx The spec: assertion helpers + 15 suites + 81 tests + | + |--- bootstrap_test.py Reads test.sx, emits pytest module + | | + | +-> test_sx_spec.py 81 pytest test cases + | | + | +-> shared/sx/evaluator.py (Python SX evaluator) + | + |--- bootstrap_test_js.py Reads test.sx, emits Node.js TAP script + | + +-> test_sx_spec.js 81 TAP test cases + | + +-> sx-browser.js (JS SX evaluator)"))) + + ;; Framework + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "The test framework") + (p :class "text-stone-600" + "The framework defines two declarative forms and nine assertion helpers, all in pure SX:") + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Forms") + (~doc-code :code + (highlight "(defsuite \"name\" ...tests)\n(deftest \"name\" ...body)" "lisp"))) + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Assertion helpers") + (~doc-code :code + (highlight "(define assert-equal\n (fn (expected actual)\n (assert (equal? expected actual)\n (str \"Expected \" (str expected) \" but got \" (str actual)))))\n\n(define assert-true (fn (val) (assert val ...)))\n(define assert-false (fn (val) (assert (not val) ...)))\n(define assert-nil (fn (val) (assert (nil? val) ...)))\n(define assert-type (fn (expected-type val) ...))\n(define assert-length (fn (expected-len col) ...))\n(define assert-contains (fn (item col) ...))" "lisp")))) + + ;; Example tests + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Example: SX testing SX") + (p :class "text-stone-600" + "The test suites cover every language feature. Here is the arithmetic suite testing the evaluator's arithmetic primitives:") + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "From test.sx") + (~doc-code :code + (highlight "(defsuite \"arithmetic\"\n (deftest \"addition\"\n (assert-equal 3 (+ 1 2))\n (assert-equal 0 (+ 0 0))\n (assert-equal -1 (+ 1 -2))\n (assert-equal 10 (+ 1 2 3 4)))\n\n (deftest \"subtraction\"\n (assert-equal 1 (- 3 2))\n (assert-equal -1 (- 2 3)))\n\n (deftest \"multiplication\"\n (assert-equal 6 (* 2 3))\n (assert-equal 0 (* 0 100))\n (assert-equal 24 (* 1 2 3 4)))\n\n (deftest \"division\"\n (assert-equal 2 (/ 6 3))\n (assert-equal 2.5 (/ 5 2)))\n\n (deftest \"modulo\"\n (assert-equal 1 (mod 7 3))\n (assert-equal 0 (mod 6 3))))" "lisp")))) + + ;; Bootstrapping to Python + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Bootstrap to Python") + (p :class "text-stone-600" + (code :class "text-violet-700 text-sm" "bootstrap_test.py") + " parses " + (code :class "text-violet-700 text-sm" "test.sx") + ", extracts the " + (code :class "text-violet-700 text-sm" "define") + " forms (assertion helpers) as a preamble, then emits each " + (code :class "text-violet-700 text-sm" "defsuite") + " as a pytest class and each " + (code :class "text-violet-700 text-sm" "deftest") + " as a test method.") + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Generated Python") + (~doc-code :code + (highlight "# Auto-generated from test.sx\nfrom shared.sx.parser import parse_all\nfrom shared.sx.evaluator import _eval, _trampoline\n\ndef _make_env():\n env = {}\n for expr in parse_all(_PREAMBLE):\n _trampoline(_eval(expr, env))\n return env\n\ndef _run(sx_source, env=None):\n if env is None:\n env = _make_env()\n exprs = parse_all(sx_source)\n for expr in exprs:\n result = _trampoline(_eval(expr, env))\n return result\n\nclass TestSpecArithmetic:\n def test_addition(self):\n _run('(do (assert-equal 3 (+ 1 2)) ...)')\n\n def test_subtraction(self):\n _run('(do (assert-equal 1 (- 3 2)) ...)')" "python"))) + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Run it") + (~doc-code :code + (highlight "$ python bootstrap_test.py --output test_sx_spec.py\nParsed 15 suites, 9 preamble defines from test.sx\nTotal test cases: 81\n\n$ pytest test_sx_spec.py -v\n81 passed in 0.15s" "bash")))) + + ;; Bootstrapping to JavaScript + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Bootstrap to JavaScript") + (p :class "text-stone-600" + (code :class "text-violet-700 text-sm" "bootstrap_test_js.py") + " emits a standalone Node.js script that loads " + (code :class "text-violet-700 text-sm" "sx-browser.js") + " (bootstrapped from the spec), injects any platform-specific primitives into the test env, then runs all 81 tests with TAP output.") + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Generated JavaScript") + (~doc-code :code + (highlight "// Auto-generated from test.sx\nvar Sx = require('./sx-browser.js');\n\nvar _envPrimitives = {\n 'equal?': function(a, b) { return deepEqual(a, b); },\n 'boolean?': function(x) { return typeof x === 'boolean'; },\n // ... platform primitives injected into env\n};\n\nfunction _makeEnv() {\n var env = {};\n for (var k in _envPrimitives) env[k] = _envPrimitives[k];\n var exprs = Sx.parseAll(PREAMBLE);\n for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);\n return env;\n}\n\nfunction _run(name, sxSource) {\n var env = _makeEnv();\n var exprs = Sx.parseAll(sxSource);\n for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);\n}" "javascript"))) + (div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3" + (h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Run it") + (~doc-code :code + (highlight "$ python bootstrap_test_js.py --output test_sx_spec.js\nParsed 15 suites, 9 preamble defines from test.sx\nTotal test cases: 81\n\n$ node test_sx_spec.js\nTAP version 13\n1..81\nok 1 - literals > numbers are numbers\nok 2 - literals > strings are strings\n...\nok 81 - edge-cases > empty operations\n\n# tests 81\n# pass 81" "bash")))) + + ;; What it proves + (div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3" + (h2 :class "text-lg font-semibold text-blue-900" "What this proves") + (ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm" + (li "The test spec is " (strong "written in SX") " — the same language it tests") + (li "The same 81 tests run on " (strong "both Python and JavaScript")) + (li "Both hosts produce " (strong "identical results") " from identical SX source") + (li "Adding a new host requires only a new bootstrapper — the tests are " (strong "already written")) + (li "Platform divergences (truthiness of 0, [], \"\") are " (strong "documented, not hidden")) + (li "The spec is " (strong "executable") " — it doesn't just describe behavior, it verifies it"))) + + ;; Test suites + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "All 15 test suites") + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Suite") + (th :class "px-3 py-2 font-medium text-stone-600" "Tests") + (th :class "px-3 py-2 font-medium text-stone-600" "Covers"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "literals") + (td :class "px-3 py-2" "6") + (td :class "px-3 py-2 text-stone-700" "number, string, boolean, nil, list, dict type checking")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "arithmetic") + (td :class "px-3 py-2" "5") + (td :class "px-3 py-2 text-stone-700" "+, -, *, /, mod with edge cases")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "comparison") + (td :class "px-3 py-2" "3") + (td :class "px-3 py-2 text-stone-700" "=, equal?, <, >, <=, >=")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "strings") + (td :class "px-3 py-2" "7") + (td :class "px-3 py-2 text-stone-700" "str, string-length, substring, contains?, upcase, trim, split/join")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "lists") + (td :class "px-3 py-2" "10") + (td :class "px-3 py-2 text-stone-700" "first, rest, nth, last, cons, append, reverse, empty?, contains?, flatten")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "dicts") + (td :class "px-3 py-2" "6") + (td :class "px-3 py-2 text-stone-700" "literals, get, assoc, dissoc, keys/vals, has-key?, merge")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "predicates") + (td :class "px-3 py-2" "7") + (td :class "px-3 py-2 text-stone-700" "nil?, number?, string?, list?, dict?, boolean?, not")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "special-forms") + (td :class "px-3 py-2" "10") + (td :class "px-3 py-2 text-stone-700" "if, when, cond, and, or, let, let (Clojure), do/begin, define, set!")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "lambdas") + (td :class "px-3 py-2" "5") + (td :class "px-3 py-2 text-stone-700" "basic, closures, as argument, recursion, higher-order returns")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "higher-order") + (td :class "px-3 py-2" "6") + (td :class "px-3 py-2 text-stone-700" "map, filter, reduce, some, every?, map-indexed")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "components") + (td :class "px-3 py-2" "4") + (td :class "px-3 py-2 text-stone-700" "defcomp, &key params, &rest children, defaults")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "macros") + (td :class "px-3 py-2" "3") + (td :class "px-3 py-2 text-stone-700" "defmacro, quasiquote/unquote, splice-unquote")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "threading") + (td :class "px-3 py-2" "1") + (td :class "px-3 py-2 text-stone-700" "-> thread-first macro")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "truthiness") + (td :class "px-3 py-2" "2") + (td :class "px-3 py-2 text-stone-700" "truthy/falsy values (platform-universal subset)")) + (tr + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "edge-cases") + (td :class "px-3 py-2" "6") + (td :class "px-3 py-2 text-stone-700" "nested scoping, recursive map, keywords, dict eval, nil propagation, empty ops")))))) + + ;; Full source + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Full specification source") + (p :class "text-xs text-stone-400 italic" + "The s-expression source below is the canonical test specification. " + "Bootstrap compilers read this file to generate native test runners.") + (div :class "not-prose bg-stone-100 rounded-lg p-5 mx-auto max-w-3xl" + (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" + (code (highlight spec-source "sx")))))))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index f1400d0..dcac513 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -341,6 +341,8 @@ :filename (get item "filename") :href (str "/specs/" (get item "slug")) :source (read-spec-file (get item "filename")))) extension-spec-items)) + "testing" (~spec-testing-content + :spec-source (read-spec-file "test.sx")) :else (let ((spec (find-spec slug))) (if spec (~spec-detail-content