Files
stack/tests/sem/test_parse.py
kert 59eb56f659
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m18s
CI / skinny-install (api) (push) Successful in 40s
CI / skinny-install (bcda) (push) Successful in 35s
CI / skinny-install (bib) (push) Successful in 38s
CI / skinny-install (cli) (push) Successful in 46s
CI / skinny-install (conf) (push) Successful in 36s
CI / skinny-install (opps) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 47s
CI / skinny-install (rex) (push) Successful in 35s
Infra CI / notebooks (push) Successful in 3m17s
CI / lint-test (push) Failing after 3m30s
CI / skinny-install (bls) (push) Successful in 34s
CI / skinny-install (ccw) (push) Successful in 45s
CI / skinny-install (cms) (push) Successful in 32s
CI / skinny-install (perf) (push) Successful in 43s
Deploy / build-scan-report (push) Has been cancelled
Infra CI / docs (push) Failing after 20s
Infra CI / api (push) Successful in 16s
Infra CI / mc (push) Successful in 12s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m27s
Infra CI / zotero (push) Successful in 6m10s
chore: hw provisioning, test coverage, deps
2026-04-09 22:26:31 -04:00

229 lines
7.2 KiB
Python

"""Tests for sem.parse — AST to semantic nodes."""
from __future__ import annotations
import ast
import textwrap
from sem.nodes import NodeKind
from sem.parse import parse_tree
def _parse(source: str, module: str = "test_mod") -> list:
source = textwrap.dedent(source)
tree = ast.parse(source)
return parse_tree(tree, module, source)
class TestFunctionNodes:
def test_top_level_function(self):
nodes = _parse("""
def greet(name):
return f"hi {name}"
""")
funcs = [n for n in nodes if n.kind == NodeKind.FUNCTION]
assert len(funcs) == 1
assert "greet" in funcs[0].node_id
assert funcs[0].module == "test_mod"
def test_method_inside_class(self):
nodes = _parse("""
class Foo:
def bar(self):
pass
""")
classes = [n for n in nodes if n.kind == NodeKind.CLASS]
assert len(classes) == 1
# The method is nested inside the class context
methods = [n for n in nodes if n.kind in (NodeKind.FUNCTION, NodeKind.METHOD)]
assert len(methods) == 1
assert "Foo" in methods[0].node_id
assert "bar" in methods[0].node_id
def test_async_function(self):
nodes = _parse("""
async def fetch():
return 42
""")
funcs = [n for n in nodes if n.kind == NodeKind.FUNCTION]
assert len(funcs) == 1
assert "fetch" in funcs[0].node_id
class TestBranchNodes:
def test_if_else(self):
nodes = _parse("""
def check(x):
if x > 0:
return "pos"
else:
return "neg"
""")
ifs = [n for n in nodes if n.kind == NodeKind.BRANCH_IF]
elses = [n for n in nodes if n.kind == NodeKind.BRANCH_ELSE]
assert len(ifs) == 1
assert len(elses) == 1
def test_elif(self):
nodes = _parse("""
def classify(x):
if x > 0:
return "pos"
elif x == 0:
return "zero"
else:
return "neg"
""")
elifs = [n for n in nodes if n.kind == NodeKind.BRANCH_ELIF]
assert len(elifs) == 1
def test_match_case(self):
nodes = _parse("""
def route(cmd):
match cmd:
case "start":
pass
case "stop":
pass
case _:
pass
""")
cases = [n for n in nodes if n.kind == NodeKind.MATCH_CASE]
assert len(cases) == 3
class TestExceptionNodes:
def test_except_handler(self):
nodes = _parse("""
def risky():
try:
open("f")
except FileNotFoundError:
pass
except ValueError:
pass
""")
handlers = [n for n in nodes if n.kind == NodeKind.EXCEPT_HANDLER]
assert len(handlers) == 2
class TestControlFlow:
def test_for_loop(self):
nodes = _parse("""
for i in range(10):
print(i)
""")
loops = [n for n in nodes if n.kind == NodeKind.FOR_LOOP]
assert len(loops) == 1
def test_async_for_loop(self):
nodes = _parse("""
async def fetch_all():
async for item in aiter():
process(item)
""")
loops = [n for n in nodes if n.kind == NodeKind.FOR_LOOP]
assert len(loops) == 1
def test_with_block(self):
nodes = _parse("""
def process():
with open("f") as fh:
fh.read()
""")
withs = [n for n in nodes if n.kind == NodeKind.WITH_BLOCK]
assert len(withs) == 1
def test_async_with_block(self):
nodes = _parse("""
async def process():
async with aopen("f") as fh:
await fh.read()
""")
withs = [n for n in nodes if n.kind == NodeKind.WITH_BLOCK]
assert len(withs) == 1
def test_assert_node(self):
nodes = _parse("""
def check(x):
assert x > 0, "must be positive"
""")
asserts = [n for n in nodes if n.kind == NodeKind.ASSERT]
assert len(asserts) == 1
def test_while_loop(self):
nodes = _parse("""
while True:
break
""")
loops = [n for n in nodes if n.kind == NodeKind.WHILE_LOOP]
assert len(loops) == 1
def test_return_and_raise(self):
nodes = _parse("""
def f(x):
if x:
return 1
raise ValueError("bad")
""")
returns = [n for n in nodes if n.kind == NodeKind.RETURN]
raises = [n for n in nodes if n.kind == NodeKind.RAISE]
assert len(returns) == 1
assert len(raises) == 1
class TestNodeIdentity:
def test_node_id_is_stable(self):
"""Same source should produce identical node IDs across parses."""
src = "def f():\n return 1\n"
nodes_a = parse_tree(ast.parse(src), "mod", src)
nodes_b = parse_tree(ast.parse(src), "mod", src)
ids_a = [n.node_id for n in nodes_a]
ids_b = [n.node_id for n in nodes_b]
assert ids_a == ids_b
def test_source_hash_changes_with_content(self):
src_a = "def f():\n return 1\n"
src_b = "def f():\n return 2\n"
nodes_a = parse_tree(ast.parse(src_a), "mod", src_a)
nodes_b = parse_tree(ast.parse(src_b), "mod", src_b)
funcs_a = [n for n in nodes_a if n.kind == NodeKind.FUNCTION]
funcs_b = [n for n in nodes_b if n.kind == NodeKind.FUNCTION]
assert funcs_a[0].source_hash != funcs_b[0].source_hash
def test_parse_module_from_file(self, tmp_path):
"""parse_module reads a file and produces nodes."""
from sem.parse import parse_module
src = tmp_path / "example.py"
src.write_text("def hello():\n return 1\n")
nodes = parse_module(src)
funcs = [n for n in nodes if n.kind == NodeKind.FUNCTION]
assert len(funcs) == 1
assert "hello" in funcs[0].node_id
# Module name derived from file stem
assert funcs[0].module == "example"
def test_parse_module_with_explicit_name(self, tmp_path):
"""parse_module uses explicit module name when provided."""
from sem.parse import parse_module
src = tmp_path / "foo.py"
src.write_text("x = 1\n")
nodes = parse_module(src, module="my.custom.module")
# No function nodes, but module name should be set
assert all(n.module == "my.custom.module" for n in nodes) or len(nodes) == 0
def test_node_id_no_line_numbers(self):
"""Node IDs must not contain raw line numbers."""
nodes = _parse("""
def hello():
if True:
return 1
""")
for node in nodes:
# Line numbers appear in span, not in node_id
assert node.span.start_line > 0
# node_id uses ordinal, not line number
assert ":L" not in node.node_id