"""确定性相容性规则引擎 ``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