| """确定性相容性规则引擎 ``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=["甘露醇", "胶态二氧化硅", "乳糖"], |
| ) |
| 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"]) |
| |
| assert len(assessment.excipient_results) == 2 |
|
|
|
|
| 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 |
|
|