Files
rose-ash/shared/sexp/tests/test_relations.py
giles a0a0f5ebc2 Implement flexible entity relation system (Phases A–E)
Declarative relation registry via defrelation s-expressions with
cardinality enforcement (one-to-one, one-to-many, many-to-many),
registry-aware relate/unrelate/can-relate API endpoints, generic
container-nav fragment, and relation-driven UI components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 08:35:17 +00:00

246 lines
8.1 KiB
Python

"""Tests for the relation registry (Phase A)."""
import pytest
from shared.sexp.evaluator import evaluate, EvalError
from shared.sexp.parser import parse
from shared.sexp.relations import (
_RELATION_REGISTRY,
clear_registry,
get_relation,
load_relation_registry,
relations_from,
relations_to,
all_relations,
)
from shared.sexp.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"