diff --git a/shared/sx/tests/test_parity.py b/shared/sx/tests/test_parity.py new file mode 100644 index 0000000..5858d24 --- /dev/null +++ b/shared/sx/tests/test_parity.py @@ -0,0 +1,739 @@ +"""Parity tests: hand-written evaluator/renderer vs bootstrapped sx_ref.py. + +Every test runs the same SX input through both implementations and asserts +identical results. This is the gate for migrating from hand-written to +bootstrapped code — 100% parity required before any swap. + +Run: python -m pytest shared/sx/tests/test_parity.py -v +""" + +import pytest +from shared.sx.parser import parse, parse_all +from shared.sx.types import Symbol, Keyword, Lambda, Component, Macro, NIL + + +# --------------------------------------------------------------------------- +# Two evaluators, one interface +# --------------------------------------------------------------------------- + +def hw_eval(text, env=None): + """Evaluate via hand-written evaluator.py.""" + from shared.sx.evaluator import evaluate as _evaluate, EvalError + if env is None: + env = {} + return _evaluate(parse(text), env) + + +def ref_eval(text, env=None): + """Evaluate via bootstrapped sx_ref.py.""" + from shared.sx.ref.sx_ref import evaluate as _evaluate, EvalError + if env is None: + env = {} + return _evaluate(parse(text), env) + + +def hw_render(text, env=None): + """Render via hand-written html.py.""" + from shared.sx.html import render + if env is None: + env = {} + return render(parse(text), env) + + +def ref_render(text, env=None): + """Render via bootstrapped sx_ref.py.""" + from shared.sx.ref.sx_ref import render + if env is None: + env = {} + return render(parse(text), env) + + +def hw_eval_multi(text, env=None): + """Evaluate multiple expressions (e.g. defines then call).""" + from shared.sx.evaluator import evaluate as _evaluate + if env is None: + env = {} + result = None + for expr in parse_all(text): + result = _evaluate(expr, env) + return result, env + + +def ref_eval_multi(text, env=None): + """Evaluate multiple expressions via sx_ref.py.""" + from shared.sx.ref.sx_ref import evaluate as _evaluate + if env is None: + env = {} + result = None + for expr in parse_all(text): + result = _evaluate(expr, env) + return result, env + + +def normalize(val): + """Normalize values for comparison (handle NIL variants).""" + if val is None or val is NIL: + return None + if isinstance(val, list): + return [normalize(v) for v in val] + if isinstance(val, dict): + return {k: normalize(v) for k, v in val.items()} + return val + + +def assert_parity(sx_text, env_hw=None, env_ref=None): + """Assert both evaluators produce the same result.""" + hw = normalize(hw_eval(sx_text, env_hw)) + ref = normalize(ref_eval(sx_text, env_ref)) + assert hw == ref, f"MISMATCH on {sx_text!r}:\n hw={hw!r}\n ref={ref!r}" + + +def assert_render_parity(sx_text, env_hw=None, env_ref=None): + """Assert both renderers produce the same HTML.""" + hw = hw_render(sx_text, env_hw) + ref = ref_render(sx_text, env_ref) + assert hw == ref, f"RENDER MISMATCH on {sx_text!r}:\n hw={hw!r}\n ref={ref!r}" + + +# --------------------------------------------------------------------------- +# Eval parity: Literals +# --------------------------------------------------------------------------- + +class TestParityLiterals: + def test_int(self): + assert_parity("42") + + def test_float(self): + assert_parity("3.14") + + def test_string(self): + assert_parity('"hello"') + + def test_true(self): + assert_parity("true") + + def test_false(self): + assert_parity("false") + + def test_nil(self): + assert_parity("nil") + + def test_keyword(self): + assert_parity(":foo") + + def test_symbol_lookup(self): + assert_parity("x", {"x": 10}, {"x": 10}) + + +# --------------------------------------------------------------------------- +# Eval parity: Arithmetic +# --------------------------------------------------------------------------- + +class TestParityArithmetic: + def test_add(self): + assert_parity("(+ 1 2 3)") + + def test_sub(self): + assert_parity("(- 10 3)") + + def test_negate(self): + assert_parity("(- 5)") + + def test_mul(self): + assert_parity("(* 2 3 4)") + + def test_div(self): + assert_parity("(/ 10 4)") + + def test_mod(self): + assert_parity("(mod 7 3)") + + def test_clamp(self): + assert_parity("(clamp 15 0 10)") + assert_parity("(clamp -5 0 10)") + + def test_abs(self): + assert_parity("(abs -5)") + + def test_floor_ceil(self): + assert_parity("(floor 3.7)") + assert_parity("(ceil 3.2)") + + def test_round(self): + assert_parity("(round 3.5)") + + +# --------------------------------------------------------------------------- +# Eval parity: Comparison and predicates +# --------------------------------------------------------------------------- + +class TestParityComparison: + def test_eq(self): + assert_parity("(= 1 1)") + assert_parity("(= 1 2)") + + def test_lt_gt(self): + assert_parity("(< 1 2)") + assert_parity("(> 2 1)") + + def test_lte_gte(self): + assert_parity("(<= 1 1)") + assert_parity("(>= 2 1)") + + def test_predicates(self): + assert_parity("(odd? 3)") + assert_parity("(even? 4)") + assert_parity("(zero? 0)") + assert_parity("(nil? nil)") + assert_parity('(string? "hi")') + assert_parity("(number? 42)") + assert_parity("(list? (list 1))") + assert_parity("(dict? {:a 1})") + + def test_empty(self): + assert_parity("(empty? (list))") + assert_parity("(empty? (list 1))") + assert_parity("(empty? nil)") + assert_parity('(empty? "")') + + +# --------------------------------------------------------------------------- +# Eval parity: Special forms +# --------------------------------------------------------------------------- + +class TestParitySpecialForms: + def test_if_true(self): + assert_parity("(if true 1 2)") + + def test_if_false(self): + assert_parity("(if false 1 2)") + + def test_if_no_else(self): + assert_parity("(if false 1)") + + def test_when_true(self): + assert_parity("(when true 42)") + + def test_when_false(self): + assert_parity("(when false 42)") + + def test_and(self): + assert_parity("(and true true 3)") + assert_parity("(and true false 3)") + + def test_or(self): + assert_parity("(or false false 3)") + assert_parity("(or false 2 3)") + + def test_let_scheme(self): + assert_parity("(let ((x 10) (y 20)) (+ x y))") + + def test_let_clojure(self): + assert_parity("(let (x 10 y 20) (+ x y))") + + def test_let_sequential(self): + assert_parity("(let ((x 1) (y (+ x 1))) y)") + + def test_begin(self): + assert_parity("(begin 1 2 3)") + + def test_cond_clojure(self): + assert_parity("(cond false 1 true 2 :else 3)") + + def test_cond_else(self): + assert_parity("(cond false 1 false 2 :else 99)") + + def test_case(self): + assert_parity('(case 2 1 "one" 2 "two" :else "other")') + + def test_thread_first(self): + assert_parity("(-> 5 (+ 3) (* 2))") + + +# --------------------------------------------------------------------------- +# Eval parity: Lambda +# --------------------------------------------------------------------------- + +class TestParityLambda: + def test_create_and_call(self): + assert_parity("((fn (x) (* x x)) 5)") + + def test_closure(self): + assert_parity("(let ((a 10)) ((fn (x) (+ x a)) 5))") + + def test_higher_order(self): + assert_parity("(let ((double (fn (x) (* x 2)))) (double 7))") + + def test_multi_body(self): + assert_parity("((fn (x) (+ x 1) (* x 2)) 5)") + + def test_rest_params(self): + assert_parity("((fn (&rest args) args) 1 2 3)") + + +# --------------------------------------------------------------------------- +# Eval parity: Collections +# --------------------------------------------------------------------------- + +class TestParityCollections: + def test_list(self): + assert_parity("(list 1 2 3)") + + def test_dict_literal(self): + assert_parity("{:a (+ 1 2) :b (* 3 4)}") + + def test_dict_constructor(self): + assert_parity("(dict :a 1 :b 2)") + + def test_get_dict(self): + assert_parity('(get {:a 1 :b 2} "a")') + + def test_get_list(self): + assert_parity("(get (list 10 20 30) 1)") + + def test_first_last_rest(self): + assert_parity("(first (list 1 2 3))") + assert_parity("(last (list 1 2 3))") + assert_parity("(rest (list 1 2 3))") + + def test_len(self): + assert_parity("(len (list 1 2 3))") + assert_parity('(len "hello")') + + def test_concat(self): + assert_parity("(concat (list 1 2) (list 3 4))") + + def test_cons(self): + assert_parity("(cons 0 (list 1 2))") + + def test_keys_vals(self): + assert_parity("(keys {:a 1 :b 2})") + assert_parity("(vals {:a 1 :b 2})") + + def test_merge(self): + assert_parity("(merge {:a 1} {:b 2} {:a 3})") + + def test_assoc(self): + assert_parity("(assoc {:a 1} :b 2)") + + def test_dissoc(self): + assert_parity("(dissoc {:a 1 :b 2} :a)") + + def test_contains(self): + assert_parity('(contains? {:a 1} "a")') + assert_parity("(contains? (list 1 2 3) 2)") + + def test_nth(self): + assert_parity("(nth (list 10 20 30) 1)") + + def test_slice(self): + assert_parity("(slice (list 1 2 3 4 5) 1 3)") + assert_parity("(slice (list 1 2 3 4 5) 2)") + + def test_range(self): + assert_parity("(range 0 5)") + assert_parity("(range 1 10 2)") + + +# --------------------------------------------------------------------------- +# Eval parity: Higher-order forms +# --------------------------------------------------------------------------- + +class TestParityHigherOrder: + def test_map(self): + assert_parity("(map (fn (x) (* x x)) (list 1 2 3 4))") + + def test_map_indexed(self): + assert_parity("(map-indexed (fn (i x) (+ i x)) (list 10 20 30))") + + def test_filter(self): + assert_parity("(filter (fn (x) (> x 2)) (list 1 2 3 4))") + + def test_reduce(self): + assert_parity("(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3))") + + def test_some(self): + assert_parity("(some (fn (x) (if (> x 3) x nil)) (list 1 2 4 5))") + + def test_every(self): + assert_parity("(every? (fn (x) (> x 0)) (list 1 2 3))") + assert_parity("(every? (fn (x) (> x 2)) (list 1 2 3))") + + def test_for_each(self): + # for-each returns nil + assert_parity("(for-each (fn (x) x) (list 1 2 3))") + + +# --------------------------------------------------------------------------- +# Eval parity: Strings +# --------------------------------------------------------------------------- + +class TestParityStrings: + def test_str(self): + assert_parity('(str "hello" " " "world")') + + def test_str_with_numbers(self): + assert_parity('(str "val=" 42)') + + def test_str_with_nil(self): + assert_parity('(str "a" nil "b")') + + def test_upper_lower(self): + assert_parity('(upper "hello")') + assert_parity('(lower "HELLO")') + + def test_join(self): + assert_parity('(join ", " (list "a" "b" "c"))') + + def test_split(self): + assert_parity('(split "a,b,c" ",")') + + def test_replace(self): + assert_parity('(replace "hello world" "world" "there")') + + def test_starts_ends_with(self): + assert_parity('(starts-with? "hello" "hel")') + assert_parity('(ends-with? "hello" "llo")') + + def test_trim(self): + assert_parity('(trim " hello ")') + + def test_index_of(self): + assert_parity('(index-of "hello" "ll")') + + +# --------------------------------------------------------------------------- +# Eval parity: Define and defcomp +# --------------------------------------------------------------------------- + +class TestParityDefine: + def test_define(self): + _, hw_env = hw_eval_multi("(define x 42) x") + _, ref_env = ref_eval_multi("(define x 42) x") + assert hw_env["x"] == ref_env["x"] + + def test_define_fn(self): + hw_r, _ = hw_eval_multi("(define double (fn (x) (* x 2))) (double 5)") + ref_r, _ = ref_eval_multi("(define double (fn (x) (* x 2))) (double 5)") + assert hw_r == ref_r + + def test_defcomp(self): + hw_r, hw_env = hw_eval_multi( + '(defcomp ~card (&key title) (str "Card: " title))' + ' (~card :title "Hello")' + ) + ref_r, ref_env = ref_eval_multi( + '(defcomp ~card (&key title) (str "Card: " title))' + ' (~card :title "Hello")' + ) + assert hw_r == ref_r + assert isinstance(hw_env["~card"], Component) + assert isinstance(ref_env["~card"], Component) + + def test_defcomp_with_children(self): + hw_r, _ = hw_eval_multi( + "(defcomp ~wrap (&key &rest children) children)" + " (~wrap 1 2 3)" + ) + ref_r, _ = ref_eval_multi( + "(defcomp ~wrap (&key &rest children) children)" + " (~wrap 1 2 3)" + ) + assert hw_r == ref_r + + def test_defcomp_missing_kwarg(self): + hw_r, _ = hw_eval_multi( + "(defcomp ~opt (&key x y) (list x y))" + " (~opt :x 1)" + ) + ref_r, _ = ref_eval_multi( + "(defcomp ~opt (&key x y) (list x y))" + " (~opt :x 1)" + ) + assert normalize(hw_r) == normalize(ref_r) + + +# --------------------------------------------------------------------------- +# Eval parity: Macros +# --------------------------------------------------------------------------- + +class TestParityMacros: + def test_defmacro_expansion(self): + hw_r, _ = hw_eval_multi( + "(defmacro double (x) `(+ ,x ,x))" + " (double 5)" + ) + ref_r, _ = ref_eval_multi( + "(defmacro double (x) `(+ ,x ,x))" + " (double 5)" + ) + assert hw_r == ref_r + + def test_macro_with_splice(self): + hw_r, _ = hw_eval_multi( + "(defmacro add-all (&rest nums) `(+ ,@nums))" + " (add-all 1 2 3)" + ) + ref_r, _ = ref_eval_multi( + "(defmacro add-all (&rest nums) `(+ ,@nums))" + " (add-all 1 2 3)" + ) + assert hw_r == ref_r + + def test_quasiquote(self): + hw = hw_eval("`(a ,x b)", {"x": 42}) + ref = ref_eval("`(a ,x b)", {"x": 42}) + # Both should produce [Symbol("a"), 42, Symbol("b")] + assert len(hw) == len(ref) + assert hw[1] == ref[1] == 42 + + +# --------------------------------------------------------------------------- +# Eval parity: set! and mutation +# --------------------------------------------------------------------------- + +class TestParitySetBang: + def test_set_bang(self): + hw_env = {"x": 1} + ref_env = {"x": 1} + hw_eval("(set! x 42)", hw_env) + ref_eval("(set! x 42)", ref_env) + assert hw_env["x"] == ref_env["x"] == 42 + + +# --------------------------------------------------------------------------- +# Eval parity: TCO / recursion +# --------------------------------------------------------------------------- + +class TestParityTCO: + def test_recursive_sum(self): + src = """ + (define sum (fn (n acc) + (if (<= n 0) acc + (sum (- n 1) (+ acc n))))) + (sum 1000 0) + """ + hw_r, _ = hw_eval_multi(src) + ref_r, _ = ref_eval_multi(src) + assert hw_r == ref_r == 500500 + + +# --------------------------------------------------------------------------- +# Render parity: Basic HTML +# --------------------------------------------------------------------------- + +class TestParityRenderBasic: + def test_string(self): + assert_render_parity('"Hello"') + + def test_number(self): + assert_render_parity("42") + + def test_simple_tag(self): + assert_render_parity('(div "hello")') + + def test_tag_with_class(self): + assert_render_parity('(div :class "container" "content")') + + def test_nested_tags(self): + assert_render_parity('(div (p "para") (span "text"))') + + def test_void_element(self): + assert_render_parity('(br)') + assert_render_parity('(img :src "test.png" :alt "test")') + + def test_boolean_attr(self): + assert_render_parity('(input :type "checkbox" :checked true)') + assert_render_parity('(input :type "text" :disabled false)') + + def test_fragment(self): + assert_render_parity('(<> (p "a") (p "b"))') + + def test_nil_child(self): + assert_render_parity("(div nil)") + + def test_escaped_text(self): + assert_render_parity('(div "")') + + +# --------------------------------------------------------------------------- +# Render parity: Special forms in render context +# --------------------------------------------------------------------------- + +class TestParityRenderForms: + def test_if(self): + assert_render_parity('(if true (span "yes") (span "no"))') + assert_render_parity('(if false (span "yes") (span "no"))') + + def test_when(self): + assert_render_parity('(when true (p "shown"))') + assert_render_parity('(when false (p "hidden"))') + + def test_cond(self): + assert_render_parity('(cond false (span "a") true (span "b") :else (span "c"))') + + def test_let(self): + assert_render_parity('(let ((x "hello")) (p x))') + + def test_map(self): + assert_render_parity('(map (fn (x) (li x)) (list "a" "b" "c"))') + + def test_begin(self): + assert_render_parity('(begin (p "a") (p "b"))') + + +# --------------------------------------------------------------------------- +# Render parity: Components +# --------------------------------------------------------------------------- + +class TestParityRenderComponents: + def test_simple_component(self): + src = '(defcomp ~card (&key title) (div :class "card" (h2 title)))' + call = '(~card :title "Hello")' + hw_env = {} + ref_env = {} + hw_eval(src, hw_env) + ref_eval(src, ref_env) + assert_render_parity(call, hw_env, ref_env) + + def test_component_with_children(self): + src = '(defcomp ~box (&key &rest children) (div :class "box" children))' + call = '(~box (p "a") (p "b"))' + hw_env = {} + ref_env = {} + hw_eval(src, hw_env) + ref_eval(src, ref_env) + assert_render_parity(call, hw_env, ref_env) + + def test_nested_components(self): + src = """ + (defcomp ~inner (&key text) (span text)) + (defcomp ~outer (&key) (div (~inner :text "hello"))) + """ + hw_env = {} + ref_env = {} + hw_eval_multi(src, hw_env) + ref_eval_multi(src, ref_env) + assert_render_parity("(~outer)", hw_env, ref_env) + + def test_component_with_when(self): + src = '(defcomp ~opt (&key show label) (when show (span label)))' + hw_env = {} + ref_env = {} + hw_eval(src, hw_env) + ref_eval(src, ref_env) + hw_html = hw_render('(~opt :show true :label "yes")', hw_env) + ref_html = ref_render('(~opt :show true :label "yes")', ref_env) + assert hw_html == ref_html + hw_html2 = hw_render('(~opt :show false :label "no")', hw_env) + ref_html2 = ref_render('(~opt :show false :label "no")', ref_env) + assert hw_html2 == ref_html2 + + +# --------------------------------------------------------------------------- +# Render parity: Macros in render +# --------------------------------------------------------------------------- + +class TestParityRenderMacros: + def test_macro_in_render(self): + src = '(defmacro bold (text) `(strong ,text))' + hw_env = {} + ref_env = {} + hw_eval(src, hw_env) + ref_eval(src, ref_env) + assert_render_parity('(bold "hello")', hw_env, ref_env) + + +# --------------------------------------------------------------------------- +# Render parity: raw! and html: prefix +# --------------------------------------------------------------------------- + +class TestParityRenderSpecial: + def test_raw_html(self): + assert_render_parity('(raw! "bold")') + + def test_style_attr(self): + assert_render_parity('(div :style "color:red" "text")') + + def test_data_attr(self): + assert_render_parity('(div :data-id "123" "text")') + + +# --------------------------------------------------------------------------- +# Deps parity +# --------------------------------------------------------------------------- + +class TestParityDeps: + def _make_envs(self, *sources): + hw_env = {} + ref_env = {} + for src in sources: + hw_eval_multi(src, hw_env) + ref_eval_multi(src, ref_env) + return hw_env, ref_env + + def test_transitive_deps(self): + from shared.sx.deps import _transitive_deps_fallback + from shared.sx.ref.sx_ref import transitive_deps as ref_td + hw_env, ref_env = self._make_envs( + '(defcomp ~page (&key) (div (~layout)))', + '(defcomp ~layout (&key) (div (~header) (~footer)))', + '(defcomp ~header (&key) (nav "header"))', + '(defcomp ~footer (&key) (footer "footer"))', + ) + hw_deps = _transitive_deps_fallback("~page", hw_env) + ref_deps = set(ref_td("~page", ref_env)) + assert hw_deps == ref_deps + + def test_compute_all_deps(self): + from shared.sx.deps import _compute_all_deps_fallback + from shared.sx.ref.sx_ref import compute_all_deps as ref_cad + hw_env, ref_env = self._make_envs( + '(defcomp ~a (&key) (div (~b)))', + '(defcomp ~b (&key) (div (~c)))', + '(defcomp ~c (&key) (span "leaf"))', + ) + _compute_all_deps_fallback(hw_env) + ref_cad(ref_env) + for key in ("~a", "~b", "~c"): + hw_d = hw_env[key].deps or set() + ref_d = ref_env[key].deps + assert set(hw_d) == set(ref_d), f"Deps mismatch for {key}" + + def test_scan_components_from_sx(self): + from shared.sx.deps import _scan_components_from_sx_fallback + from shared.sx.ref.sx_ref import scan_components_from_source as ref_sc + source = '(~card :title "hi" (~badge :label "new"))' + hw = _scan_components_from_sx_fallback(source) + ref = set(ref_sc(source)) + assert hw == ref + + def test_io_refs_parity(self): + from shared.sx.deps import _compute_all_io_refs_fallback + from shared.sx.ref.sx_ref import compute_all_io_refs as ref_cio + io_names = {"highlight", "app-url", "config", "fetch-data"} + hw_env, ref_env = self._make_envs( + '(defcomp ~page (&key) (div (~nav) (fetch-data "x")))', + '(defcomp ~nav (&key) (nav (app-url "/")))', + '(defcomp ~pure (&key) (div "hello"))', + ) + _compute_all_io_refs_fallback(hw_env, io_names) + ref_cio(ref_env, list(io_names)) + for key in ("~page", "~nav", "~pure"): + hw_refs = hw_env[key].io_refs or set() + ref_refs = ref_env[key].io_refs + assert set(hw_refs) == set(ref_refs), f"IO refs mismatch for {key}" + + +# --------------------------------------------------------------------------- +# Error parity +# --------------------------------------------------------------------------- + +class TestParityErrors: + def test_undefined_symbol(self): + from shared.sx.evaluator import EvalError as HwError + from shared.sx.ref.sx_ref import EvalError as RefError + with pytest.raises(HwError): + hw_eval("undefined_var") + with pytest.raises(RefError): + ref_eval("undefined_var")