All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m37s
Rename all sexp directories, files, identifiers, and references to sx. artdag/ excluded (separate media processing DSL). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
8.1 KiB
Python
246 lines
8.1 KiB
Python
"""Tests for the relation registry (Phase A)."""
|
|
|
|
import pytest
|
|
|
|
from shared.sx.evaluator import evaluate, EvalError
|
|
from shared.sx.parser import parse
|
|
from shared.sx.relations import (
|
|
_RELATION_REGISTRY,
|
|
clear_registry,
|
|
get_relation,
|
|
load_relation_registry,
|
|
relations_from,
|
|
relations_to,
|
|
all_relations,
|
|
)
|
|
from shared.sx.types import RelationDef
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_registry():
|
|
"""Clear registry before each test."""
|
|
clear_registry()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# defrelation parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDefrelation:
|
|
def test_basic_defrelation(self):
|
|
tree = parse('''
|
|
(defrelation :page->market
|
|
:from "page"
|
|
:to "market"
|
|
:cardinality :one-to-many
|
|
:inverse :market->page
|
|
:nav :submenu
|
|
:nav-icon "fa fa-shopping-bag"
|
|
:nav-label "markets")
|
|
''')
|
|
result = evaluate(tree)
|
|
assert isinstance(result, RelationDef)
|
|
assert result.name == "page->market"
|
|
assert result.from_type == "page"
|
|
assert result.to_type == "market"
|
|
assert result.cardinality == "one-to-many"
|
|
assert result.inverse == "market->page"
|
|
assert result.nav == "submenu"
|
|
assert result.nav_icon == "fa fa-shopping-bag"
|
|
assert result.nav_label == "markets"
|
|
|
|
def test_defrelation_registered(self):
|
|
tree = parse('''
|
|
(defrelation :a->b
|
|
:from "a" :to "b" :cardinality :one-to-one :nav :hidden)
|
|
''')
|
|
evaluate(tree)
|
|
assert get_relation("a->b") is not None
|
|
assert get_relation("a->b").cardinality == "one-to-one"
|
|
|
|
def test_defrelation_one_to_one(self):
|
|
tree = parse('''
|
|
(defrelation :page->menu_node
|
|
:from "page" :to "menu_node"
|
|
:cardinality :one-to-one :nav :hidden)
|
|
''')
|
|
result = evaluate(tree)
|
|
assert result.cardinality == "one-to-one"
|
|
assert result.inverse is None
|
|
assert result.nav == "hidden"
|
|
|
|
def test_defrelation_many_to_many(self):
|
|
tree = parse('''
|
|
(defrelation :post->entry
|
|
:from "post" :to "calendar_entry"
|
|
:cardinality :many-to-many
|
|
:inverse :entry->post
|
|
:nav :inline
|
|
:nav-icon "fa fa-file-alt"
|
|
:nav-label "events")
|
|
''')
|
|
result = evaluate(tree)
|
|
assert result.cardinality == "many-to-many"
|
|
|
|
def test_default_nav_is_hidden(self):
|
|
tree = parse('''
|
|
(defrelation :x->y
|
|
:from "x" :to "y" :cardinality :one-to-many)
|
|
''')
|
|
result = evaluate(tree)
|
|
assert result.nav == "hidden"
|
|
|
|
def test_invalid_cardinality_raises(self):
|
|
tree = parse('''
|
|
(defrelation :bad
|
|
:from "a" :to "b" :cardinality :wrong)
|
|
''')
|
|
with pytest.raises(EvalError, match="invalid cardinality"):
|
|
evaluate(tree)
|
|
|
|
def test_invalid_nav_raises(self):
|
|
tree = parse('''
|
|
(defrelation :bad
|
|
:from "a" :to "b" :cardinality :one-to-one :nav :bogus)
|
|
''')
|
|
with pytest.raises(EvalError, match="invalid nav"):
|
|
evaluate(tree)
|
|
|
|
def test_missing_from_raises(self):
|
|
tree = parse('''
|
|
(defrelation :bad :to "b" :cardinality :one-to-one)
|
|
''')
|
|
with pytest.raises(EvalError, match="missing required :from"):
|
|
evaluate(tree)
|
|
|
|
def test_missing_to_raises(self):
|
|
tree = parse('''
|
|
(defrelation :bad :from "a" :cardinality :one-to-one)
|
|
''')
|
|
with pytest.raises(EvalError, match="missing required :to"):
|
|
evaluate(tree)
|
|
|
|
def test_missing_cardinality_raises(self):
|
|
tree = parse('''
|
|
(defrelation :bad :from "a" :to "b")
|
|
''')
|
|
with pytest.raises(EvalError, match="missing required :cardinality"):
|
|
evaluate(tree)
|
|
|
|
def test_name_must_be_keyword(self):
|
|
tree = parse('(defrelation "not-keyword" :from "a" :to "b" :cardinality :one-to-one)')
|
|
with pytest.raises(EvalError, match="must be a keyword"):
|
|
evaluate(tree)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRegistry:
|
|
def _load_sample(self):
|
|
tree = parse('''
|
|
(begin
|
|
(defrelation :page->market
|
|
:from "page" :to "market" :cardinality :one-to-many
|
|
:nav :submenu :nav-icon "fa fa-shopping-bag" :nav-label "markets")
|
|
(defrelation :page->calendar
|
|
:from "page" :to "calendar" :cardinality :one-to-many
|
|
:nav :submenu :nav-icon "fa fa-calendar" :nav-label "calendars")
|
|
(defrelation :post->entry
|
|
:from "post" :to "calendar_entry" :cardinality :many-to-many
|
|
:nav :inline)
|
|
(defrelation :page->menu_node
|
|
:from "page" :to "menu_node" :cardinality :one-to-one
|
|
:nav :hidden))
|
|
''')
|
|
evaluate(tree)
|
|
|
|
def test_get_relation(self):
|
|
self._load_sample()
|
|
rel = get_relation("page->market")
|
|
assert rel is not None
|
|
assert rel.to_type == "market"
|
|
|
|
def test_get_relation_not_found(self):
|
|
assert get_relation("nonexistent") is None
|
|
|
|
def test_relations_from_page(self):
|
|
self._load_sample()
|
|
rels = relations_from("page")
|
|
names = {r.name for r in rels}
|
|
assert names == {"page->market", "page->calendar", "page->menu_node"}
|
|
|
|
def test_relations_from_post(self):
|
|
self._load_sample()
|
|
rels = relations_from("post")
|
|
assert len(rels) == 1
|
|
assert rels[0].name == "post->entry"
|
|
|
|
def test_relations_from_empty(self):
|
|
self._load_sample()
|
|
assert relations_from("nonexistent") == []
|
|
|
|
def test_relations_to_market(self):
|
|
self._load_sample()
|
|
rels = relations_to("market")
|
|
assert len(rels) == 1
|
|
assert rels[0].name == "page->market"
|
|
|
|
def test_relations_to_calendar_entry(self):
|
|
self._load_sample()
|
|
rels = relations_to("calendar_entry")
|
|
assert len(rels) == 1
|
|
assert rels[0].name == "post->entry"
|
|
|
|
def test_all_relations(self):
|
|
self._load_sample()
|
|
assert len(all_relations()) == 4
|
|
|
|
def test_clear_registry(self):
|
|
self._load_sample()
|
|
assert len(all_relations()) == 4
|
|
clear_registry()
|
|
assert len(all_relations()) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# load_relation_registry() — built-in definitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLoadBuiltins:
|
|
def test_loads_builtin_relations(self):
|
|
load_relation_registry()
|
|
assert get_relation("page->market") is not None
|
|
assert get_relation("page->calendar") is not None
|
|
assert get_relation("post->calendar_entry") is not None
|
|
assert get_relation("page->menu_node") is not None
|
|
|
|
def test_builtin_page_market(self):
|
|
load_relation_registry()
|
|
rel = get_relation("page->market")
|
|
assert rel.from_type == "page"
|
|
assert rel.to_type == "market"
|
|
assert rel.cardinality == "one-to-many"
|
|
assert rel.inverse == "market->page"
|
|
assert rel.nav == "submenu"
|
|
assert rel.nav_icon == "fa fa-shopping-bag"
|
|
|
|
def test_builtin_post_entry(self):
|
|
load_relation_registry()
|
|
rel = get_relation("post->calendar_entry")
|
|
assert rel.cardinality == "many-to-many"
|
|
assert rel.nav == "inline"
|
|
|
|
def test_builtin_page_menu_node(self):
|
|
load_relation_registry()
|
|
rel = get_relation("page->menu_node")
|
|
assert rel.cardinality == "one-to-one"
|
|
assert rel.nav == "hidden"
|
|
|
|
def test_frozen_dataclass(self):
|
|
load_relation_registry()
|
|
rel = get_relation("page->market")
|
|
with pytest.raises(AttributeError):
|
|
rel.name = "changed"
|