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>
246 lines
7.7 KiB
Python
246 lines
7.7 KiB
Python
#!/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()
|