File size: 4,979 Bytes
0e6887b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""相容性报告渲染测试(compatibility-engine-upgrade 任务 6.4)。

覆盖需求 3.1(渲染收归 report_service)、3.2(北欧色板 + 图标/文字标签,色觉可达性)、
3.3(二维风险矩阵)、3.4(KPI / 局限性 / RPN)。
"""

from __future__ import annotations

from kernel.services import Services
from kernel.skill_base import ExtractedData
from services.report_service import (
    NORDIC_RISK_COLORS,
    ReportService,
    risk_label,
)
from skills.compatibility.skill import SKILL


class _StubLLM:
    def complete(self, system, user, *, temperature=0.3, **kw):
        class _R:
            ok = True
            content = "机理分析:伯胺与还原糖经美拉德反应缩合。"
        return _R()


def _assemble(smiles="NCCO", excipients="乳糖\n硬脂酸镁", lang="zh"):
    from kernel.skill_base import RawInput

    svc = Services(llm=_StubLLM(), lang=lang)
    raw = RawInput(smiles=smiles, excipient=excipients)
    data = SKILL.extract(raw, svc)
    result = SKILL.compute(data)
    sections = SKILL.explain(result, svc)
    html = ReportService().assemble(SKILL.meta, result, sections, lang=lang)
    return result, sections, html


def test_matrix_and_kpi_rendered_by_report_service_not_explain():
    """矩阵/结构由 report_service 渲染;explain 不产出其 HTML(需求 3.1)。"""
    result, sections, html = _assemble()
    # explain 仅机理叙述。
    assert "risk_matrix" not in sections.sections
    assert "structure" not in sections.sections
    # report_service 渲染矩阵与 KPI。
    assert "compat-matrix" in html
    assert "kpi-row" in html


def test_risk_presentation_has_icon_and_text_not_color_only():
    """风险呈现同时含图标与文字标签,不单独依赖颜色(需求 3.2.3 色觉可达性)。"""
    _, _, html = _assemble()
    # 文字标签存在。
    assert "高风险" in html
    # 图标(任一风险图标)存在。
    assert ("⛔" in html) or ("⚠" in html) or ("✓" in html)


def test_nordic_palette_used_not_legacy_red():
    """风险矩阵单元格使用北欧低饱和色板(赭红 #c25b4e)渲染。"""
    _, _, html = _assemble()
    assert NORDIC_RISK_COLORS["high"] in html  # 赭红
    # 矩阵单元格内联样式使用北欧色板。
    assert f'background:{NORDIC_RISK_COLORS["high"]}' in html.replace(" ", "")


def test_limitations_section_present():
    """报告含独立"局限性与验证需求"小节(需求 3.4.2)。"""
    _, _, html = _assemble()
    assert "局限性与验证需求" in html


def test_overall_risk_localized_not_raw_enum():
    """整体风险显示本地化标签,不残留英文枚举(需求 0.1)。"""
    _, _, html = _assemble()
    assert "高风险" in html
    # 元数据条不应出现裸枚举 "high" 作为风险值展示。
    assert ">high<" not in html


def test_uncertain_when_no_functional_groups():
    """无官能团(无 SMILES)→ 整体不确定,报告显示"不确定"(需求 2.5.1 / 0.1)。"""
    result, _, html = _assemble(smiles="", excipients="乳糖")
    assert result.summary["overall_risk"] == "uncertain"
    assert "不确定" in html


def test_english_report_localizes_risk_labels():
    """英文报告风险标签为英文(需求 0.1.2 / NFR-5)。"""
    _, _, html = _assemble(lang="en")
    assert "High risk" in html
    assert "高风险" not in html


def test_risk_label_helper_handles_missing_enum():
    """risk_label 对缺失/非法枚举回退为"不确定"(需求 0.1.3)。"""
    assert risk_label(None, "zh") == "不确定"
    assert risk_label("bogus", "en") == "Uncertain"
    assert risk_label("high", "zh") == "高风险"


def test_mitigation_strategies_present_deterministically_without_llm():
    """缓解策略按反应机理组织、确定性呈现,不依赖 LLM(需求 3.4.4)。"""
    result, _, html = _assemble(smiles="NCCO", excipients="乳糖")
    # compute 确定性产出缓解策略。
    mits = result.summary.get("mitigations")
    assert mits, "应有按机理组织的缓解策略"
    assert any(m["reaction"] == "maillard" for m in mits)
    # report_service 渲染独立小节(无 LLM 时仍在,_assemble 用 StubLLM 但策略来自 compute)。
    assert "可执行缓解策略" in html


def test_mitigation_strategies_are_qualitative_no_numbers():
    """缓解策略为定性文本,不含未引用的定量数值(HP-1)。"""
    from skills.compatibility import matrix

    excipients = [{"name": "乳糖", "display_zh": "乳糖",
                   "reactions": [{"reaction": "maillard", "risk": "high"}]}]
    mits = matrix.mitigation_strategies(excipients)
    import re
    for m in mits:
        for s in m["strategies_zh"] + m["strategies_en"]:
            # 不应出现 kJ/mol、pKa= 数字、k= 数字等定量模式。
            assert not re.search(r"\d+\s*(?:kJ|kcal)\s*/\s*mol", s)
            assert not re.search(r"pKa\s*=\s*\d", s)