feat(ontology): 本体 spec 与跨引用校验

- pydantic 模型覆盖 entities/links/actions/functions/rules/constraints
- 跨引用校验:link.from/to 实体存在、rule.via 链路连得通且终点与 to 一致
- YAML 1.2 口径 loader:on/off/yes/no 不再被当成布尔(否则 on: Line 解析成 {True: Line})
- of 支持标量类型或实体名;属性支持 accepted: boolean 简写
- 19 条测试,其中 test_readme_example_loads 直接解析 README 里的 YAML
- dev 组补 types-pyyaml,mypy strict 通过
This commit is contained in:
Donny committed 2026-11-05 02:36:25 +08:00
1 parent e0bea0c54f
commit cc0d80ba3a
4 files changed
+598

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
"""本体 spec 测试。
其中 `test_readme_example_loads` 直接解析 README 里那段 YAML —— 文档示例一旦写坏,
这里先红,读者不会抄到坏例子。
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import yaml
from adapter_python_pg.errors import OntologyError
from adapter_python_pg.ontology.spec import (
Cardinality,
EntityKind,
PropertyType,
RuleKind,
load_ontology,
)
README = Path(__file__).resolve().parents[1] / "README.md"
def parse(mapping: dict[str, object]) -> object:
"""走公开入口(YAML 文本 → Ontology),与真实使用路径一致。"""
return load_ontology(yaml.safe_dump(mapping, allow_unicode=True))
def readme_yaml() -> str:
text = README.read_text(encoding="utf-8")
block = re.search(r"```yaml\n(.*?)```", text, re.S)
assert block, "README 里没有 yaml 代码块"
return block.group(1)
def minimal(**overrides: object) -> dict[str, object]:
"""最简本体,供各用例覆盖局部字段。"""
base: dict[str, object] = {
"ontology": "t",
"entities": {
"A": {},
"B": {},
},
"links": {"A_B": {"type": "rel", "from": "A", "to": "B"}},
}
base.update(overrides)
return base
# -- README 契约 -----------------------------------------------------------
def test_readme_example_loads() -> None:
onto = load_ontology(readme_yaml())
assert onto.ontology == "factory-twin"
assert set(onto.entities) == {"Factory", "Line", "Device", "SensorReading"}
assert set(onto.links) == {"Line_Factory", "Device_Line"}
assert onto.entities["SensorReading"].kind is EntityKind.TIMESERIES
assert onto.entities["SensorReading"].time == "ts"
assert onto.entities["Factory"].properties["location"].type is PropertyType.GEO
assert onto.entities["Line"].properties["status"].values == ("running", "idle", "fault")
assert onto.links["Device_Line"].cardinality is Cardinality.N_TO_ONE
assert onto.links["Device_Line"].from_ == "Device"
assert onto.rules["Device_inFactory"].kind is RuleKind.DERIVE_LINK
assert onto.rules["Device_inFactory"].via == ("mounted_on", "belongs_to")
def test_readme_rule_chain_resolves_device_to_factory() -> None:
onto = load_ontology(readme_yaml())
assert onto.link_types()["mounted_on"] == [("Device", "Line")]
assert onto.link_types()["belongs_to"] == [("Line", "Factory")]
def test_timeseries_entities_helper() -> None:
onto = load_ontology(readme_yaml())
assert list(onto.timeseries_entities()) == ["SensorReading"]
# -- 跨引用校验 ------------------------------------------------------------
def test_link_target_must_exist() -> None:
bad = minimal(links={"A_B": {"type": "rel", "from": "A", "to": "Nope"}})
with pytest.raises(OntologyError, match=r"link A_B\.to 引用了不存在的实体"):
parse(bad)
def test_link_source_must_exist() -> None:
bad = minimal(links={"A_B": {"type": "rel", "from": "Nope", "to": "B"}})
with pytest.raises(OntologyError, match=r"link A_B\.from 引用了不存在的实体"):
parse(bad)
def test_rule_via_must_be_connected() -> None:
bad = minimal(
rules={
"backwards": {
"kind": "derive_link",
"from": "B",
"to": "A",
"via": ["rel"], # rel 只从 A 出发,B 走不动
}
}
)
with pytest.raises(OntologyError, match="via 链在第 1 步断了"):
parse(bad)
def test_rule_chain_end_must_match_to() -> None:
bad = minimal(
entities={"A": {}, "B": {}, "C": {}},
links={
"A_B": {"type": "rel", "from": "A", "to": "B"},
"B_C": {"type": "rel2", "from": "B", "to": "C"},
},
rules={
"short": {"kind": "derive_link", "from": "A", "to": "C", "via": ["rel"]},
},
)
with pytest.raises(OntologyError, match="via 链终点是 'B'"):
parse(bad)
def test_rule_via_unknown_type() -> None:
bad = minimal(
rules={"r": {"kind": "derive_link", "from": "A", "to": "B", "via": ["nope"]}},
)
with pytest.raises(OntologyError, match="不存在的 link type"):
parse(bad)
# -- 字段级校验 ------------------------------------------------------------
def test_enum_requires_values() -> None:
bad = minimal(entities={"A": {"properties": {"s": {"type": "enum"}}}})
with pytest.raises(OntologyError, match="enum 属性必须给 values"):
parse(bad)
def test_values_only_on_enum() -> None:
bad = minimal(entities={"A": {"properties": {"s": {"type": "string", "values": ["x"]}}}})
with pytest.raises(OntologyError, match="只有 enum 属性可以给 values"):
parse(bad)
def test_list_requires_of() -> None:
bad = minimal(entities={"A": {"properties": {"tags": {"type": "list"}}}})
with pytest.raises(OntologyError, match="list 属性必须给 of"):
parse(bad)
def test_timeseries_requires_declared_time() -> None:
bad = minimal(
entities={
"A": {"kind": "timeseries", "time": "ts"}, # properties 里没有 ts
}
)
with pytest.raises(OntologyError, match="未在 properties 中定义"):
parse(bad)
def test_timeseries_time_must_be_datetime() -> None:
bad = minimal(
entities={
"A": {
"kind": "timeseries",
"time": "ts",
"properties": {"ts": {"type": "string"}},
}
}
)
with pytest.raises(OntologyError, match="time 属性类型必须是 datetime"):
parse(bad)
def test_unknown_field_is_rejected() -> None:
"""拼错字段名必须报错,而不是被静默忽略。"""
bad = minimal(entities={"A": {"properties": {"x": {"type": "string", "indx": True}}}})
with pytest.raises(OntologyError, match="indx"):
parse(bad)
def test_bad_identifier() -> None:
bad = minimal(entities={"A; drop table x": {}})
with pytest.raises(OntologyError, match="名字非法"):
parse(bad)
# -- 加载入口 --------------------------------------------------------------
def test_load_from_text_and_path(tmp_path: Path) -> None:
text = readme_yaml()
assert load_ontology(text).ontology == "factory-twin"
path = tmp_path / "onto.yaml"
path.write_text(text, encoding="utf-8")
assert load_ontology(path).ontology == "factory-twin"
def test_missing_file(tmp_path: Path) -> None:
with pytest.raises(OntologyError, match="本体文件不存在"):
load_ontology(tmp_path / "nope.yaml")
def test_yaml_syntax_error() -> None:
with pytest.raises(OntologyError, match="YAML 语法错误"):
load_ontology("ontology: x\nentities: [unclosed")
def test_root_must_be_mapping() -> None:
with pytest.raises(OntologyError, match="根节点必须是映射"):
load_ontology("- a\n- b\n")