| """``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 ( |
| ChartService, |
| CIBand, |
| ObservedTrace, |
| ChartResult, |
| QBD_RISK_COLORS, |
| ) |
|
|
| np = pytest.importorskip("numpy") |
| pytest.importorskip("matplotlib") |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| 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) |
| |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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"] |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| |
| assert np.mean(up_half[1:]) > np.mean(low_half[1:]) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| def test_chinese_font_available_by_default(): |
| """内嵌字体存在时,默认以中文标签渲染。""" |
| svc = ChartService() |
| |
| 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)" |
| |
| 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___" |
|
|
|
|
| |
| |
| |
|
|
| 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__": |
| 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] |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|