merge: T6 知识库实体与图谱(T-P2-01/05)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# pytest prepend 导入模式按 basename 注册模块:unit 与 integration 下的
|
||||
# 同名测试文件(如 test_kb_entity.py,任务契约指定两端同名)会撞模块名。
|
||||
# 加包标记后模块名唯一化为 tests.unit.<name>,与 tests/__init__.py 一致。
|
||||
@@ -0,0 +1,222 @@
|
||||
"""kb 域纯逻辑单测(不连库):状态机全跃迁矩阵、12 类 Literal、色板格式校验。
|
||||
|
||||
repo / service 的 DB 路径由 ``tests/integration/test_kb_entity.py`` 覆盖,
|
||||
这里只钉死「规则本身」:哪条跃迁合法、哪类实体名合法、什么 hex 合法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import product
|
||||
from typing import get_args
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packages.kb.schemas import (
|
||||
ENTITY_STATUSES,
|
||||
ENTITY_TYPES,
|
||||
MAX_GRAPH_HOPS,
|
||||
ColorRole,
|
||||
EntityCreate,
|
||||
EntityUpdate,
|
||||
GraphHop,
|
||||
GraphNode,
|
||||
GraphPath,
|
||||
RelationCreate,
|
||||
)
|
||||
from packages.kb.service import (
|
||||
CONTENT_EDITABLE_STATUSES,
|
||||
LEGAL_TRANSITIONS,
|
||||
assert_transition,
|
||||
can_transition,
|
||||
ensure_content_editable,
|
||||
ensure_valid_color_role,
|
||||
ensure_valid_hex,
|
||||
ensure_valid_hops,
|
||||
)
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
|
||||
# 12 类本体(知识库文档 §3.1)。这里刻意不引用 ENTITY_TYPES 常量本身,
|
||||
# 而是手写清单——常量抄错时测试必须能抓住。
|
||||
_EXPECTED_12_TYPES = (
|
||||
"item",
|
||||
"color",
|
||||
"style",
|
||||
"body_shape",
|
||||
"face_hair",
|
||||
"occasion",
|
||||
"material",
|
||||
"accessory",
|
||||
"makeup",
|
||||
"rule",
|
||||
"outfit",
|
||||
"trend",
|
||||
)
|
||||
|
||||
#: 状态机合法跃迁(draft→reviewing→published→published 后归档 + 撤稿支线)。
|
||||
#: published→draft 不在其中:改已发布内容 = 撤稿归档后重录新草稿。
|
||||
_LEGAL_PAIRS = {
|
||||
("draft", "reviewing"),
|
||||
("reviewing", "published"),
|
||||
("published", "archived"),
|
||||
("draft", "archived"),
|
||||
("reviewing", "archived"),
|
||||
}
|
||||
|
||||
|
||||
class TestStateMachineMatrix:
|
||||
"""全跃迁矩阵:4 态 × 4 态共 16 组合,合法 5 条,其余 11 条全部拦下。"""
|
||||
|
||||
@pytest.mark.parametrize(("current", "target"), list(product(ENTITY_STATUSES, repeat=2)))
|
||||
def test_matrix(self, current: str, target: str) -> None:
|
||||
expected = (current, target) in _LEGAL_PAIRS
|
||||
assert can_transition(current, target) is expected
|
||||
if expected:
|
||||
assert_transition(current, target) # 不抛即合法
|
||||
else:
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
assert_transition(current, target)
|
||||
assert exc_info.value.code == ErrorCode.STATE_CONFLICT
|
||||
assert exc_info.value.http_status == 409
|
||||
assert exc_info.value.data == {"current": current, "target": target}
|
||||
|
||||
def test_matrix_definition_is_exact(self) -> None:
|
||||
"""合法性来源是 LEGAL_TRANSITIONS 常量:常量与期望集合互为镜像。"""
|
||||
assert frozenset(_LEGAL_PAIRS) == LEGAL_TRANSITIONS
|
||||
assert len(_LEGAL_PAIRS) == 5
|
||||
|
||||
@pytest.mark.parametrize("status", ["draft", "reviewing"])
|
||||
def test_content_editable_in(self, status: str) -> None:
|
||||
ensure_content_editable(status) # 不抛即可编辑
|
||||
|
||||
@pytest.mark.parametrize("status", ["published", "archived"])
|
||||
def test_content_frozen_outside_draft_reviewing(self, status: str) -> None:
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
ensure_content_editable(status)
|
||||
assert exc_info.value.code == ErrorCode.STATE_CONFLICT
|
||||
assert frozenset({"draft", "reviewing"}) == CONTENT_EDITABLE_STATUSES
|
||||
|
||||
|
||||
class TestEntitySchemas:
|
||||
@pytest.mark.parametrize("entity_type", _EXPECTED_12_TYPES)
|
||||
def test_all_12_entity_types_accepted(self, entity_type: str) -> None:
|
||||
payload = EntityCreate(entity_type=entity_type, name="白衬衫", attrs={"sleeve": "long"})
|
||||
assert payload.entity_type == entity_type
|
||||
assert ENTITY_TYPES == _EXPECTED_12_TYPES
|
||||
assert len(_EXPECTED_12_TYPES) == 12
|
||||
|
||||
@pytest.mark.parametrize("entity_type", ["garbage", "Item", "items", "", "user"])
|
||||
def test_invalid_entity_type_rejected(self, entity_type: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EntityCreate(entity_type=entity_type, name="白衬衫") # type: ignore[arg-type]
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "x" * 129])
|
||||
def test_name_length_enforced(self, name: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EntityCreate(entity_type="item", name=name)
|
||||
|
||||
def test_attrs_default_empty_and_optional_fields(self) -> None:
|
||||
payload = EntityCreate(entity_type="style", name="通勤风")
|
||||
assert payload.attrs == {}
|
||||
assert payload.source is None
|
||||
assert payload.license is None
|
||||
|
||||
def test_entity_update_defaults_and_status_literal(self) -> None:
|
||||
assert EntityUpdate().content_fields() == {}
|
||||
assert EntityUpdate(attrs={"a": 1}).content_fields() == {"attrs": {"a": 1}}
|
||||
# status 不是内容字段:纯跃迁请求不该引起 version 变化
|
||||
assert EntityUpdate(status="published").content_fields() == {}
|
||||
with pytest.raises(ValidationError):
|
||||
EntityUpdate(status="frozen") # type: ignore[arg-type]
|
||||
|
||||
def test_entity_update_distinguishes_unset_and_null(self) -> None:
|
||||
"""PATCH 语义:显式传 null 清空字段,未传不动字段。"""
|
||||
payload = EntityUpdate.model_validate({"source": None})
|
||||
assert payload.content_fields() == {"source": None}
|
||||
assert EntityUpdate.model_validate({}).content_fields() == {}
|
||||
|
||||
def test_status_literal_matches_state_machine(self) -> None:
|
||||
assert set(ENTITY_STATUSES) == {"draft", "reviewing", "published", "archived"}
|
||||
|
||||
|
||||
class TestRelationAndGraphSchemas:
|
||||
def test_relation_create_defaults(self) -> None:
|
||||
payload = RelationCreate(from_entity_id=1, to_entity_id=2, rel_type="suits")
|
||||
assert payload.weight == payload.confidence # 默认 1
|
||||
assert str(payload.weight) == "1"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rel_type", "weight"),
|
||||
[("", 1), ("x" * 65, 1), ("suits", -0.1), ("suits", 10)],
|
||||
)
|
||||
def test_relation_create_rejects_bad_shape(self, rel_type: str, weight: float) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RelationCreate(from_entity_id=1, to_entity_id=2, rel_type=rel_type, weight=weight)
|
||||
|
||||
def test_graph_path_shape_invariants(self) -> None:
|
||||
"""可解释路径的结构不变式:len(hops) == len(nodes) - 1,逐跳有 rel_type。"""
|
||||
path = GraphPath(
|
||||
nodes=[
|
||||
GraphNode(entity_id="1", entity_type="item", name="白衬衫", status="published"),
|
||||
GraphNode(entity_id="2", entity_type="style", name="通勤风", status="published"),
|
||||
GraphNode(entity_id="3", entity_type="occasion", name="面试", status="published"),
|
||||
],
|
||||
hops=[
|
||||
GraphHop(from_entity_id="1", rel_type="suits", to_entity_id="2"),
|
||||
GraphHop(from_entity_id="2", rel_type="fits", to_entity_id="3"),
|
||||
],
|
||||
)
|
||||
assert len(path.hops) == len(path.nodes) - 1
|
||||
assert [hop.rel_type for hop in path.hops] == ["suits", "fits"]
|
||||
|
||||
|
||||
class TestColorValidation:
|
||||
@pytest.mark.parametrize("hex_code", ["#A1B2C3", "#a1b2c3", "#000000", "#FFFFFF"])
|
||||
def test_valid_hex(self, hex_code: str) -> None:
|
||||
ensure_valid_hex(hex_code) # 不抛即合法
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hex_code",
|
||||
[
|
||||
"A1B2C3", # 缺 #
|
||||
"#A1B2C", # 5 位
|
||||
"#A1B2C3D", # 7 位
|
||||
"#G1H2J3", # 非 hex 字符
|
||||
" #A1B2C3", # 前导空格
|
||||
"#A1B2C3\n", # 尾部换行:fullmatch 必须拦
|
||||
"#Z9",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_invalid_hex(self, hex_code: str) -> None:
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
ensure_valid_hex(hex_code)
|
||||
assert exc_info.value.code == ErrorCode.BAD_REQUEST
|
||||
assert exc_info.value.http_status == 400
|
||||
|
||||
@pytest.mark.parametrize("role", ["base", "accent", "avoid"])
|
||||
def test_valid_roles(self, role: str) -> None:
|
||||
ensure_valid_color_role(role)
|
||||
|
||||
@pytest.mark.parametrize("role", ["primary", "BASE", "", "main"])
|
||||
def test_invalid_roles(self, role: str) -> None:
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
ensure_valid_color_role(role)
|
||||
assert exc_info.value.code == ErrorCode.BAD_REQUEST
|
||||
|
||||
def test_color_role_literal_matches_guard(self) -> None:
|
||||
assert set(get_args(ColorRole)) == {"base", "accent", "avoid"}
|
||||
|
||||
|
||||
class TestHopsBound:
|
||||
"""1~3 跳:超界 400(HTTP 入口由 Query ge/le 先行拦成 422,service 兜底)。"""
|
||||
|
||||
@pytest.mark.parametrize("max_hops", [1, 2, MAX_GRAPH_HOPS])
|
||||
def test_valid_hops(self, max_hops: int) -> None:
|
||||
ensure_valid_hops(max_hops)
|
||||
|
||||
@pytest.mark.parametrize("max_hops", [0, -1, 4, 99])
|
||||
def test_out_of_range_hops(self, max_hops: int) -> None:
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
ensure_valid_hops(max_hops)
|
||||
assert exc_info.value.code == ErrorCode.BAD_REQUEST
|
||||
Reference in New Issue
Block a user