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