Preformu / tests /test_chart_service.py
Kevinshh's picture
feat: 意图保真(intent-fidelity) + 描述性梳理技能 + 相容性引擎升级; 修复转置宽表解析/CQA对账/澄清交互/功能切换串显; .gitignore 排除专利与机密Demo数据
0e6887b
Raw
History Blame Contribute Delete
18.3 kB
"""``ChartService`` 的单元测试(任务 8)。
覆盖需求:
- 4.1 / 4.2:预测带由 ``compute`` 产出的**真实** CI 网格直接渲染,**不含任何硬编码
比例系数**——断言渲染层消费的上下界与传入数据逐点一致(不缩放、不推导)。
- 4.4:一级动力学非对称带——上下半宽不相等的形态被原样保留。
- 11.5:多 CQA 叠加图,并按木桶原理标注短板 CQA(shelf_life 最短者)。
- 14.3:统一红 / 黄 / 绿 QbD 风险色板,识别中英文与高 / 中 / 低三档。
- 14.1 关联:加载内嵌中文字体;缺失 / 禁用时降级英文标签,不报错。
- design Property 7:matplotlib / numpy 缺失时优雅降级(ok=False + warning),不崩溃。
测试自包含:不触网;图像仅校验是否成功生成内联 PNG 与其结构属性,
不对像素做脆弱断言。导入路径由 tests/conftest.py 设置(底座以顶层包 ``services`` 导入)。
"""
from __future__ import annotations
import math
import pytest
from services.chart_service import ( # noqa: E402
ChartService,
CIBand,
ObservedTrace,
ChartResult,
QBD_RISK_COLORS,
)
np = pytest.importorskip("numpy")
pytest.importorskip("matplotlib")
# ---------------------------------------------------------------------------
# 测试夹具:构造真实 CI 网格(零级喇叭形 / 一级非对称)
# ---------------------------------------------------------------------------
def _zero_order_band(label="含量", spec_limit=95.0, spec_type="lower", shelf_life=None):
"""零级(线性)模型的对称喇叭形 CI 网格:半宽随 |t - t̄| 增大。"""
times = np.linspace(0.0, 24.0, 25)
t_bar = times.mean()
point = 100.0 - 0.3 * times # 含量随时间线性下降
# 对称喇叭形半宽:随偏离均值增大(模拟 SE_pred 的展宽,但由"上游"给出)。
half = 0.5 + 0.05 * np.abs(times - t_bar)
lower = point - half
upper = point + half
return CIBand(
times=times, point=point, lower=lower, upper=upper,
label=label, spec_type=spec_type, spec_limit=spec_limit,
shelf_life=shelf_life, observed_t=[0, 6, 12], observed_y=[100.1, 98.0, 96.5],
)
def _first_order_band(label="杂质", spec_limit=1.0, spec_type="upper", shelf_life=None):
"""一级模型在实数空间的非对称 CI 网格:上半宽 != 下半宽。"""
times = np.linspace(0.0, 24.0, 25)
# 在对数空间对称,经 exp 映射回实数 → 非对称包络。
ln_point = math.log(0.2) + 0.06 * times
ln_half = 0.1 + 0.02 * times
point = np.exp(ln_point)
lower = np.exp(ln_point - ln_half)
upper = np.exp(ln_point + ln_half)
return CIBand(
times=times, point=point, lower=lower, upper=upper,
label=label, spec_type=spec_type, spec_limit=spec_limit,
shelf_life=shelf_life,
)
# ---------------------------------------------------------------------------
# QbD 风险色板(需求 14.3)
# ---------------------------------------------------------------------------
def test_risk_palette_is_red_yellow_green():
palette = ChartService.risk_palette()
assert palette["high"] == "#dc3545" # 红
assert palette["medium"] == "#ffc107" # 黄
assert palette["low"] == "#28a745" # 绿
# 返回副本,外部修改不影响内部常量。
palette["high"] = "#000000"
assert QBD_RISK_COLORS["high"] == "#dc3545"
@pytest.mark.parametrize(
"level,expected",
[
("high", "high"), ("High", "high"), ("高危", "high"), ("不合格", "high"),
("non_compliant", "high"), ("red", "high"),
("medium", "medium"), ("临界", "medium"), ("marginal", "medium"),
("warning", "medium"), ("yellow", "medium"),
("low", "low"), ("合格", "low"), ("compliant", "low"), ("pass", "low"),
("green", "low"),
],
)
def test_normalize_risk_level_recognizes_zh_en_aliases(level, expected):
assert ChartService.normalize_risk_level(level) == expected
def test_normalize_risk_level_substring_and_default():
# 子串匹配(如「高风险等级」)。
assert ChartService.normalize_risk_level("高风险等级") == "high"
# 无法识别 → 默认中危(保守)。
assert ChartService.normalize_risk_level("unknown-xyz") == "medium"
assert ChartService.normalize_risk_level(None) == "medium"
def test_risk_color_maps_to_palette():
assert ChartService.risk_color("高危") == QBD_RISK_COLORS["high"]
assert ChartService.risk_color("临界") == QBD_RISK_COLORS["medium"]
assert ChartService.risk_color("合格") == QBD_RISK_COLORS["low"]
# ---------------------------------------------------------------------------
# 真实 CI 带:零硬编码系数(需求 4.1 / 4.2)
# ---------------------------------------------------------------------------
def test_validate_band_returns_arrays_unchanged():
"""validate_band 原样返回上下界,不做任何缩放 / 推导(需求 4.2 硬保证)。"""
band = _zero_order_band()
times, point, lower, upper = ChartService.validate_band(band)
np.testing.assert_array_almost_equal(times, np.asarray(band.times, dtype=float))
np.testing.assert_array_almost_equal(point, np.asarray(band.point, dtype=float))
# 关键:上下界与传入数据逐点一致 —— 渲染层不重算带宽。
np.testing.assert_array_almost_equal(lower, np.asarray(band.lower, dtype=float))
np.testing.assert_array_almost_equal(upper, np.asarray(band.upper, dtype=float))
def test_validate_band_rejects_mismatched_lengths():
band = CIBand(times=[0, 1, 2], point=[1, 2], lower=[0, 1], upper=[2, 3])
with pytest.raises(ValueError, match="长度不一致"):
ChartService.validate_band(band)
def test_validate_band_rejects_empty():
band = CIBand(times=[], point=[], lower=[], upper=[])
with pytest.raises(ValueError, match="为空"):
ChartService.validate_band(band)
def test_validate_band_rejects_lower_above_upper():
band = CIBand(times=[0, 1], point=[1, 1], lower=[2, 2], upper=[1, 1])
with pytest.raises(ValueError, match="lower > upper"):
ChartService.validate_band(band)
def test_band_halfwidths_match_input_exactly_no_scaling():
"""半宽 == 传入 (upper-point) 与 (point-lower),证明无 se_scale 之类硬编码系数。"""
band = _zero_order_band()
up_half, low_half = ChartService.band_halfwidths(band)
expected_up = np.asarray(band.upper, dtype=float) - np.asarray(band.point, dtype=float)
expected_low = np.asarray(band.point, dtype=float) - np.asarray(band.lower, dtype=float)
np.testing.assert_array_almost_equal(up_half, expected_up)
np.testing.assert_array_almost_equal(low_half, expected_low)
def test_zero_order_band_widens_with_extrapolation():
"""零级带半宽随 |t - t̄| 单调不减(喇叭形,需求 4.3 关联)。"""
band = _zero_order_band()
up_half, _ = ChartService.band_halfwidths(band)
times = np.asarray(band.times, dtype=float)
dist = np.abs(times - times.mean())
order = np.argsort(dist)
sorted_half = up_half[order]
# 按到均值距离排序后,半宽应单调不减。
assert np.all(np.diff(sorted_half) >= -1e-9)
# ---------------------------------------------------------------------------
# 一级动力学非对称带(需求 4.4)
# ---------------------------------------------------------------------------
def test_first_order_band_is_asymmetric():
"""一级动力学:对 t>0,upper-point != point-lower(非对称,需求 4.4)。"""
band = _first_order_band()
up_half, low_half = ChartService.band_halfwidths(band)
# 至少在外推段存在显著非对称(上半宽大于下半宽)。
assert np.any(np.abs(up_half - low_half) > 1e-6)
# exp 映射特性:上半宽整体 >= 下半宽。
assert np.mean(up_half[1:]) > np.mean(low_half[1:])
# ---------------------------------------------------------------------------
# 渲染:单 CQA 预测带
# ---------------------------------------------------------------------------
def test_prediction_band_renders_png():
svc = ChartService()
result = svc.prediction_band(_zero_order_band(shelf_life=18.0))
assert isinstance(result, ChartResult)
assert result.ok is True
assert result.image_base64.startswith("data:image/png;base64,")
assert len(result.image_base64) > 100
assert bool(result) is True
def test_prediction_band_renders_first_order():
svc = ChartService()
result = svc.prediction_band(_first_order_band(shelf_life=12.0))
assert result.ok is True
assert result.image_base64.startswith("data:image/png;base64,")
def test_prediction_band_rejects_bad_data():
svc = ChartService()
bad = CIBand(times=[0, 1], point=[1, 1], lower=[5, 5], upper=[1, 1])
result = svc.prediction_band(bad)
assert result.ok is False
assert "不合法" in result.error
# ---------------------------------------------------------------------------
# 多 CQA 叠加 / 分图 + 短板标注(需求 11.5)
# ---------------------------------------------------------------------------
def test_resolve_limiting_picks_shortest_shelf_life():
"""缺省短板判定取 shelf_life 最短者(木桶原理)。"""
assay = _zero_order_band(label="含量", shelf_life=24.0)
impurity = _first_order_band(label="杂质", shelf_life=15.0)
limiting = ChartService._resolve_limiting([assay, impurity], None)
assert limiting == "杂质"
def test_resolve_limiting_respects_explicit():
assay = _zero_order_band(label="含量", shelf_life=24.0)
impurity = _first_order_band(label="杂质", shelf_life=15.0)
limiting = ChartService._resolve_limiting([assay, impurity], "含量")
assert limiting == "含量"
def test_multi_cqa_panels_renders_and_marks_limiting():
svc = ChartService()
assay = _zero_order_band(label="含量", shelf_life=24.0)
impurity = _first_order_band(label="杂质", shelf_life=15.0)
result = svc.multi_cqa_overlay([assay, impurity], mode="panels")
assert result.ok is True
assert result.image_base64.startswith("data:image/png;base64,")
def test_multi_cqa_overlay_mode_renders():
svc = ChartService()
assay = _zero_order_band(label="含量", shelf_life=24.0)
impurity = _first_order_band(label="杂质", shelf_life=15.0)
result = svc.multi_cqa_overlay(
[assay, impurity], limiting_cqa="杂质", mode="overlay"
)
assert result.ok is True
assert result.image_base64.startswith("data:image/png;base64,")
def test_multi_cqa_empty_returns_error():
svc = ChartService()
result = svc.multi_cqa_overlay([])
assert result.ok is False
assert "未提供" in result.error
def test_multi_cqa_rejects_bad_band():
svc = ChartService()
bad = CIBand(times=[0, 1], point=[1, 1], lower=[5, 5], upper=[1, 1])
result = svc.multi_cqa_overlay([bad])
assert result.ok is False
assert "不合法" in result.error
# ---------------------------------------------------------------------------
# 中文字体加载与英文降级(需求 14.1 关联)
# ---------------------------------------------------------------------------
def test_chinese_font_available_by_default():
"""内嵌字体存在时,默认以中文标签渲染。"""
svc = ChartService()
# 仓库内置 fonts/NotoSansSC-Regular.otf,应被发现。
assert svc.chinese_available is True
assert svc.lang == "zh"
assert svc.label("time_axis") == "时间 (月)"
def test_english_fallback_when_chinese_disabled():
"""显式关闭中文(或字体缺失)时降级英文标签,不报错。"""
svc = ChartService(enable_chinese=False)
assert svc.chinese_available is False
assert svc.lang == "en"
assert svc.label("time_axis") == "Time (months)"
# 仍能正常渲染(英文标签);用 ASCII CQA 名避免缺字形告警。
result = svc.prediction_band(_zero_order_band(label="Assay"))
assert result.ok is True
assert result.used_chinese_font is False
def test_missing_font_path_degrades_to_english(tmp_path):
"""字体路径不存在 → 降级英文标签,渲染照常成功。"""
missing = tmp_path / "no-such-font.otf"
svc = ChartService(font_path=str(missing))
assert svc.chinese_available is False
assert svc.lang == "en"
result = svc.prediction_band(_first_order_band(label="Impurity"))
assert result.ok is True
assert result.used_chinese_font is False
def test_label_unknown_key_returns_key():
svc = ChartService()
assert svc.label("___nonexistent___") == "___nonexistent___"
# ---------------------------------------------------------------------------
# 重依赖缺失优雅降级(design Property 7)
# ---------------------------------------------------------------------------
def test_graceful_degradation_when_backend_missing(monkeypatch):
"""模拟 matplotlib / numpy 不可用:返回 ok=False + warning,绝不抛异常。"""
svc = ChartService()
monkeypatch.setattr(svc, "_ensure_backend", lambda: None)
pred = svc.prediction_band(_zero_order_band())
assert pred.ok is False
assert "matplotlib" in pred.warning
multi = svc.multi_cqa_overlay([_zero_order_band()])
assert multi.ok is False
assert "matplotlib" in multi.warning
if __name__ == "__main__": # pragma: no cover
import sys
sys.exit(pytest.main([__file__, "-v"]))
# ---------------------------------------------------------------------------
# 实测数据趋势图(数据梳理可视化)
# ---------------------------------------------------------------------------
def test_observed_trends_renders_png():
"""多条实测序列渲染为内联 PNG,且不依赖任何外推数据。"""
svc = ChartService()
traces = [
ObservedTrace(times=[0, 3, 6, 9, 12], values=[0.1, 0.13, 0.16, 0.19, 0.22],
label="B1@25C_60RH"),
ObservedTrace(times=[0, 3, 6], values=[0.1, 0.5, 1.0], label="B1@40C_75RH"),
]
res = svc.observed_trends(traces, spec_limit=0.5)
assert isinstance(res, ChartResult)
assert res.ok
assert res.image_base64.startswith("data:image/png;base64,")
def test_observed_trends_no_spec_line_when_absent():
"""未提供规格限时仍能正常渲染(不画规格参考线,不报错)。"""
svc = ChartService()
res = svc.observed_trends(
[ObservedTrace(times=[0, 6, 12], values=[0.2, 0.3, 0.4], label="B1")],
spec_limit=None,
)
assert res.ok
assert res.image_base64.startswith("data:image/png;base64,")
def test_observed_trends_empty_returns_not_ok():
"""无有效序列时优雅返回 ok=False,不抛异常。"""
svc = ChartService()
res = svc.observed_trends([])
assert not res.ok
assert res.error
# ---------------------------------------------------------------------------
# 目标预测时间点高亮标记(用户反馈:直观感受预测点位置)
# ---------------------------------------------------------------------------
def test_prediction_band_with_target_timepoints_renders():
"""带目标预测时间点的预测带应成功渲染(标记不报错)。"""
band = _zero_order_band()
band.target_timepoints = [12.0, 24.0]
res = ChartService().prediction_band(band)
assert res.ok
assert res.image_base64.startswith("data:image/png;base64,")
def test_prediction_band_target_timepoints_out_of_range_safe():
"""目标时间点超出网格范围时跳过标记、仍正常渲染(不报错)。"""
band = _zero_order_band()
band.target_timepoints = [999.0] # 超出 0–24 网格
res = ChartService().prediction_band(band)
assert res.ok
def test_prediction_band_no_target_timepoints_backward_compatible():
"""未提供目标时间点时与旧行为一致,正常渲染。"""
band = _zero_order_band()
assert band.target_timepoints is None
res = ChartService().prediction_band(band)
assert res.ok
# ---------------------------------------------------------------------------
# 自适应可视化新增图型(adaptive-report-visualization 任务 9)
# ---------------------------------------------------------------------------
def _is_png_datauri(s: str) -> bool:
return isinstance(s, str) and s.startswith("data:image/png;base64,")
def test_grouped_bar_renders_png():
svc = ChartService()
res = svc.grouped_bar(["20μg", "40μg", "60μg"], [5.3, 6.1, 4.8],
title="水分", value_label="%",
reference_lines=[{"value": 12.0, "label": "≤12.0", "kind": "upper"}])
assert res.ok and _is_png_datauri(res.image_base64)
def test_grouped_dot_variant():
svc = ChartService()
res = svc.grouped_bar(["A", "B"], [1.0, 2.0], dot=True)
assert res.ok and _is_png_datauri(res.image_base64)
def test_distribution_dot_with_av_line():
svc = ChartService()
res = svc.distribution_dot([99.19, 100.0, 97.68, 96.0, 98.81], title="含量均匀度",
value_label="%", mean=98.336,
reference_lines=[{"value": 15.0, "label": "AV≤15", "kind": "av"}])
assert res.ok and _is_png_datauri(res.image_base64)
def test_distribution_box_variant():
svc = ChartService()
res = svc.distribution_dot([99.1, 100.0, 97.6, 98.8, 96.0], box=True)
assert res.ok and _is_png_datauri(res.image_base64)
def test_status_matrix_renders_with_marks():
svc = ChartService()
rows = [{"label": "水分 20μg", "status": "pass"},
{"label": "总杂 40μg", "status": "fail"},
{"label": "含量% 60μg", "status": "na"}]
res = svc.status_matrix(rows, title="限度符合性")
assert res.ok and _is_png_datauri(res.image_base64)
def test_new_charts_english_fallback_when_no_font():
# 显式关闭中文字体:仍应成功出图(英文/符号降级)。
svc = ChartService(enable_chinese=False)
assert svc.lang == "en"
assert svc.grouped_bar(["A", "B"], [1, 2]).ok
assert svc.distribution_dot([1, 2, 3]).ok
assert svc.status_matrix([{"label": "x", "status": "pass"}]).ok