New test specs (test-deps.sx: 33 tests, test-engine.sx: 37 tests) covering component dependency analysis and engine pure functions. All 6 spec modules now have formal SX tests: eval (81), parser (39), router (18), render (23), deps (33), engine (37) = 231 total. - Add engine as spec module in bootstrap_py.py (alongside deps) - Add primitive aliases (trim, replace, parse_int, upper) for engine functions - Fix parse-int to match JS parseInt semantics (strip trailing non-digits) - Regenerate sx_ref.py with --spec-modules deps,engine - Update all three test runners (run.js, run.py, sx-test-runner.js) - Add Dependencies and Engine nav items and testing page entries - Wire deps-source/engine-source through testing overview UI Node.js: 231/231 pass. Python: 226/231 (5 pre-existing parser/router gaps). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
11 KiB
Python
387 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Run SX test specs against the Python SX evaluator.
|
|
|
|
The Python evaluator parses and evaluates test specs — SX tests itself.
|
|
This script provides only platform functions (error catching, reporting).
|
|
|
|
Usage:
|
|
python shared/sx/tests/run.py # run all available specs
|
|
python shared/sx/tests/run.py eval # run only test-eval.sx
|
|
python shared/sx/tests/run.py eval parser router # run specific specs
|
|
python shared/sx/tests/run.py --legacy # run monolithic test.sx
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
_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, _call_lambda
|
|
from shared.sx.types import Symbol, Keyword, Lambda, NIL
|
|
|
|
# --- 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], env))
|
|
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()
|
|
|
|
|
|
# --- Parser platform functions ---
|
|
|
|
def sx_parse(source):
|
|
"""Parse SX source string into list of AST expressions."""
|
|
return parse_all(source)
|
|
|
|
|
|
def sx_serialize(val):
|
|
"""Serialize an AST value to SX source text."""
|
|
if val is None or val is NIL:
|
|
return "nil"
|
|
if isinstance(val, bool):
|
|
return "true" if val else "false"
|
|
if isinstance(val, (int, float)):
|
|
return str(val)
|
|
if isinstance(val, str):
|
|
escaped = val.replace("\\", "\\\\").replace('"', '\\"')
|
|
return f'"{escaped}"'
|
|
if isinstance(val, Symbol):
|
|
return val.name
|
|
if isinstance(val, Keyword):
|
|
return f":{val.name}"
|
|
if isinstance(val, list):
|
|
inner = " ".join(sx_serialize(x) for x in val)
|
|
return f"({inner})"
|
|
if isinstance(val, dict):
|
|
parts = []
|
|
for k, v in val.items():
|
|
parts.append(f":{k}")
|
|
parts.append(sx_serialize(v))
|
|
return "{" + " ".join(parts) + "}"
|
|
return str(val)
|
|
|
|
|
|
def make_symbol(name):
|
|
return Symbol(name)
|
|
|
|
|
|
def make_keyword(name):
|
|
return Keyword(name)
|
|
|
|
|
|
def symbol_name(sym):
|
|
if isinstance(sym, Symbol):
|
|
return sym.name
|
|
return str(sym)
|
|
|
|
|
|
def keyword_name(kw):
|
|
if isinstance(kw, Keyword):
|
|
return kw.name
|
|
return str(kw)
|
|
|
|
|
|
# --- Render platform function ---
|
|
|
|
def render_html(sx_source):
|
|
"""Parse SX source and render to HTML via the bootstrapped evaluator."""
|
|
try:
|
|
from shared.sx.ref.sx_ref import render_to_html as _render_to_html
|
|
except ImportError:
|
|
raise RuntimeError("render-to-html not available — sx_ref.py not built")
|
|
exprs = parse_all(sx_source)
|
|
render_env = dict(env)
|
|
result = ""
|
|
for expr in exprs:
|
|
result += _render_to_html(expr, render_env)
|
|
return result
|
|
|
|
|
|
# --- Spec registry ---
|
|
|
|
SPECS = {
|
|
"eval": {"file": "test-eval.sx", "needs": []},
|
|
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
|
|
"router": {"file": "test-router.sx", "needs": []},
|
|
"render": {"file": "test-render.sx", "needs": ["render-html"]},
|
|
"deps": {"file": "test-deps.sx", "needs": []},
|
|
"engine": {"file": "test-engine.sx", "needs": []},
|
|
}
|
|
|
|
REF_DIR = os.path.join(_HERE, "..", "ref")
|
|
|
|
|
|
def eval_file(filename, env):
|
|
"""Load and evaluate an SX file."""
|
|
filepath = os.path.join(REF_DIR, filename)
|
|
if not os.path.exists(filepath):
|
|
print(f"# SKIP {filename} (file not found)")
|
|
return
|
|
with open(filepath) as f:
|
|
src = f.read()
|
|
exprs = parse_all(src)
|
|
for expr in exprs:
|
|
_trampoline(_eval(expr, env))
|
|
|
|
|
|
# --- Build env ---
|
|
env = {
|
|
"try-call": try_call,
|
|
"report-pass": report_pass,
|
|
"report-fail": report_fail,
|
|
"push-suite": push_suite,
|
|
"pop-suite": pop_suite,
|
|
# Parser platform functions
|
|
"sx-parse": sx_parse,
|
|
"sx-serialize": sx_serialize,
|
|
"make-symbol": make_symbol,
|
|
"make-keyword": make_keyword,
|
|
"symbol-name": symbol_name,
|
|
"keyword-name": keyword_name,
|
|
# Render platform function
|
|
"render-html": render_html,
|
|
# Extra primitives needed by spec modules (router.sx, deps.sx)
|
|
"for-each-indexed": "_deferred", # replaced below
|
|
"dict-set!": "_deferred",
|
|
"dict-has?": "_deferred",
|
|
"dict-get": "_deferred",
|
|
"append!": "_deferred",
|
|
"inc": lambda n: n + 1,
|
|
}
|
|
|
|
|
|
def _call_sx(fn, args, caller_env):
|
|
"""Call an SX lambda or native function with args."""
|
|
if isinstance(fn, Lambda):
|
|
return _trampoline(_call_lambda(fn, list(args), caller_env))
|
|
return fn(*args)
|
|
|
|
|
|
def _for_each_indexed(fn, coll):
|
|
"""for-each-indexed that respects set! in lambda closures.
|
|
|
|
The hand-written evaluator copies envs on lambda calls, which breaks
|
|
set! mutation of outer scope. We eval directly in the closure dict
|
|
to match the bootstrapped semantics (cell-based mutation).
|
|
"""
|
|
if isinstance(fn, Lambda):
|
|
closure = fn.closure
|
|
for i, item in enumerate(coll or []):
|
|
# Bind params directly in the closure (no copy)
|
|
for p, v in zip(fn.params, [i, item]):
|
|
closure[p] = v
|
|
_trampoline(_eval(fn.body, closure))
|
|
else:
|
|
for i, item in enumerate(coll or []):
|
|
fn(i, item)
|
|
return NIL
|
|
|
|
|
|
def _dict_set(d, k, v):
|
|
if isinstance(d, dict):
|
|
d[k] = v
|
|
return NIL
|
|
|
|
|
|
def _dict_has(d, k):
|
|
return isinstance(d, dict) and k in d
|
|
|
|
|
|
def _dict_get(d, k):
|
|
if isinstance(d, dict):
|
|
return d.get(k, NIL)
|
|
return NIL
|
|
|
|
|
|
def _append_mut(lst, item):
|
|
if isinstance(lst, list):
|
|
lst.append(item)
|
|
return NIL
|
|
|
|
|
|
env["for-each-indexed"] = _for_each_indexed
|
|
env["dict-set!"] = _dict_set
|
|
env["dict-has?"] = _dict_has
|
|
env["dict-get"] = _dict_get
|
|
env["append!"] = _append_mut
|
|
|
|
|
|
def _load_router_from_bootstrap(env):
|
|
"""Load router functions from the bootstrapped sx_ref.py.
|
|
|
|
The hand-written evaluator can't run router.sx faithfully because
|
|
set! inside lambda closures doesn't propagate to outer scopes
|
|
(the evaluator uses dict copies, not cells). The bootstrapped code
|
|
compiles set! to cell-based mutation, so we import from there.
|
|
"""
|
|
try:
|
|
from shared.sx.ref.sx_ref import (
|
|
split_path_segments,
|
|
parse_route_pattern,
|
|
match_route_segments,
|
|
match_route,
|
|
find_matching_route,
|
|
make_route_segment,
|
|
)
|
|
env["split-path-segments"] = split_path_segments
|
|
env["parse-route-pattern"] = parse_route_pattern
|
|
env["match-route-segments"] = match_route_segments
|
|
env["match-route"] = match_route
|
|
env["find-matching-route"] = find_matching_route
|
|
env["make-route-segment"] = make_route_segment
|
|
except ImportError:
|
|
# Fallback: eval router.sx directly (may fail on set! scoping)
|
|
eval_file("router.sx", env)
|
|
|
|
|
|
def _load_deps_from_bootstrap(env):
|
|
"""Load deps functions from the bootstrapped sx_ref.py."""
|
|
try:
|
|
from shared.sx.ref.sx_ref import (
|
|
scan_refs,
|
|
scan_components_from_source,
|
|
transitive_deps,
|
|
compute_all_deps,
|
|
components_needed,
|
|
page_component_bundle,
|
|
page_css_classes,
|
|
scan_io_refs,
|
|
transitive_io_refs,
|
|
compute_all_io_refs,
|
|
component_pure_p,
|
|
)
|
|
env["scan-refs"] = scan_refs
|
|
env["scan-components-from-source"] = scan_components_from_source
|
|
env["transitive-deps"] = transitive_deps
|
|
env["compute-all-deps"] = compute_all_deps
|
|
env["components-needed"] = components_needed
|
|
env["page-component-bundle"] = page_component_bundle
|
|
env["page-css-classes"] = page_css_classes
|
|
env["scan-io-refs"] = scan_io_refs
|
|
env["transitive-io-refs"] = transitive_io_refs
|
|
env["compute-all-io-refs"] = compute_all_io_refs
|
|
env["component-pure?"] = component_pure_p
|
|
env["test-env"] = lambda: env
|
|
except ImportError:
|
|
eval_file("deps.sx", env)
|
|
env["test-env"] = lambda: env
|
|
|
|
|
|
def _load_engine_from_bootstrap(env):
|
|
"""Load engine pure functions from the bootstrapped sx_ref.py."""
|
|
try:
|
|
from shared.sx.ref.sx_ref import (
|
|
parse_time,
|
|
parse_trigger_spec,
|
|
default_trigger,
|
|
parse_swap_spec,
|
|
parse_retry_spec,
|
|
next_retry_ms,
|
|
filter_params,
|
|
)
|
|
env["parse-time"] = parse_time
|
|
env["parse-trigger-spec"] = parse_trigger_spec
|
|
env["default-trigger"] = default_trigger
|
|
env["parse-swap-spec"] = parse_swap_spec
|
|
env["parse-retry-spec"] = parse_retry_spec
|
|
env["next-retry-ms"] = next_retry_ms
|
|
env["filter-params"] = filter_params
|
|
except ImportError:
|
|
eval_file("engine.sx", env)
|
|
|
|
|
|
def main():
|
|
global passed, failed, test_num
|
|
|
|
args = sys.argv[1:]
|
|
|
|
# Legacy mode
|
|
if args and args[0] == "--legacy":
|
|
print("TAP version 13")
|
|
eval_file("test.sx", env)
|
|
else:
|
|
# Determine which specs to run
|
|
specs_to_run = args if args else list(SPECS.keys())
|
|
|
|
print("TAP version 13")
|
|
|
|
# Always load framework first
|
|
eval_file("test-framework.sx", env)
|
|
|
|
for spec_name in specs_to_run:
|
|
spec = SPECS.get(spec_name)
|
|
if not spec:
|
|
print(f"# SKIP unknown spec: {spec_name}")
|
|
continue
|
|
|
|
# Check platform requirements
|
|
can_run = True
|
|
for need in spec["needs"]:
|
|
if need not in env:
|
|
print(f"# SKIP {spec_name} (missing: {need})")
|
|
can_run = False
|
|
break
|
|
if not can_run:
|
|
continue
|
|
|
|
# Load prerequisite spec modules
|
|
if spec_name == "router":
|
|
_load_router_from_bootstrap(env)
|
|
if spec_name == "deps":
|
|
_load_deps_from_bootstrap(env)
|
|
if spec_name == "engine":
|
|
_load_engine_from_bootstrap(env)
|
|
|
|
print(f"# --- {spec_name} ---")
|
|
eval_file(spec["file"], env)
|
|
|
|
# Summary
|
|
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()
|