diff --git a/shared/static/scripts/sx-test-runner.js b/shared/static/scripts/sx-test-runner.js new file mode 100644 index 0000000..89ce30c --- /dev/null +++ b/shared/static/scripts/sx-test-runner.js @@ -0,0 +1,96 @@ +// sx-test-runner.js — Run test.sx in the browser using sx-browser.js. +// Loaded on the /specs/testing page. Uses the Sx global. +(function() { + 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; + } + + window.sxRunTests = function(srcId, outId, btnId) { + var src = document.getElementById(srcId).textContent; + var out = document.getElementById(outId); + var btn = document.getElementById(btnId); + + var stack = [], passed = 0, failed = 0, num = 0, lines = []; + + var env = { + "try-call": function(thunk) { + try { + Sx.eval([thunk], env); + return { ok: true }; + } catch(e) { + return { ok: false, error: e.message || String(e) }; + } + }, + "report-pass": function(name) { + num++; passed++; + lines.push("ok " + num + " - " + stack.concat([name]).join(" > ")); + }, + "report-fail": function(name, error) { + num++; failed++; + lines.push("not ok " + num + " - " + stack.concat([name]).join(" > ")); + lines.push(" # " + error); + }, + "push-suite": function(name) { stack.push(name); }, + "pop-suite": function() { stack.pop(); }, + + "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, n) { return String(s).indexOf(n) !== -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]); }, + }; + + try { + var t0 = performance.now(); + var exprs = Sx.parseAll(src); + for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env); + var elapsed = Math.round(performance.now() - t0); + lines.push(""); + lines.push("1.." + num); + lines.push("# tests " + (passed + failed)); + lines.push("# pass " + passed); + if (failed > 0) lines.push("# fail " + failed); + lines.push("# time " + elapsed + "ms"); + } catch(e) { + lines.push(""); + lines.push("FATAL: " + (e.message || String(e))); + } + + out.textContent = lines.join("\n"); + out.style.display = "block"; + btn.textContent = passed + "/" + (passed + failed) + " passed" + (failed === 0 ? "" : " (" + failed + " failed)"); + btn.className = failed > 0 + ? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default" + : "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default"; + }; +})(); 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/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..2f6fd97 --- /dev/null +++ b/shared/sx/ref/test.sx @@ -0,0 +1,597 @@ +;; ========================================================================== +;; test.sx — Self-hosting SX test framework +;; +;; Defines a minimal test framework in SX that tests SX — the language +;; proves its own correctness. The framework is self-executing: any host +;; that provides 5 platform functions can evaluate this file directly. +;; +;; Platform functions required: +;; try-call (thunk) → {:ok true} | {:ok false :error "msg"} +;; report-pass (name) → platform-specific pass output +;; report-fail (name error) → platform-specific fail output +;; push-suite (name) → push suite name onto context stack +;; pop-suite () → pop suite name from context stack +;; +;; Usage: +;; ;; Host injects platform functions into env, then: +;; (eval-file "test.sx" env) +;; +;; The same test.sx runs on every host — Python, JavaScript, etc. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; 1. Test framework macros +;; -------------------------------------------------------------------------- +;; +;; deftest and defsuite are macros that make test.sx directly executable. +;; The host provides try-call (error catching), reporting, and suite +;; context — everything else is pure SX. + +(defmacro deftest (name &rest body) + `(let ((result (try-call (fn () ,@body)))) + (if (get result "ok") + (report-pass ,name) + (report-fail ,name (get result "error"))))) + +(defmacro defsuite (name &rest items) + `(do (push-suite ,name) + ,@items + (pop-suite))) + + +;; -------------------------------------------------------------------------- +;; 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) + (let ((result (try-call thunk))) + (assert (not (get result "ok")) + "Expected an error to be thrown but none was")))) + + +;; ========================================================================== +;; 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/run.js b/shared/sx/tests/run.js new file mode 100644 index 0000000..ec77792 --- /dev/null +++ b/shared/sx/tests/run.js @@ -0,0 +1,108 @@ +// Run test.sx directly against sx-browser.js. +// +// sx-browser.js parses and evaluates test.sx — SX tests itself. +// This script provides only platform functions (error catching, reporting). +// +// Usage: node shared/sx/tests/run.js + +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]); }, +}; + +// --- Read and evaluate test.sx --- +var src = fs.readFileSync(path.resolve(__dirname, "../ref/test.sx"), "utf8"); +var exprs = Sx.parseAll(src); + +console.log("TAP version 13"); +for (var i = 0; i < exprs.length; i++) { + Sx.eval(exprs[i], env); +} + +// --- 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); +} diff --git a/shared/sx/tests/run.py b/shared/sx/tests/run.py new file mode 100644 index 0000000..3fda836 --- /dev/null +++ b/shared/sx/tests/run.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Run test.sx directly against the Python SX evaluator. + +The Python evaluator parses and evaluates test.sx — SX tests itself. +This script provides only platform functions (error catching, reporting). + +Usage: python shared/sx/tests/run.py +""" +from __future__ import annotations + +import os +import sys +import traceback + +_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.evaluator import _eval, _trampoline + +# --- Test state --- +suite_stack: list[str] = [] +passed = 0 +failed = 0 +test_num = 0 + + +def try_call(thunk): + """Call an SX thunk, catching errors.""" + try: + _trampoline(_eval([thunk], {})) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + +def report_pass(name): + global passed, test_num + test_num += 1 + passed += 1 + full_name = " > ".join(suite_stack + [name]) + print(f"ok {test_num} - {full_name}") + + +def report_fail(name, error): + global failed, test_num + test_num += 1 + failed += 1 + full_name = " > ".join(suite_stack + [name]) + print(f"not ok {test_num} - {full_name}") + print(f" # {error}") + + +def push_suite(name): + suite_stack.append(name) + + +def pop_suite(): + suite_stack.pop() + + +def main(): + env = { + "try-call": try_call, + "report-pass": report_pass, + "report-fail": report_fail, + "push-suite": push_suite, + "pop-suite": pop_suite, + } + + test_sx = os.path.join(_HERE, "..", "ref", "test.sx") + with open(test_sx) as f: + src = f.read() + + exprs = parse_all(src) + + print("TAP version 13") + for expr in exprs: + _trampoline(_eval(expr, env)) + + print() + print(f"1..{test_num}") + print(f"# tests {passed + failed}") + print(f"# pass {passed}") + if failed > 0: + print(f"# fail {failed}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/shared/sx/tests/test_sx_spec.py b/shared/sx/tests/test_sx_spec.py new file mode 100644 index 0000000..db885cc --- /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) (let ((result (try-call thunk))) (assert (not (get result "ok")) "Expected an error to be thrown but none was"))))''' + + +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 823a8e0..a9adafd 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -103,7 +103,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/") @@ -196,7 +197,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..7ed3e4d --- /dev/null +++ b/sx/sx/testing.sx @@ -0,0 +1,234 @@ +;; 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. " + (code :class "text-violet-700 text-sm" "test.sx") + " is a self-executing test spec — it defines " + (code :class "text-violet-700 text-sm" "deftest") + " and " + (code :class "text-violet-700 text-sm" "defsuite") + " as macros, writes 81 test cases, and runs them. Any host that provides five platform functions can evaluate the file directly.") + (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. No code generation, no intermediate files — the evaluator runs the spec.")) + + ;; Live test runner + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Run in browser") + (p :class "text-stone-600" + "This page loaded " + (code :class "text-violet-700 text-sm" "sx-browser.js") + " to render itself. The same evaluator can run " + (code :class "text-violet-700 text-sm" "test.sx") + " right here — SX testing SX, in your browser:") + (div :class "flex items-center gap-4" + (button :id "test-btn" + :class "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 cursor-pointer" + :onclick "sxRunTests('test-sx-source','test-output','test-btn')" + "Run 81 tests")) + (pre :id "test-output" + :class "text-sm font-mono bg-stone-900 text-green-400 rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto" + :style "display:none" + "") + ;; Hidden: raw test.sx source for the browser runner + (textarea :id "test-sx-source" :style "display:none" spec-source) + ;; Load the test runner script + (script :src (asset-url "/scripts/sx-test-runner.js"))) + + ;; How it works + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Architecture") + (p :class "text-stone-600" + "The test framework needs five platform functions. Everything else — macros, assertion helpers, test suites — is pure SX:") + (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 Self-executing: macros + helpers + 81 tests + | + |--- browser sx-browser.js evaluates test.sx in this page + | + |--- run.js Injects 5 platform fns, evaluates test.sx + | | + | +-> sx-browser.js JS evaluator (bootstrapped from spec) + | + |--- run.py Injects 5 platform fns, evaluates test.sx + | + +-> evaluator.py Python evaluator + +Platform functions (5 total — everything else is pure SX): + try-call (thunk) -> {:ok true} | {:ok false :error \"msg\"} + report-pass (name) -> output pass + report-fail (name error) -> output fail + push-suite (name) -> push suite context + pop-suite () -> pop suite context"))) + + ;; 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 macros and nine assertion helpers, all in SX. The macros are the key — they make " + (code :class "text-violet-700 text-sm" "defsuite") + " and " + (code :class "text-violet-700 text-sm" "deftest") + " executable forms, not just declarations:") + (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" "Macros") + (~doc-code :code + (highlight "(defmacro deftest (name &rest body)\n `(let ((result (try-call (fn () ,@body))))\n (if (get result \"ok\")\n (report-pass ,name)\n (report-fail ,name (get result \"error\")))))\n\n(defmacro defsuite (name &rest items)\n `(do (push-suite ,name)\n ,@items\n (pop-suite)))" "lisp"))) + (p :class "text-stone-600 text-sm" + (code :class "text-violet-700 text-sm" "deftest") + " wraps the body in a thunk, passes it to " + (code :class "text-violet-700 text-sm" "try-call") + " (the one platform function that catches errors), then reports pass or fail. " + (code :class "text-violet-700 text-sm" "defsuite") + " pushes a name onto the context stack, runs its children, and pops.") + (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) ...))\n(define assert-throws (fn (thunk) ...))" "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")))) + + ;; Running tests — JS + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "JavaScript: direct evaluation") + (p :class "text-stone-600" + (code :class "text-violet-700 text-sm" "sx-browser.js") + " evaluates " + (code :class "text-violet-700 text-sm" "test.sx") + " directly. The runner injects platform functions and calls " + (code :class "text-violet-700 text-sm" "Sx.eval") + " on each parsed expression:") + (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.js") + (~doc-code :code + (highlight "var Sx = require('./sx-browser.js');\nvar src = fs.readFileSync('test.sx', 'utf8');\n\nvar env = {\n 'try-call': function(thunk) {\n try {\n Sx.eval([thunk], env); // call the SX lambda\n return { ok: true };\n } catch(e) {\n return { ok: false, error: e.message };\n }\n },\n 'report-pass': function(name) { console.log('ok - ' + name); },\n 'report-fail': function(name, err) { console.log('not ok - ' + name); },\n 'push-suite': function(n) { stack.push(n); },\n 'pop-suite': function() { stack.pop(); },\n};\n\nvar exprs = Sx.parseAll(src);\nfor (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);" "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" "Output") + (~doc-code :code + (highlight "$ node shared/sx/tests/run.js\nTAP version 13\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")))) + + ;; Running tests — Python + (div :class "space-y-3" + (h2 :class "text-2xl font-semibold text-stone-800" "Python: direct evaluation") + (p :class "text-stone-600" + "Same approach — the Python evaluator runs " + (code :class "text-violet-700 text-sm" "test.sx") + " directly:") + (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.py") + (~doc-code :code + (highlight "from shared.sx.parser import parse_all\nfrom shared.sx.evaluator import _eval, _trampoline\n\ndef try_call(thunk):\n try:\n _trampoline(_eval([thunk], {}))\n return {'ok': True}\n except Exception as e:\n return {'ok': False, 'error': str(e)}\n\nenv = {\n 'try-call': try_call,\n 'report-pass': report_pass,\n 'report-fail': report_fail,\n 'push-suite': push_suite,\n 'pop-suite': pop_suite,\n}\n\nfor expr in parse_all(src):\n _trampoline(_eval(expr, env))" "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" "Output") + (~doc-code :code + (highlight "$ python shared/sx/tests/run.py\nTAP version 13\nok 1 - literals > numbers are numbers\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") " and " (strong "executed by SX") " — no code generation") + (li "The same 81 tests run on " (strong "Python, Node.js, and in the browser") " from the same file") + (li "Each host provides only " (strong "5 platform functions") " — everything else is pure SX") + (li "Adding a new host means implementing 5 functions, not rewriting tests") + (li "Platform divergences (truthiness of 0, [], \"\") are " (strong "documented, not hidden")) + (li "The spec is " (strong "executable") " — click the button above to prove 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. " + "Any host that implements the five platform functions can evaluate it directly.") + (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 52bd627..d6f33e3 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -342,6 +342,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