"""service 层纯逻辑:状态机矩阵、DSL 写入校验、ORM 行 → 域规则转换。 不连库——DB 路径由 tests/integration/test_rules_engine.py 覆盖。 """ from __future__ import annotations from decimal import Decimal import pytest from packages.rules import service from packages.shared.errors import AppError, ErrorCode from packages.shared.models import KbRule VALID_PAYLOAD = { "rule_key": "R-TEST-001", "category": "body_shape", "priority": 90, "conditions": {"body_shape": "A型"}, "effects": { "recommend": [{"slot": "top", "attrs": {"shoulder": ["垫肩"]}}], "avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"]}}], "explanation": "测试规则", }, "is_hard": False, "confidence": 0.95, } ALL_STATUSES = ("draft", "published", "archived") # ── 状态机全跃迁矩阵 ─────────────────────────────────────────────────────── def test_legal_transitions_accepted() -> None: for current, target in ( ("draft", "published"), # 发布 ("published", "archived"), # 下线 ("draft", "archived"), # 撤稿 ): service.ensure_transition(current, target) # 不抛即通过 @pytest.mark.parametrize("current", ALL_STATUSES) @pytest.mark.parametrize("target", ALL_STATUSES) def test_illegal_transitions_rejected(current: str, target: str) -> None: if (current, target) in service.LEGAL_TRANSITIONS: pytest.skip("合法跃迁另测") with pytest.raises(AppError) as excinfo: service.ensure_transition(current, target) assert excinfo.value.code == ErrorCode.STATE_CONFLICT assert excinfo.value.data == {"current": current, "target": target} # ── DSL 写入校验 ─────────────────────────────────────────────────────────── def test_validate_rule_payload_accepts_valid_dsl() -> None: rule = service.validate_rule_payload(VALID_PAYLOAD) assert rule.rule_key == "R-TEST-001" def test_validate_rule_payload_rejects_unknown_keys_with_details() -> None: with pytest.raises(AppError) as excinfo: service.validate_rule_payload({**VALID_PAYLOAD, "conclusions": []}) assert excinfo.value.code == ErrorCode.VALIDATION_FAILED assert excinfo.value.data["errors"] # 拒绝原因必须可读,不能只说"不合法" assert any("conclusions" in err for err in excinfo.value.data["errors"]) def test_validate_rule_payload_rejects_unknown_condition_field() -> None: with pytest.raises(AppError) as excinfo: service.validate_rule_payload({**VALID_PAYLOAD, "conditions": {"body_shap": "A型"}}) assert excinfo.value.code == ErrorCode.VALIDATION_FAILED # ── ORM 行 → 域规则 ──────────────────────────────────────────────────────── def _row(**overrides: object) -> KbRule: values: dict[str, object] = { "rule_id": 1, "rule_key": "R-ROW-001", "category": "body_shape", "version": 1, "status": "published", "priority": 90, "conditions": {"body_shape": "A型"}, "effects": { "recommend": [{"slot": "top", "attrs": {"shoulder": ["垫肩"]}}], "explanation": "行转换", }, "is_hard": False, "confidence": Decimal("0.950"), } values.update(overrides) return KbRule(**values) # type: ignore[arg-type] def test_row_to_rule_parses_stored_jsonb() -> None: rule = service.row_to_rule(_row()) assert rule.rule_key == "R-ROW-001" assert rule.confidence == pytest.approx(0.95) # Numeric(4,3) → float assert rule.conditions.clauses["body_shape"] == "A型" assert rule.effects.recommend[0].attrs["shoulder"] == ("垫肩",) def test_row_to_rule_rejects_corrupted_stored_dsl() -> None: # 库里数据坏了是 5xxxx 知识库数据错误,必须响亮失败而不是静默跳过 with pytest.raises(AppError) as excinfo: service.row_to_rule(_row(conditions={"mood": "开心"})) assert excinfo.value.code == ErrorCode.CONFIG_ERROR assert excinfo.value.data == {"rule_key": "R-ROW-001"} def test_published_rules_cache_invalidate_resets() -> None: cache = service.PublishedRules() assert cache._rules is None cache._rules = () cache.invalidate() assert cache._rules is None