File size: 9,513 Bytes
19729e9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | """确定性相容性规则引擎 ``skills.compatibility.rules`` 的单元 / 属性测试(任务 18)。
覆盖需求 8.2:相容性「计算」为确定性官能团 → 风险规则引擎(纯 Python,风险分级)。
测试取向:
- 规则正确性:已知官能团 × 辅料特性组合命中预期反应与风险等级(红 / 黄 / 绿)。
- 确定性:相同输入恒得相同输出(无随机 / 无网络 / 无 LLM)。
- 聚合正确:整体风险 = 各辅料风险的最坏者(worse 聚合)。
- 鲁棒性:未知辅料 / 空输入 / dict 与 str 混合输入均稳健处理。
导入路径由 tests/conftest.py 统一设置(仓库根加入 sys.path,顶层 skills 包可导入)。
"""
from __future__ import annotations
import pytest
from skills.compatibility import rules
# ---------------------------------------------------------------------------
# 辅料解析
# ---------------------------------------------------------------------------
def test_resolve_known_excipient_lactose_is_reducing_sugar():
profile = rules.resolve_excipient("乳糖")
assert profile.known is True
assert "reducing_sugar" in profile.tags
def test_resolve_excipient_english_alias_case_insensitive():
profile = rules.resolve_excipient("Magnesium Stearate")
assert profile.known is True
assert "alkaline" in profile.tags
def test_resolve_unknown_excipient_marked_not_known_with_no_tags():
profile = rules.resolve_excipient("某种未收录辅料XYZ")
assert profile.known is False
assert profile.tags == frozenset()
def test_resolve_empty_name_returns_unknown_placeholder():
profile = rules.resolve_excipient("")
assert profile.known is False
assert profile.tags == frozenset()
# ---------------------------------------------------------------------------
# 规则命中:五大反应维度
# ---------------------------------------------------------------------------
def test_maillard_high_risk_amine_plus_reducing_sugar():
"""伯胺 + 还原糖(乳糖)→ 美拉德高风险。"""
assessment = rules.evaluate(
functional_groups=[{"id": "primary_amine"}],
excipients=["乳糖"],
)
assert assessment.overall_risk == rules.RISK_HIGH
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("maillard") == rules.RISK_HIGH
def test_oxidation_high_risk_thiol_plus_peroxide():
"""巯基 + 过氧化物(交联聚维酮)→ 氧化高风险。"""
assessment = rules.evaluate(["thiol"], ["交联聚维酮"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("oxidation") == rules.RISK_HIGH
assert assessment.overall_risk == rules.RISK_HIGH
def test_oxidation_medium_risk_phenol_plus_peroxide():
"""酚羟基 + 过氧化物 → 氧化中风险(非高)。"""
assessment = rules.evaluate(["phenol"], ["聚维酮"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("oxidation") == rules.RISK_MEDIUM
def test_hydrolysis_high_risk_ester_plus_alkaline():
"""酯基 + 碱性辅料(硬脂酸镁)→ 碱催化水解高风险。"""
assessment = rules.evaluate(["ester"], ["硬脂酸镁"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("hydrolysis") == rules.RISK_HIGH
def test_acid_base_medium_risk_carboxyl_plus_alkaline():
"""羧基 + 碱性辅料 → 酸碱相互作用中风险。"""
assessment = rules.evaluate(["carboxylic_acid"], ["磷酸氢钙"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("acid_base") == rules.RISK_MEDIUM
def test_adsorption_low_risk_any_group_plus_high_surface_area():
"""任意官能团 + 高比表面积辅料(胶态二氧化硅)→ 吸附低风险。"""
assessment = rules.evaluate(["alcohol"], ["胶态二氧化硅"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("adsorption") == rules.RISK_LOW
# ---------------------------------------------------------------------------
# 无反应 / 惰性辅料
# ---------------------------------------------------------------------------
def test_inert_excipient_yields_no_significant_risk():
"""甘露醇(非还原糖醇,惰性)+ 伯胺 → 无显著相互作用。"""
assessment = rules.evaluate(["primary_amine"], ["甘露醇"])
er = assessment.excipient_results[0]
assert er.reactions == []
assert er.risk == rules.RISK_NONE
assert assessment.overall_risk == rules.RISK_NONE
def test_no_functional_groups_adds_note():
"""无官能团时仍可评估辅料,但附低置信度提示。"""
assessment = rules.evaluate([], ["乳糖"])
# 无官能团 → 美拉德等规则不命中。
assert assessment.excipient_results[0].reactions == []
assert any("未识别到 API 官能团" in n for n in assessment.notes)
# ---------------------------------------------------------------------------
# 整体风险聚合(取最坏)
# ---------------------------------------------------------------------------
def test_overall_risk_is_worst_of_all_excipients():
"""多辅料:整体风险取各辅料的最坏者。"""
assessment = rules.evaluate(
functional_groups=["primary_amine", "ester"],
excipients=["甘露醇", "胶态二氧化硅", "乳糖"], # none, low(adsorption), high(maillard)
)
risks = {er.display_zh: er.risk for er in assessment.excipient_results}
assert risks["甘露醇"] == rules.RISK_NONE
assert risks["胶态二氧化硅"] == rules.RISK_LOW
assert risks["乳糖"] == rules.RISK_HIGH
assert assessment.overall_risk == rules.RISK_HIGH
def test_counts_and_summary_structure():
assessment = rules.evaluate(["primary_amine", "ester"], ["乳糖", "甘露醇"])
summary = assessment.summary()
assert summary["n_excipients"] == 2
assert summary["overall_risk"] == rules.RISK_HIGH
assert summary["n_high"] >= 1
assert isinstance(summary["excipients"], list)
# 风险色块包含整体 + 每个辅料。
blocks = assessment.risk_blocks()
assert len(blocks) == 1 + 2
assert blocks[0]["color"] == rules.QBD_RISK_COLORS[rules.RISK_HIGH]
# ---------------------------------------------------------------------------
# 输入归一鲁棒性
# ---------------------------------------------------------------------------
def test_accepts_string_and_dict_inputs_equivalently():
"""str 与 dict 形式的官能团 / 辅料输入应等价。"""
a = rules.evaluate(["primary_amine"], ["乳糖"])
b = rules.evaluate([{"id": "primary_amine"}], [{"name": "乳糖"}])
assert a.overall_risk == b.overall_risk
assert a.excipient_results[0].risk == b.excipient_results[0].risk
def test_duplicate_excipients_deduplicated():
assessment = rules.evaluate(["primary_amine"], ["乳糖", "乳糖", "Lactose"])
# 乳糖 与 Lactose 命中同一档;去重按名称小写。
assert len(assessment.excipient_results) == 2 # "乳糖" 与 "Lactose" 名称不同
def test_empty_excipients_returns_empty_results_with_note():
assessment = rules.evaluate(["primary_amine"], [])
assert assessment.excipient_results == []
assert assessment.overall_risk == rules.RISK_NONE
assert any("未提供任何辅料" in n for n in assessment.notes)
# ---------------------------------------------------------------------------
# 确定性(属性式):相同输入恒得相同输出
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"groups,excips",
[
(["primary_amine"], ["乳糖"]),
(["thiol", "ester"], ["交联聚维酮", "硬脂酸镁"]),
(["phenol"], ["聚维酮", "甘露醇"]),
([], ["未知辅料A", "微晶纤维素"]),
],
)
def test_determinism_same_input_same_output(groups, excips):
"""规则引擎确定性:多次评估结果完全一致。"""
first = rules.evaluate(groups, excips).summary()
for _ in range(3):
again = rules.evaluate(groups, excips).summary()
assert again == first
def test_worse_helper_picks_more_severe():
assert rules.worse(rules.RISK_LOW, rules.RISK_HIGH) == rules.RISK_HIGH
assert rules.worse(rules.RISK_MEDIUM, rules.RISK_LOW) == rules.RISK_MEDIUM
assert rules.worse(rules.RISK_NONE, rules.RISK_NONE) == rules.RISK_NONE
def test_risk_color_maps_to_qbd_palette():
assert rules.risk_color(rules.RISK_HIGH) == "#dc3545"
assert rules.risk_color(rules.RISK_MEDIUM) == "#ffc107"
assert rules.risk_color(rules.RISK_LOW) == "#28a745"
# 未知等级回退绿。
assert rules.risk_color("???") == "#28a745"
def test_merged_reaction_takes_worst_risk_and_merges_groups():
"""同一反应被多条规则命中时,取最坏风险并合并触发官能团。
氧化:thiol→高、phenol→中;二者同存时该反应应为高,且两官能团都被记录。
"""
reactions = rules.assess_pair(
group_ids=["thiol", "phenol"], tags=["peroxide"]
)
ox = next(r for r in reactions if r.reaction == "oxidation")
assert ox.risk == rules.RISK_HIGH
assert "thiol" in ox.group_ids and "phenol" in ox.group_ids
|