"""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