Phase 1 — Macros: defmacro + quasiquote syntax (`, ,, ,@) in parser,
evaluator, HTML renderer, and JS mirror. Macro type, expansion, and
round-trip serialization.
Phase 2 — Expanded primitives: app-url, url-for, asset-url, config,
format-date, parse-int (pure); service, request-arg, request-path,
nav-tree, get-children (I/O); jinja-global, relations-from (pure).
Updated _io_service to accept (service "registry-name" "method" :kwargs)
with auto kebab→snake conversion. DTO-to-dict now expands datetime fields
into year/month/day convenience keys. Tuple returns converted to lists.
Phase 3 — Declarative handlers: HandlerDef type, defhandler special form,
handler registry (service → name → HandlerDef), async evaluator+renderer
(async_eval.py) that awaits I/O primitives inline within control flow.
Handler loading from .sx files, execute_handler, blueprint factory.
Phase 4 — Convert all fragment routes: 13 Python fragment handlers across
8 services replaced with declarative .sx handler files. All routes.py
simplified to uniform sx dispatch pattern. Two Jinja HTML handlers
(events/container-cards, events/account-page) kept as Python.
New files: shared/sx/async_eval.py, shared/sx/handlers.py,
shared/sx/tests/test_handlers.py, plus 13 handler .sx files under
{service}/sx/handlers/. MarketService.product_by_slug() added.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
238 lines
6.7 KiB
Python
238 lines
6.7 KiB
Python
"""Tests for the s-expression parser."""
|
|
|
|
import pytest
|
|
from shared.sx.parser import parse, parse_all, serialize, ParseError
|
|
from shared.sx.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") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Quasiquote
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestQuasiquote:
|
|
def test_quasiquote_symbol(self):
|
|
result = parse("`x")
|
|
assert result == [Symbol("quasiquote"), Symbol("x")]
|
|
|
|
def test_quasiquote_list(self):
|
|
result = parse("`(a b c)")
|
|
assert result == [Symbol("quasiquote"), [Symbol("a"), Symbol("b"), Symbol("c")]]
|
|
|
|
def test_unquote(self):
|
|
result = parse(",x")
|
|
assert result == [Symbol("unquote"), Symbol("x")]
|
|
|
|
def test_splice_unquote(self):
|
|
result = parse(",@xs")
|
|
assert result == [Symbol("splice-unquote"), Symbol("xs")]
|
|
|
|
def test_quasiquote_with_unquote(self):
|
|
result = parse("`(a ,x b)")
|
|
assert result == [Symbol("quasiquote"), [
|
|
Symbol("a"),
|
|
[Symbol("unquote"), Symbol("x")],
|
|
Symbol("b"),
|
|
]]
|
|
|
|
def test_quasiquote_with_splice(self):
|
|
result = parse("`(a ,@rest)")
|
|
assert result == [Symbol("quasiquote"), [
|
|
Symbol("a"),
|
|
[Symbol("splice-unquote"), Symbol("rest")],
|
|
]]
|
|
|
|
def test_roundtrip_quasiquote(self):
|
|
assert serialize(parse("`(a ,x ,@rest)")) == "`(a ,x ,@rest)"
|
|
|
|
def test_roundtrip_unquote(self):
|
|
assert serialize(parse(",x")) == ",x"
|
|
|
|
def test_roundtrip_splice_unquote(self):
|
|
assert serialize(parse(",@xs")) == ",@xs"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|