Files
meida_backend/tests/unit/test_kb_entity.py
T
xsl d9249400af feat(kb): entity store with lifecycle, graph traversal and color palette
12 类实体统一表 + 状态机 + version 递增 + kb_relation 递归 CTE 1~3 跳
可解释路径(防环)+ kb_color 色板(T-P2-01/05,TD-09/18)。

实现:
- packages/kb/schemas.py:12 类 EntityType 与状态 Literal 校验、
  EntityCreate/Update/Out、RelationCreate、GraphNode/Hop/Path/QueryResult、
  ColorOut;雪花 ID 全部字符串出参(JS 精度)。
- packages/kb/repo.py:实体 CRUD/按名查重/过滤分页搜索;traverse 递归
  CTE(path 数组 + NOT @> 防环,锚点与递归段 varchar[] 显转型);色板读写。
- packages/kb/service.py:状态机矩阵(draft→reviewing→published→archived
  + draft/reviewing→archived 撤稿,非法跃迁 40900,矩阵单测 16 组全覆盖)、
  内容修改仅 draft/reviewing 且 version+1、published 只读;关系写入
  (自环/三元组重复 400、端点缺失 404);色板 role/hex(#RRGGBB) 校验。
- apps/api/routers/kb.py:GET/POST /kb/items、GET/PATCH /kb/items/{id}、
  GET /kb/items/{id}/graph?max_hops=1..3(超界 422);KbService 装配就近
  放路由文件(apps/api/deps.py 本轮禁改)。

测试:单测 77 例(跃迁矩阵、12 类 Literal、非法类型拒绝、hex/role/hops
校验);集成 5 例(真实 PG:生命周期/改 published 被拒/版本递增、关系
约束、2 跳可解释路径+防环、列表过滤分页、色板读写)。全套门禁绿:
ruff check/format、mypy(指定包列表)、lint-imports、单测 315 通过、
集成 21 通过。

偏差说明:
1. 新增 tests/unit/__init__.py 与 tests/integration/__init__.py(仅注释
   的包标记):任务文档要求单测/集成同名 test_kb_entity.py,pytest
   prepend 导入模式按 basename 注册模块会撞名,加包标记唯一化。
2. 关系与色板未挂 HTTP 路由:文档 §3.4 路由清单仅列 items+graph,
   录入入口属运营后台 T-P2-09,本批在 service 层落地并集成直连验证。
3. published→draft 按文档字面矩阵(其余跃迁非法)拦为 40900:改已发布
   内容的既定路径是撤稿归档后重录新草稿(错误 hint 已说明),未私开
   回退通道。
2026-08-14 20:09:12 +08:00

223 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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