"""kb_rule DSL 解析校验与 match 矩阵(TD-10,纯逻辑)。 核心验收点:**非法结构 100% 拒绝**——未识别的键(顶层/子句/条件字段)全部 在解析时抛错,而不是被静默忽略后变成一条永不生效的死规则。 """ from __future__ import annotations import pytest from pydantic import ValidationError from packages.rules import dsl VALID_RULE = { "rule_key": "R-BODY-A-002", "category": "body_shape", "priority": 90, "conditions": { "body_shape": "A型", "features": {"include": ["溜肩"]}, "goals": {"include": ["修饰溜肩"]}, }, "effects": { "recommend": [ {"slot": "top", "attrs": {"shoulder": ["垫肩", "自然肩"], "sleeve": ["泡泡袖"]}} ], "avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"], "silhouette": ["修身"]}}], "explanation": "溜肩需要在肩部建立结构感", }, "is_hard": False, "confidence": 0.95, } # ── 解析:合法结构 ───────────────────────────────────────────────────────── def test_parse_doc_example() -> None: rule = dsl.Rule.model_validate(VALID_RULE) assert rule.rule_key == "R-BODY-A-002" assert rule.priority == 90 assert rule.is_hard is False assert rule.confidence == pytest.approx(0.95) # 等值子句解析为 str,集合子句解析为 ConditionClause assert rule.conditions.clauses["body_shape"] == "A型" features = rule.conditions.clauses["features"] assert isinstance(features, dsl.ConditionClause) assert features.include == ("溜肩",) assert rule.effects.recommend[0].slot == "top" assert rule.effects.avoid[0].attrs["shoulder"] == ("落肩",) def test_parse_min_max_and_exclude_clause() -> None: rule = dsl.Rule.model_validate( { **VALID_RULE, "conditions": { "temperature": {"min": 18, "max": 26}, "season": {"exclude": ["夏"]}, }, } ) temp = rule.conditions.clauses["temperature"] assert isinstance(temp, dsl.ConditionClause) assert temp.min == 18.0 assert temp.max == 26.0 season = rule.conditions.clauses["season"] assert isinstance(season, dsl.ConditionClause) assert season.exclude == ("夏",) def test_empty_conditions_means_match_all() -> None: # 合规类兜底规则("全场禁真兽皮")无条件命中是合法语义。 rule = dsl.Rule.model_validate({**VALID_RULE, "conditions": {}}) assert rule.conditions.clauses == {} assert dsl.match(rule.conditions, dsl.RuleContext()) is True def test_explanation_only_effect_is_valid() -> None: rule = dsl.Rule.model_validate( {**VALID_RULE, "effects": {"explanation": "整体偏保守的通勤思路"}} ) assert rule.effects.recommend == () assert rule.effects.avoid == () def test_attrs_empty_means_whole_slot() -> None: rule = dsl.Rule.model_validate( {**VALID_RULE, "effects": {"avoid": [{"slot": "swimsuit"}], "explanation": "回避品类"}} ) assert rule.effects.avoid[0].attrs == {} # ── 解析:非法结构 100% 拒绝 ─────────────────────────────────────────────── @pytest.mark.parametrize( "mutation", [ {"rule_key": 12345}, # 非 str {"rule_key": "R-" + "x" * 63}, # 超出 64 字符(列宽) {"category": "c" * 33}, # 超出 32 字符(列宽) {"priority": 101}, # 超出分组上限 100 {"priority": -1}, {"confidence": 1.5}, {"confidence": -0.1}, ], ) def test_invalid_top_level_values_rejected(mutation: dict) -> None: with pytest.raises(ValidationError): dsl.Rule.model_validate({**VALID_RULE, **mutation}) def test_unknown_top_level_key_rejected() -> None: with pytest.raises(ValidationError, match="unexpected"): dsl.Rule.model_validate({**VALID_RULE, "unexpected": 1}) def test_unknown_condition_field_rejected() -> None: # 条件字段名 typo:静默忽略会让这条规则永不命中 with pytest.raises(ValidationError, match="未识别的条件字段"): dsl.Rule.model_validate({**VALID_RULE, "conditions": {"body_shap": "A型"}}) def test_unknown_clause_key_rejected() -> None: with pytest.raises(ValidationError): dsl.Rule.model_validate({**VALID_RULE, "conditions": {"features": {"includes": ["溜肩"]}}}) def test_empty_clause_rejected() -> None: with pytest.raises(ValidationError, match="至少要包含"): dsl.Rule.model_validate({**VALID_RULE, "conditions": {"features": {}}}) def test_min_greater_than_max_rejected() -> None: with pytest.raises(ValidationError, match="min 不能大于 max"): dsl.Rule.model_validate( {**VALID_RULE, "conditions": {"temperature": {"min": 26, "max": 18}}} ) def test_empty_attr_values_rejected() -> None: with pytest.raises(ValidationError, match="取值列表不能为空"): dsl.Rule.model_validate( { **VALID_RULE, "effects": { "recommend": [{"slot": "top", "attrs": {"shoulder": []}}], "explanation": "x", }, } ) def test_empty_attr_dim_rejected() -> None: with pytest.raises(ValidationError, match="维度名不能为空"): dsl.Rule.model_validate( { **VALID_RULE, "effects": { "recommend": [{"slot": "top", "attrs": {"": ["垫肩"]}}], "explanation": "x", }, } ) def test_empty_effects_rejected() -> None: with pytest.raises(ValidationError, match="至少要包含"): dsl.Rule.model_validate({**VALID_RULE, "effects": {}}) def test_blank_explanation_alone_rejected() -> None: # explanation="" 视同未填,效果为空的规则没有存在意义 with pytest.raises(ValidationError, match="至少要包含"): dsl.Rule.model_validate({**VALID_RULE, "effects": {"explanation": ""}}) def test_non_dict_conditions_rejected() -> None: with pytest.raises(ValidationError): dsl.Rule.model_validate({**VALID_RULE, "conditions": ["body_shape"]}) def test_effects_missing_rejected() -> None: payload = dict(VALID_RULE) del payload["effects"] with pytest.raises(ValidationError): dsl.Rule.model_validate(payload) # ── match 矩阵 ───────────────────────────────────────────────────────────── def _cond(**clauses: object) -> dsl.RuleCondition: return dsl.RuleCondition.model_validate(clauses) def test_match_equality_hit_and_miss() -> None: cond = _cond(body_shape="A型") assert dsl.match(cond, dsl.RuleContext(body_shape="A型")) is True assert dsl.match(cond, dsl.RuleContext(body_shape="H型")) is False assert dsl.match(cond, dsl.RuleContext(body_shape=None)) is False # 缺字段 def test_match_equality_on_collection_field_is_membership() -> None: cond = _cond(user_taboos="露脐") assert dsl.match(cond, dsl.RuleContext(user_taboos=("露脐", "豹纹"))) is True assert dsl.match(cond, dsl.RuleContext(user_taboos=())) is False def test_match_include_requires_all_values() -> None: cond = _cond(features={"include": ["溜肩", "宽胯"]}) assert dsl.match(cond, dsl.RuleContext(features=("溜肩", "宽胯", "小腿粗"))) is True # 子集语义:只命中一半不算命中 assert dsl.match(cond, dsl.RuleContext(features=("溜肩",))) is False assert dsl.match(cond, dsl.RuleContext(features=())) is False def test_match_include_on_scalar_field() -> None: cond = _cond(occasion={"include": ["面试", "通勤"]}) assert dsl.match(cond, dsl.RuleContext(occasion="面试")) is True assert dsl.match(cond, dsl.RuleContext(occasion="约会")) is False def test_match_exclude() -> None: cond = _cond(features={"exclude": ["溜肩"]}) assert dsl.match(cond, dsl.RuleContext(features=("宽胯",))) is True assert dsl.match(cond, dsl.RuleContext(features=("宽胯", "溜肩"))) is False def test_match_min_max_closed_interval() -> None: cond = _cond(temperature={"min": 18, "max": 26}) assert dsl.match(cond, dsl.RuleContext(temperature=18.0)) is True # 闭区间含端点 assert dsl.match(cond, dsl.RuleContext(temperature=26.0)) is True assert dsl.match(cond, dsl.RuleContext(temperature=21.5)) is True assert dsl.match(cond, dsl.RuleContext(temperature=17.9)) is False assert dsl.match(cond, dsl.RuleContext(temperature=26.1)) is False assert dsl.match(cond, dsl.RuleContext(temperature=None)) is False # 缺字段 def test_match_min_only_and_max_only() -> None: assert dsl.match(_cond(temperature={"min": 10}), dsl.RuleContext(temperature=10.0)) assert not dsl.match(_cond(temperature={"min": 10}), dsl.RuleContext(temperature=9.9)) assert dsl.match(_cond(temperature={"max": 10}), dsl.RuleContext(temperature=10.0)) assert not dsl.match(_cond(temperature={"max": 10}), dsl.RuleContext(temperature=10.1)) def test_match_clauses_are_anded() -> None: cond = _cond(body_shape="A型", features={"include": ["溜肩"]}) assert dsl.match(cond, dsl.RuleContext(body_shape="A型", features=("溜肩",))) is True assert dsl.match(cond, dsl.RuleContext(body_shape="A型", features=())) is False assert dsl.match(cond, dsl.RuleContext(body_shape="H型", features=("溜肩",))) is False def test_match_include_and_min_max_combined_in_one_clause() -> None: # 同一子句内 include 与区间也按 AND 处理 cond = _cond(goals={"include": ["显瘦"], "exclude": ["显壮"]}) assert dsl.match(cond, dsl.RuleContext(goals=("显瘦",))) is True assert dsl.match(cond, dsl.RuleContext(goals=("显瘦", "显壮"))) is False assert dsl.match(cond, dsl.RuleContext(goals=("显高",))) is False