"""``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