Phase 1: s-expression core library + test infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
S-expression parser, evaluator, and primitive registry in shared/sexp/. 109 unit tests covering parsing, evaluation, special forms, lambdas, closures, components (defcomp), and 60+ pure builtins. Test infrastructure: Dockerfile.unit (tier 1, fast) and Dockerfile.integration (tier 2, ffmpeg). Dev watch mode auto-reruns on file changes. Deploy gate blocks push on test failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
shared/sexp/tests/__init__.py
Normal file
0
shared/sexp/tests/__init__.py
Normal file
326
shared/sexp/tests/test_evaluator.py
Normal file
326
shared/sexp/tests/test_evaluator.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""Tests for the s-expression evaluator."""
|
||||
|
||||
import pytest
|
||||
from shared.sexp import parse, evaluate, EvalError, Symbol, Keyword, NIL
|
||||
from shared.sexp.types import Lambda, Component
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ev(text, env=None):
|
||||
"""Parse and evaluate a single expression."""
|
||||
return evaluate(parse(text), env)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Literals and lookups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiterals:
|
||||
def test_int(self):
|
||||
assert ev("42") == 42
|
||||
|
||||
def test_string(self):
|
||||
assert ev('"hello"') == "hello"
|
||||
|
||||
def test_true(self):
|
||||
assert ev("true") is True
|
||||
|
||||
def test_nil(self):
|
||||
assert ev("nil") is NIL
|
||||
|
||||
def test_symbol_lookup(self):
|
||||
assert ev("x", {"x": 10}) == 10
|
||||
|
||||
def test_undefined_symbol(self):
|
||||
with pytest.raises(EvalError, match="Undefined symbol"):
|
||||
ev("xyz")
|
||||
|
||||
def test_keyword_evaluates_to_name(self):
|
||||
assert ev(":foo") == "foo"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestArithmetic:
|
||||
def test_add(self):
|
||||
assert ev("(+ 1 2 3)") == 6
|
||||
|
||||
def test_sub(self):
|
||||
assert ev("(- 10 3)") == 7
|
||||
|
||||
def test_negate(self):
|
||||
assert ev("(- 5)") == -5
|
||||
|
||||
def test_mul(self):
|
||||
assert ev("(* 2 3 4)") == 24
|
||||
|
||||
def test_div(self):
|
||||
assert ev("(/ 10 4)") == 2.5
|
||||
|
||||
def test_mod(self):
|
||||
assert ev("(mod 7 3)") == 1
|
||||
|
||||
def test_clamp(self):
|
||||
assert ev("(clamp 15 0 10)") == 10
|
||||
assert ev("(clamp -5 0 10)") == 0
|
||||
assert ev("(clamp 5 0 10)") == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comparison and predicates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComparison:
|
||||
def test_eq(self):
|
||||
assert ev("(= 1 1)") is True
|
||||
assert ev("(= 1 2)") is False
|
||||
|
||||
def test_lt_gt(self):
|
||||
assert ev("(< 1 2)") is True
|
||||
assert ev("(> 2 1)") is True
|
||||
|
||||
def test_predicates(self):
|
||||
assert ev("(odd? 3)") is True
|
||||
assert ev("(even? 4)") is True
|
||||
assert ev("(zero? 0)") is True
|
||||
assert ev("(nil? nil)") is True
|
||||
assert ev('(string? "hi")') is True
|
||||
assert ev("(number? 42)") is True
|
||||
assert ev("(list? (list 1))") is True
|
||||
assert ev("(dict? {:a 1})") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Special forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpecialForms:
|
||||
def test_if_true(self):
|
||||
assert ev("(if true 1 2)") == 1
|
||||
|
||||
def test_if_false(self):
|
||||
assert ev("(if false 1 2)") == 2
|
||||
|
||||
def test_if_no_else(self):
|
||||
assert ev("(if false 1)") is NIL
|
||||
|
||||
def test_when_true(self):
|
||||
assert ev("(when true 42)") == 42
|
||||
|
||||
def test_when_false(self):
|
||||
assert ev("(when false 42)") is NIL
|
||||
|
||||
def test_and_short_circuit(self):
|
||||
assert ev("(and true true 3)") == 3
|
||||
assert ev("(and true false 3)") is False
|
||||
|
||||
def test_or_short_circuit(self):
|
||||
assert ev("(or false false 3)") == 3
|
||||
assert ev("(or false 2 3)") == 2
|
||||
|
||||
def test_let_scheme_style(self):
|
||||
assert ev("(let ((x 10) (y 20)) (+ x y))") == 30
|
||||
|
||||
def test_let_clojure_style(self):
|
||||
assert ev("(let (x 10 y 20) (+ x y))") == 30
|
||||
|
||||
def test_let_sequential(self):
|
||||
assert ev("(let ((x 1) (y (+ x 1))) y)") == 2
|
||||
|
||||
def test_begin(self):
|
||||
assert ev("(begin 1 2 3)") == 3
|
||||
|
||||
def test_quote(self):
|
||||
result = ev("(quote (a b c))")
|
||||
assert result == [Symbol("a"), Symbol("b"), Symbol("c")]
|
||||
|
||||
def test_cond_clojure(self):
|
||||
assert ev("(cond false 1 true 2 :else 3)") == 2
|
||||
|
||||
def test_cond_else(self):
|
||||
assert ev("(cond false 1 false 2 :else 99)") == 99
|
||||
|
||||
def test_case(self):
|
||||
assert ev('(case 2 1 "one" 2 "two" :else "other")') == "two"
|
||||
|
||||
def test_thread_first(self):
|
||||
assert ev("(-> 5 (+ 3) (* 2))") == 16
|
||||
|
||||
def test_define(self):
|
||||
env = {}
|
||||
ev("(define x 42)", env)
|
||||
assert env["x"] == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lambda
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLambda:
|
||||
def test_create_and_call(self):
|
||||
assert ev("((fn (x) (* x x)) 5)") == 25
|
||||
|
||||
def test_closure(self):
|
||||
result = ev("(let ((a 10)) ((fn (x) (+ x a)) 5))")
|
||||
assert result == 15
|
||||
|
||||
def test_higher_order(self):
|
||||
result = ev("(let ((double (fn (x) (* x 2)))) (double 7))")
|
||||
assert result == 14
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCollections:
|
||||
def test_list_constructor(self):
|
||||
assert ev("(list 1 2 3)") == [1, 2, 3]
|
||||
|
||||
def test_dict_constructor(self):
|
||||
assert ev("(dict :a 1 :b 2)") == {"a": 1, "b": 2}
|
||||
|
||||
def test_get_dict(self):
|
||||
assert ev('(get {:a 1 :b 2} "a")') == 1
|
||||
|
||||
def test_get_list(self):
|
||||
assert ev("(get (list 10 20 30) 1)") == 20
|
||||
|
||||
def test_first_last_rest(self):
|
||||
assert ev("(first (list 1 2 3))") == 1
|
||||
assert ev("(last (list 1 2 3))") == 3
|
||||
assert ev("(rest (list 1 2 3))") == [2, 3]
|
||||
|
||||
def test_len(self):
|
||||
assert ev("(len (list 1 2 3))") == 3
|
||||
|
||||
def test_concat(self):
|
||||
assert ev("(concat (list 1 2) (list 3 4))") == [1, 2, 3, 4]
|
||||
|
||||
def test_cons(self):
|
||||
assert ev("(cons 0 (list 1 2))") == [0, 1, 2]
|
||||
|
||||
def test_keys_vals(self):
|
||||
assert ev("(keys {:a 1 :b 2})") == ["a", "b"]
|
||||
assert ev("(vals {:a 1 :b 2})") == [1, 2]
|
||||
|
||||
def test_merge(self):
|
||||
assert ev("(merge {:a 1} {:b 2} {:a 3})") == {"a": 3, "b": 2}
|
||||
|
||||
def test_assoc(self):
|
||||
assert ev('(assoc {:a 1} :b 2)') == {"a": 1, "b": 2}
|
||||
|
||||
def test_dissoc(self):
|
||||
assert ev('(dissoc {:a 1 :b 2} :a)') == {"b": 2}
|
||||
|
||||
def test_empty(self):
|
||||
assert ev("(empty? (list))") is True
|
||||
assert ev("(empty? (list 1))") is False
|
||||
assert ev("(empty? nil)") is True
|
||||
|
||||
def test_contains(self):
|
||||
assert ev('(contains? {:a 1} "a")') is True
|
||||
assert ev("(contains? (list 1 2 3) 2)") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Higher-order forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHigherOrder:
|
||||
def test_map(self):
|
||||
assert ev("(map (fn (x) (* x x)) (list 1 2 3 4))") == [1, 4, 9, 16]
|
||||
|
||||
def test_map_indexed(self):
|
||||
result = ev("(map-indexed (fn (i x) (+ i x)) (list 10 20 30))")
|
||||
assert result == [10, 21, 32]
|
||||
|
||||
def test_filter(self):
|
||||
assert ev("(filter (fn (x) (> x 2)) (list 1 2 3 4))") == [3, 4]
|
||||
|
||||
def test_reduce(self):
|
||||
assert ev("(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3))") == 6
|
||||
|
||||
def test_some(self):
|
||||
assert ev("(some (fn (x) (if (> x 3) x nil)) (list 1 2 4 5))") == 4
|
||||
|
||||
def test_every(self):
|
||||
assert ev("(every? (fn (x) (> x 0)) (list 1 2 3))") is True
|
||||
assert ev("(every? (fn (x) (> x 2)) (list 1 2 3))") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStrings:
|
||||
def test_str(self):
|
||||
assert ev('(str "hello" " " "world")') == "hello world"
|
||||
|
||||
def test_str_numbers(self):
|
||||
assert ev('(str "val=" 42)') == "val=42"
|
||||
|
||||
def test_upper_lower(self):
|
||||
assert ev('(upper "hello")') == "HELLO"
|
||||
assert ev('(lower "HELLO")') == "hello"
|
||||
|
||||
def test_join(self):
|
||||
assert ev('(join ", " (list "a" "b" "c"))') == "a, b, c"
|
||||
|
||||
def test_split(self):
|
||||
assert ev('(split "a,b,c" ",")') == ["a", "b", "c"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# defcomp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDefcomp:
|
||||
def test_basic_component(self):
|
||||
env = {}
|
||||
ev("(defcomp ~card (&key title) title)", env)
|
||||
assert isinstance(env["~card"], Component)
|
||||
assert env["~card"].name == "card"
|
||||
|
||||
def test_component_call(self):
|
||||
env = {}
|
||||
ev("(defcomp ~greeting (&key name) (str \"Hello, \" name \"!\"))", env)
|
||||
result = ev('(~greeting :name "Alice")', env)
|
||||
assert result == "Hello, Alice!"
|
||||
|
||||
def test_component_with_children(self):
|
||||
env = {}
|
||||
ev("(defcomp ~wrapper (&key class &rest children) (list class children))", env)
|
||||
result = ev('(~wrapper :class "box" 1 2 3)', env)
|
||||
assert result == ["box", [1, 2, 3]]
|
||||
|
||||
def test_component_missing_kwarg_is_nil(self):
|
||||
env = {}
|
||||
ev("(defcomp ~opt (&key x y) (list x y))", env)
|
||||
result = ev("(~opt :x 1)", env)
|
||||
assert result == [1, NIL]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dict literal evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDictLiteral:
|
||||
def test_dict_values_evaluated(self):
|
||||
assert ev("{:a (+ 1 2) :b (* 3 4)}") == {"a": 3, "b": 12}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set!
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetBang:
|
||||
def test_set_bang(self):
|
||||
env = {"x": 1}
|
||||
ev("(set! x 42)", env)
|
||||
assert env["x"] == 42
|
||||
191
shared/sexp/tests/test_parser.py
Normal file
191
shared/sexp/tests/test_parser.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for the s-expression parser."""
|
||||
|
||||
import pytest
|
||||
from shared.sexp.parser import parse, parse_all, serialize, ParseError
|
||||
from shared.sexp.types import Symbol, Keyword, NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atoms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAtoms:
|
||||
def test_integer(self):
|
||||
assert parse("42") == 42
|
||||
|
||||
def test_negative_integer(self):
|
||||
assert parse("-7") == -7
|
||||
|
||||
def test_float(self):
|
||||
assert parse("3.14") == 3.14
|
||||
|
||||
def test_scientific(self):
|
||||
assert parse("1e-3") == 0.001
|
||||
|
||||
def test_string(self):
|
||||
assert parse('"hello world"') == "hello world"
|
||||
|
||||
def test_string_escapes(self):
|
||||
assert parse(r'"line1\nline2"') == "line1\nline2"
|
||||
assert parse(r'"tab\there"') == "tab\there"
|
||||
assert parse(r'"say \"hi\""') == 'say "hi"'
|
||||
|
||||
def test_symbol(self):
|
||||
assert parse("foo") == Symbol("foo")
|
||||
|
||||
def test_component_symbol(self):
|
||||
s = parse("~card")
|
||||
assert s == Symbol("~card")
|
||||
assert s.is_component
|
||||
|
||||
def test_keyword(self):
|
||||
assert parse(":class") == Keyword("class")
|
||||
|
||||
def test_true(self):
|
||||
assert parse("true") is True
|
||||
|
||||
def test_false(self):
|
||||
assert parse("false") is False
|
||||
|
||||
def test_nil(self):
|
||||
assert parse("nil") is NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLists:
|
||||
def test_empty_list(self):
|
||||
assert parse("()") == []
|
||||
|
||||
def test_simple_list(self):
|
||||
assert parse("(1 2 3)") == [1, 2, 3]
|
||||
|
||||
def test_mixed_list(self):
|
||||
result = parse('(div :class "main")')
|
||||
assert result == [Symbol("div"), Keyword("class"), "main"]
|
||||
|
||||
def test_nested_list(self):
|
||||
result = parse("(a (b c) d)")
|
||||
assert result == [Symbol("a"), [Symbol("b"), Symbol("c")], Symbol("d")]
|
||||
|
||||
def test_vector_sugar(self):
|
||||
assert parse("[1 2 3]") == [1, 2, 3]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMaps:
|
||||
def test_simple_map(self):
|
||||
result = parse('{:a 1 :b 2}')
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
def test_nested_map(self):
|
||||
result = parse('{:x {:y 3}}')
|
||||
assert result == {"x": {"y": 3}}
|
||||
|
||||
def test_string_keys(self):
|
||||
result = parse('{"name" "alice"}')
|
||||
assert result == {"name": "alice"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComments:
|
||||
def test_line_comment(self):
|
||||
assert parse("; comment\n42") == 42
|
||||
|
||||
def test_inline_comment(self):
|
||||
result = parse("(a ; stuff\nb)")
|
||||
assert result == [Symbol("a"), Symbol("b")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseAll:
|
||||
def test_multiple(self):
|
||||
results = parse_all("1 2 3")
|
||||
assert results == [1, 2, 3]
|
||||
|
||||
def test_multiple_lists(self):
|
||||
results = parse_all("(a) (b)")
|
||||
assert results == [[Symbol("a")], [Symbol("b")]]
|
||||
|
||||
def test_empty(self):
|
||||
assert parse_all("") == []
|
||||
assert parse_all(" ; only comments\n") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestErrors:
|
||||
def test_unterminated_list(self):
|
||||
with pytest.raises(ParseError):
|
||||
parse("(a b")
|
||||
|
||||
def test_unterminated_string(self):
|
||||
with pytest.raises(ParseError):
|
||||
parse('"hello')
|
||||
|
||||
def test_unexpected_closer(self):
|
||||
with pytest.raises(ParseError):
|
||||
parse(")")
|
||||
|
||||
def test_trailing_content(self):
|
||||
with pytest.raises(ParseError):
|
||||
parse("1 2")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSerialize:
|
||||
def test_int(self):
|
||||
assert serialize(42) == "42"
|
||||
|
||||
def test_float(self):
|
||||
assert serialize(3.14) == "3.14"
|
||||
|
||||
def test_string(self):
|
||||
assert serialize("hello") == '"hello"'
|
||||
|
||||
def test_string_escapes(self):
|
||||
assert serialize('say "hi"') == '"say \\"hi\\""'
|
||||
|
||||
def test_symbol(self):
|
||||
assert serialize(Symbol("foo")) == "foo"
|
||||
|
||||
def test_keyword(self):
|
||||
assert serialize(Keyword("class")) == ":class"
|
||||
|
||||
def test_bool(self):
|
||||
assert serialize(True) == "true"
|
||||
assert serialize(False) == "false"
|
||||
|
||||
def test_nil(self):
|
||||
assert serialize(None) == "nil"
|
||||
assert serialize(NIL) == "nil"
|
||||
|
||||
def test_list(self):
|
||||
assert serialize([Symbol("a"), 1, 2]) == "(a 1 2)"
|
||||
|
||||
def test_empty_list(self):
|
||||
assert serialize([]) == "()"
|
||||
|
||||
def test_dict(self):
|
||||
result = serialize({"a": 1, "b": 2})
|
||||
assert result == "{:a 1 :b 2}"
|
||||
|
||||
def test_roundtrip(self):
|
||||
original = '(div :class "main" (p "hello") (span 42))'
|
||||
assert serialize(parse(original)) == original
|
||||
Reference in New Issue
Block a user