"""Offline rendering and data-integrity checks for the prescription workspace.""" from __future__ import annotations import os import socket from copy import deepcopy from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest from PySide6.QtCore import QEvent, QObject, Qt from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget from doctor_workstation.ui.dialogs.issued_prescription_ai_comparison import ( PrescriptionComparisonPanel, ) def saved_row(name: str = "黄芪", *, doctor: Any = 30, candidate: Any = 15, unit: str = "g", basis: str = "per_dose", formula: str = "主方", processing: str = "生品") -> dict[str, Any]: identity = {"name": name, "processing": processing, "formula_type": formula, "administration_route": "口服", "group": "", "unit": unit, "dose_basis": basis} return {**identity, "key": "a1b2c3d4" * 8, "doctor": {**identity, "dosage": doctor}, "candidate": {**identity, "dosage": candidate}, "doctor_dosage": doctor, "candidate_dosage": candidate, "match_type": "matched"} def saved_batch(rows: list[dict[str, Any]] | None = None) -> dict[str, Any]: rows = rows if rows is not None else [saved_row(), saved_row("白术", doctor=9, candidate=12)] herbs = [] for row in rows: if row.get("candidate"): original = deepcopy(row["candidate"]) original.pop("source_rows", None) row["candidate"].setdefault("source_rows", [len(herbs)]) herbs.append(original) candidate = {"status": "available_for_review", "prescription_name": "补中益气汤加减", "herbs": herbs, "usage_instruction": "水煎温服", "times_per_day": 2, "usage_days": 7} qwen = {"status": "succeeded", "candidate": candidate, "comparison": {"status": "comparable", "score": 68.5, "herb_score": 100, "rows": rows}} openai = deepcopy(qwen) openai["candidate"]["prescription_name"] = "益气健脾方" return {"id": 44, "validity": "current", "status": "completed", "models": {"qwen": qwen, "openai": openai}} @pytest.fixture(scope="module") def application() -> QApplication: return QApplication.instance() or QApplication([]) @pytest.fixture(autouse=True) def no_network(monkeypatch: pytest.MonkeyPatch) -> None: def denied(*_args: Any, **_kwargs: Any) -> None: pytest.fail("The comparison panel must never contact external systems") monkeypatch.setattr(socket.socket, "connect", denied) monkeypatch.setattr(socket.socket, "connect_ex", denied) monkeypatch.setattr(socket, "create_connection", denied) @pytest.fixture def panel(application: QApplication): host = QWidget() host.resize(1120, 400) layout = QVBoxLayout(host) layout.setContentsMargins(0, 0, 0, 0) widget = PrescriptionComparisonPanel(host) layout.addWidget(widget) host.show() application.processEvents() yield widget host.close() host.deleteLater() application.processEvents() def test_saved_snapshot_is_readable_and_model_switch_is_explicit(panel: PrescriptionComparisonPanel, application: QApplication) -> None: data = saved_batch() before = deepcopy(data) panel.set_batch(data) application.processEvents() assert data == before assert panel.selected_model == "qwen" assert panel.model_buttons["qwen"].isChecked() assert panel.name_label.text() == "补中益气汤加减" assert panel.herb_table.rowCount() == 2 assert panel.herb_table.item(0, 1).text() == "30 克 / 每剂" assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂" assert "主方" in panel.herb_table.item(0, 0).text() assert "a1b2c3d4" not in panel.chart.accessibleDescription() assert "药味剂量一致度" not in panel.chart.accessibleDescription() assert "水煎温服" in panel.usage_label.text() assert panel.chart.bars_enabled assert panel.chart.groups[0][0] == ("克", "每剂") panel.model_buttons["openai"].click() assert panel.selected_model == "openai" assert panel.name_label.text() == "益气健脾方" assert panel.herb_table.horizontalHeaderItem(2).text() == "OpenAI剂量" assert panel.chart.model_key == "openai" @pytest.mark.parametrize("candidate_key", ["candidate_dose", "candidate_dosage", "ai_dose"]) def test_legacy_flat_doses_remain_supported(panel: PrescriptionComparisonPanel, candidate_key: str) -> None: data = saved_batch() data["models"]["qwen"]["comparison"]["rows"] = [{"name": "黄芪", "doctor_dose": 0, candidate_key: "12.50", "unit": "g", "dose_basis": "每剂", "formula_type": "主方"}] panel.set_batch(data) assert panel.herb_table.item(0, 1).text() == "0 克 / 每剂" assert panel.herb_table.item(0, 2).text() == "12.50 克 / 每剂" assert panel.chart.rows[0].doctor.number == 0 def test_absent_historical_doctor_is_never_replaced_with_current_prescription(panel: PrescriptionComparisonPanel) -> None: data = saved_batch() data["models"]["qwen"]["comparison"]["rows"] = [] data["prescription"] = {"herbs": [{"name": "黄芪", "dosage": 99, "unit": "g", "dose_basis": "per_dose"}]} panel.set_batch(data) assert panel.herb_table.rowCount() == 2 assert panel.herb_table.item(0, 1).text() == "—" assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂" assert not panel.chart.bars_enabled assert "历史处方" in panel.status_label.text() assert "99" not in panel.chart.accessibleDescription() @pytest.mark.parametrize("rows", [None, {}, "unavailable"]) def test_malformed_comparison_keeps_candidate_list_without_claiming_a_snapshot(panel: PrescriptionComparisonPanel, rows: Any) -> None: data = saved_batch() data["models"]["qwen"]["comparison"]["rows"] = rows panel.set_batch(data) assert panel.herb_table.rowCount() == 2 assert panel.herb_table.item(0, 1).text() == "—" assert not panel.chart.bars_enabled def test_explicit_missing_snapshot_and_nested_unknown_unit_override_flat_values(panel: PrescriptionComparisonPanel) -> None: row = saved_row() row["doctor"] = None row["candidate"]["unit"] = None panel.set_batch(saved_batch([row])) assert panel.herb_table.item(0, 1).text() == "—" assert "单位未注明" in panel.herb_table.item(0, 2).text() assert panel.chart.groups[0][0] is None assert "未明确" in panel.chart.accessibleDescription() def test_incomparable_report_retains_original_doses_without_bars(panel: PrescriptionComparisonPanel) -> None: data = saved_batch() data["models"]["qwen"]["comparison"].update(status="not_comparable", score=99, reason="dose_basis_mismatch") panel.set_batch(data) assert "剂量基准不同" in panel.status_label.text() assert not panel.chart.bars_enabled assert panel.herb_table.item(0, 1).text() == "30 克 / 每剂" assert all(scale is None for scale, _rows, _maximum in panel.chart.groups) assert "99" not in panel.chart.accessibleDescription() def test_units_and_bases_have_independent_scales_and_mismatched_rows_are_excluded(panel: PrescriptionComparisonPanel) -> None: rows = [saved_row("黄芪"), saved_row("白术", unit="mg", doctor=1000), saved_row("茯苓", basis="per_day", doctor=60), saved_row("甘草")] rows[-1]["candidate"]["dose_basis"] = "per_day" panel.set_batch(saved_batch(rows)) assert [scale for scale, _members, _maximum in panel.chart.groups] == [("克", "每剂"), ("毫克", "每剂"), ("克", "每日"), None] assert panel.chart.groups[0][2] == 30 assert panel.chart.groups[1][2] == 1000 assert "单位或剂量基准不同" in panel.chart.accessibleDescription() assert "每日" in panel.herb_table.item(3, 2).text() def test_main_auxiliary_and_processing_rows_never_merge(panel: PrescriptionComparisonPanel) -> None: rows = [saved_row("甘草", formula="主方"), saved_row("甘草", formula="辅方"), saved_row("甘草", processing="炙品")] rows[2]["candidate"]["processing"] = "生品" panel.set_batch(saved_batch(rows)) assert panel.herb_table.rowCount() == 3 assert "主方" in panel.herb_table.item(0, 0).text() assert "辅方" in panel.herb_table.item(1, 0).text() assert panel.chart.rows[2].scale is None assert "炮制" in panel.chart.accessibleDescription() @pytest.mark.parametrize("skipped_index", [0, 1]) def test_zero_based_trace_preserves_candidate_herbs_skipped_during_normalization(panel: PrescriptionComparisonPanel, skipped_index: int) -> None: normalized = saved_row("黄芪") normalized["candidate"]["source_rows"] = [1 - skipped_index] data = saved_batch([normalized]) original = deepcopy(data["models"]["qwen"]["candidate"]["herbs"][0]) excluded = {**original, "name": "黄芪", "processing": "炮制待核对", "dosage": 8, "instructions": "先煎 30 分钟"} herbs = [original] herbs.insert(skipped_index, excluded) data["models"]["qwen"]["candidate"]["herbs"] = herbs # Even a contradictory comparable status must not grant the omitted raw herb a bar. panel.set_batch(data) assert panel.herb_table.rowCount() == 2 assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂" assert "未纳入对比" in panel.herb_table.item(1, 0).text() assert "炮制待核对" in panel.herb_table.item(1, 0).text() assert panel.herb_table.item(1, 1).text() == "—" assert panel.herb_table.item(1, 2).text() == "8 克 / 每剂" assert panel.chart.rows[1].scale is None assert "先煎 30 分钟" in panel.chart.accessibleDescription() assert "原方 2 项" in panel.count_label.text() and "未纳入 1 项" in panel.count_label.text() def test_merged_source_rows_cover_each_original_once_and_keep_original_doses(panel: PrescriptionComparisonPanel) -> None: merged = saved_row("黄芪", candidate=10) merged["candidate"]["source_rows"] = [0, 1] data = saved_batch([merged]) original = data["models"]["qwen"]["candidate"]["herbs"][0] data["models"]["qwen"]["candidate"]["herbs"] = [{**original, "name": "北芪", "dosage": 4}, {**original, "dosage": 6}, {**original, "name": "未识别药材", "dosage": 5}] panel.set_batch(data) assert panel.herb_table.rowCount() == 2 assert panel.herb_table.item(0, 2).text() == "10 克 / 每剂" tooltip = panel.herb_table.item(0, 0).toolTip() assert "第 1 项 北芪 4 克 / 每剂" in tooltip assert "第 2 项 黄芪 6 克 / 每剂" in tooltip assert "未识别药材" in panel.herb_table.item(1, 0).text() assert "未纳入对比" in panel.herb_table.item(1, 0).text() assert "原方 3 项" in panel.count_label.text() and "对比 1 项" in panel.count_label.text() panel.search.setText("北芪") assert panel.herb_table.rowCount() == 1 assert "黄芪" in panel.herb_table.item(0, 0).text() def test_legacy_missing_trace_retains_full_original_separately_without_name_matching(panel: PrescriptionComparisonPanel) -> None: data = saved_batch([saved_row("黄芪")]) model = data["models"]["qwen"] del model["comparison"]["rows"][0]["candidate"]["source_rows"] original = model["candidate"]["herbs"][0] model["candidate"]["herbs"] = [{**original, "dosage": 4}, {**original, "name": "炮制待核对药材", "dosage": 6}] panel.set_batch(data) assert panel.herb_table.rowCount() == 3 assert panel.herb_table.item(0, 2).text() == "15 克 / 每剂" for index, dose in ((1, "4 克 / 每剂"), (2, "6 克 / 每剂")): assert "原方附列" in panel.herb_table.item(index, 0).text() assert panel.herb_table.item(index, 1).text() == "—" assert panel.herb_table.item(index, 2).text() == dose assert panel.chart.rows[index].scale is None assert "对应关系未保存" in panel.status_label.text() assert "原方附列 2 项" in panel.count_label.text() @pytest.mark.parametrize("trace", [[True], [1], [-1], ["0"], []]) def test_invalid_source_trace_never_hides_an_original_herb(panel: PrescriptionComparisonPanel, trace: list[Any]) -> None: data = saved_batch([saved_row("黄芪")]) data["models"]["qwen"]["comparison"]["rows"][0]["candidate"]["source_rows"] = trace panel.set_batch(data) assert panel.herb_table.rowCount() == 2 assert "原方附列" in panel.herb_table.item(1, 0).text() assert panel.chart.rows[1].scale is None def test_real_normalized_usage_keeps_all_special_instructions_visible_and_accessible(panel: PrescriptionComparisonPanel) -> None: row = saved_row("石膏") row["doctor"]["usage"] = {"decoction_instruction": "先煎 30 分钟", "special_usage": "布包煎", "usage_time": "饭后", "usage_way": "温服"} row["candidate"]["instructions"] = "后下 5 分钟" row["candidate"]["usage"] = {"instructions": "后下 5 分钟", "decoction_instruction": "另煎", "special_usage": "分次兑服", "usage_instruction": "服前摇匀", "usage_time": "睡前", "usage_way": "温服"} panel.set_batch(saved_batch([row])) name_item = panel.herb_table.item(0, 0) for instruction in ("先煎 30 分钟", "布包煎", "饭后", "温服", "后下 5 分钟", "另煎", "分次兑服", "服前摇匀", "睡前"): assert instruction in name_item.toolTip() assert instruction in name_item.data(Qt.ItemDataRole.AccessibleDescriptionRole) assert instruction in panel.chart.accessibleDescription() assert "先煎 30 分钟" in name_item.text() and "后下 5 分钟" in name_item.text() assert panel.chart.rows[0].candidate.instructions.count("后下 5 分钟") == 1 assert panel.herb_table.rowHeight(0) >= panel.herb_table.fontMetrics().height() * 3 + 12 def test_clicking_a_table_row_scrolls_to_its_chart_group(panel: PrescriptionComparisonPanel, application: QApplication) -> None: rows = [saved_row(f"药材{index}", unit="g" if index % 2 == 0 else "mg") for index in range(20)] panel.set_batch(saved_batch(rows)) application.processEvents() item = panel.herb_table.item(11, 0) panel.herb_table.scrollToItem(item) application.processEvents() panel.chart_scroll.verticalScrollBar().setValue(0) QTest.mouseClick(panel.herb_table.viewport(), Qt.MouseButton.LeftButton, pos=panel.herb_table.visualItemRect(item).center()) application.processEvents() assert panel.herb_table.currentRow() == 11 # Ten gram rows precede the milligram group; the clicked row is its sixth member. expected_y = 8 + panel.chart.GROUP_HEIGHT * 2 + panel.chart.ROW_HEIGHT * 15 assert panel.chart_scroll.verticalScrollBar().value() == expected_y @pytest.mark.parametrize("field", ["processing", "formula_type", "administration_route", "group"]) def test_unrecognized_identity_values_do_not_become_equal_after_localization(panel: PrescriptionComparisonPanel, field: str) -> None: row = saved_row() row["doctor"][field] = "unknown_first" row["candidate"][field] = "unknown_second" panel.set_batch(saved_batch([row])) assert panel.chart.rows[0].scale is None assert "unknown_first" not in panel.chart.accessibleDescription() @pytest.mark.parametrize("value", [None, "适量", -3, float("nan"), float("inf"), True]) def test_invalid_or_missing_doses_never_produce_numeric_bars(panel: PrescriptionComparisonPanel, application: QApplication, value: Any) -> None: panel.set_batch(saved_batch([saved_row(doctor=value)])) assert panel.chart.rows[0].doctor.number is None assert panel.chart.groups[0][2] == 15 application.processEvents() assert not panel.chart.grab().isNull() if value is None or value is True: assert panel.herb_table.item(0, 1).text() == "—" @pytest.mark.parametrize(("status", "expected"), [("running", "正在生成"), ("failed", "未完成"), ("future_state", "状态未确认")]) def test_processing_failed_and_unknown_model_states_are_explicit(panel: PrescriptionComparisonPanel, status: str, expected: str) -> None: data = saved_batch() data["models"]["qwen"].update(status=status, candidate=None, comparison=None) panel.set_batch(data) assert expected in panel.status_label.text() assert panel.empty_label.isVisible() assert panel.herb_table.rowCount() == 0 assert not panel.chart.bars_enabled @pytest.mark.parametrize("validity", ["stale", "source_updated", "future_validity"]) def test_outdated_and_unknown_validity_only_show_saved_original_values(panel: PrescriptionComparisonPanel, validity: str) -> None: data = saved_batch() data["validity"] = validity panel.set_batch(data) assert not panel.chart.bars_enabled assert "历史原值" in panel.status_label.text() assert panel.herb_table.rowCount() == 2 def test_refresh_preserves_search_model_selection_and_both_scroll_positions(panel: PrescriptionComparisonPanel, application: QApplication) -> None: data = saved_batch([saved_row(f"黄芪{index}") for index in range(40)]) panel.set_batch(data) panel.model_buttons["openai"].click() panel.search.setText("黄芪") panel.herb_table.selectRow(10) application.processEvents() panel.herb_table.verticalScrollBar().setValue(180) panel.chart_scroll.verticalScrollBar().setValue(250) before = (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value()) for _ in range(3): panel.set_batch(deepcopy(data)) application.processEvents() assert panel.search.text() == "黄芪" assert panel.selected_model == "openai" assert panel.herb_table.currentRow() == 10 assert before == (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value()) changed = deepcopy(data) changed["models"]["qwen"]["progress"] = {"stage": "completed"} panel.set_batch(changed) application.processEvents() assert panel.search.text() == "黄芪" and panel.selected_model == "openai" assert before == (panel.herb_table.verticalScrollBar().value(), panel.chart_scroll.verticalScrollBar().value()) panel.search.setText("黄芪39") assert panel.herb_table.rowCount() == 1 assert len(panel.chart.rows) == 1 panel.search.setText("未匹配") assert "没有匹配" in panel.empty_label.text() def test_parent_owned_controls_do_not_flash_windows_on_update(application: QApplication) -> None: shown_windows = [] class WindowObserver(QObject): def eventFilter(self, watched: QObject, event: QEvent) -> bool: if event.type() == QEvent.Type.Show and isinstance(watched, QWidget) and watched.isWindow(): shown_windows.append(watched) return False observer = WindowObserver() application.installEventFilter(observer) host = QWidget() try: layout = QVBoxLayout(host) widget = PrescriptionComparisonPanel(host) layout.addWidget(widget) widget.set_batch(saved_batch()) host.show() application.processEvents() assert shown_windows == [host] shown_windows.clear() widget.set_batch(saved_batch([saved_row("当归")])) widget.model_buttons["openai"].click() widget.set_batch({}) application.processEvents() assert shown_windows == [] assert all(child.parentWidget() is not None for child in widget.findChildren(QWidget)) assert widget.herb_table.rowCount() == 0 assert widget.chart.rows == [] finally: application.removeEventFilter(observer) host.close() host.deleteLater() application.processEvents() @pytest.mark.parametrize("size", [(1120, 400), (940, 340)]) def test_compact_sizes_keep_table_and_chart_scrollable(panel: PrescriptionComparisonPanel, application: QApplication, size: tuple[int, int]) -> None: panel.parentWidget().resize(*size) panel.set_batch(saved_batch([saved_row(f"药材{index}") for index in range(60)])) application.processEvents() assert panel.width() <= size[0] and panel.height() <= size[1] assert panel.herb_table.viewport().height() >= 65 assert panel.herb_table.viewport().width() >= 320 assert panel.chart_scroll.viewport().height() >= 90 assert panel.herb_table.verticalScrollBar().maximum() > 0 assert panel.chart_scroll.verticalScrollBar().maximum() > 0 assert not panel.grab().isNull() assert panel.herb_table.item(0, 2).data(Qt.ItemDataRole.AccessibleTextRole) == "15 克 / 每剂" assert "药材59" in panel.chart.accessibleDescription()