Add self-hosting SX test spec: 81 tests bootstrap to Python + JS

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 10:41:53 +00:00
parent e09bc3b601
commit 754e7557f5
10 changed files with 1901 additions and 5 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"

612
shared/sx/ref/test.sx Normal file
View File

@@ -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))))