This commit is contained in:
Your Name
2026-09-21 10:26:14 +08:00
parent bbe3e870a1
commit dbf474ddd7
67 changed files with 10636 additions and 279 deletions
+321 -2
View File
@@ -11,8 +11,8 @@ from uuid import UUID
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QPushButton
from PySide6.QtCore import QEvent, QObject, QRect, Qt
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
@@ -138,6 +138,215 @@ def test_shared_report_reads_only_and_renders_each_model_independently(applicati
assert repository.calls == calls
def test_report_open_and_poll_never_show_auxiliary_windows(application: QApplication, immediate: None) -> 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.metaObject().className(), watched.windowTitle()))
return False
observer = WindowObserver()
application.installEventFilter(observer)
dialog = None
try:
repository = Repository()
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
application.processEvents()
initial_windows = list(shown_windows)
shown_windows.clear()
dialog.tabs.setCurrentIndex(3)
dialog.model_views["qwen"]["comment"].setPlainText("正在填写的复核意见")
for _ in range(3):
dialog._timer.timeout.emit()
dialog._progress_timer.timeout.emit()
application.processEvents()
assert len([call for call in repository.calls if call[0] == "detail"]) == 4
assert shown_windows == [], "Polling must not show even transient top-level widgets"
assert initial_windows == [("IssuedPrescriptionAiDialog", dialog.windowTitle())]
assert dialog.tabs.currentIndex() == 3
assert dialog.model_views["qwen"]["comment"].toPlainText() == "正在填写的复核意见"
assert dialog.chip_row.count() == 2
assert all(dialog.chip_row.itemAt(index).widget().isVisible() for index in range(2))
assert dialog._timer.isActive() and dialog._progress_timer.isActive()
finally:
application.removeEventFilter(observer)
if dialog is not None:
dialog.close()
def test_navigation_names_every_destination_once(application: QApplication, immediate: None) -> None:
"""A duplicated tab name makes the navigation unreadable; the live progress lives in the one tab."""
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
dialog.show()
application.processEvents()
names = [dialog.tabs.tabText(index) for index in range(dialog.tabs.count())]
assert len(names) == len(set(names)), names
assert names.count("处理进度") == 1
# Those pages are hosted in a scroll area, so the tab holds the host, not the page itself.
for key in ("per_herb", "gaps", "history"):
assert dialog.tabs.indexOf(dialog.tab_pages[key]) >= 0
bar = dialog.model_views["qwen"]["progress_bar"]
assert dialog.pipeline_page.isAncestorOf(bar)
assert dialog.pipeline_page.stage_labels["qwen"].isHidden()
dialog.close()
@pytest.mark.parametrize(("width", "height"), [(1280, 860), (1024, 700), (940, 640)])
def test_prescription_workspace_is_the_primary_view(application: QApplication, immediate: None, width: int, height: int) -> None:
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
dialog.resize(width, height)
dialog.show()
application.processEvents()
assert (dialog.width(), dialog.height()) == (width, height)
assert dialog.tabs.currentWidget() is dialog.comparison_panel
assert dialog.tabs.tabText(0) == "对比总览"
assert dialog.comparison_panel.isVisible()
assert dialog.tabs.height() > height * 0.28
assert dialog.model_views["qwen"]["comment"].isVisible()
# The chart strip is on the first screen but must never outgrow its budget, and a model
# without a comparable score shows no filled bar.
assert dialog.model_views["qwen"]["agreement_bar"].value() == 0
assert dialog.model_views["openai"]["agreement_bar"].isHidden()
assert dialog.model_views["qwen"]["gauge"].accessibleDescription() == "0.0%"
assert dialog.model_views["openai"]["gauge"].accessibleDescription() == "暂无可比结果"
dialog.close()
@pytest.mark.parametrize(("width", "height"), [(1440, 940), (1280, 860), (1024, 700), (940, 640)])
def test_review_controls_remain_inside_the_sidebar(application: QApplication, immediate: None,
width: int, height: int) -> None:
repository = Repository()
model = repository.batches[0]["models"]["qwen"]
herb = model["candidate"]["herbs"][0]
model["comparison"]["rows"][0].update(key="黄芪", doctor={**herb, "dosage": 16}, candidate=herb)
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.resize(width, height)
dialog.show()
panel = dialog.comparison_panel
for _ in range(4):
application.processEvents()
assert dialog.nav_buttons["overview"].isChecked()
for key in ai.MODELS:
dialog.review_model.setCurrentIndex(0 if key == "qwen" else 1)
application.processEvents()
views = dialog.model_views[key]
for control in (dialog.review_model, views["review_state"], views["comment"], views["save"]):
assert control.isVisible()
bounds = QRect(control.mapTo(panel.checklist, control.rect().topLeft()), control.size())
assert panel.checklist.rect().contains(bounds), (key, control.accessibleName(), bounds)
assert ai.MODELS[key] in dialog.save_review_button.toolTip()
assert panel.checklist.isVisible()
dialog.close()
def test_hero_band_carries_the_frozen_identity_and_yields_on_short_windows(application: QApplication, immediate: None) -> None:
repository = Repository()
repository.batches[0]["doctor_snapshot"] = {
"patient": {"name": "测试患者", "gender_label": "女", "age": 58},
"diagnosis": {"clinical_diagnosis": "消渴病 气阴两虚"},
"prescription": {"prescription_type": "浓缩水丸", "dose_count": 1, "dose_unit": "剂"},
}
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.resize(1280, 860)
dialog.show()
application.processEvents()
assert "测试患者 · 女 · 58 岁" in dialog.identity.text()
assert "消渴病 气阴两虚" in dialog.identity.text()
assert "浓缩水丸 · 1 剂" in dialog.identity.text()
assert dialog.fact_values["basis"].text() == "同单位 · 每剂"
assert dialog.fact_values["prescription"].text() == "801 / 501"
dialog.resize(1024, 700)
application.processEvents()
assert dialog.fact_values["batch"].text() == "#40"
dialog.close()
def test_hero_band_shows_dashes_when_the_snapshot_is_not_deployed(application: QApplication, immediate: None) -> None:
repository = Repository()
repository.batches[0].pop("doctor_snapshot", None)
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.resize(1280, 860)
dialog.show()
application.processEvents()
assert dialog.identity.text() == "尚无冻结快照"
assert dialog.fact_values["prescription"].text().startswith("801")
dialog.close()
def test_failed_model_states_the_reason_and_offers_its_own_retry(application: QApplication, immediate: None) -> None:
repository = Repository()
repository.batches[0]["models"]["openai"].update(status="failed", error_code="INVALID_REPORT_OUTPUT",
error_message="INVALID_REPORT_OUTPUT")
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
application.processEvents()
assert dialog.model_views["openai"]["failure"].isVisible()
text = dialog.model_views["openai"]["failure_text"].text()
assert "OpenAI 失败" in text and "格式校验" in text and "原资料快照" in text
assert "INVALID_REPORT_OUTPUT" not in text
assert dialog.model_views["openai"]["retry"].isEnabled()
assert dialog.model_views["openai"]["retry"].isVisible()
# A model that is still running never shows a failure row.
dialog.model_views["openai"]["retry"].click()
application.processEvents()
assert ("retry", (40, "openai")) in repository.calls
dialog.close()
def test_exhausted_manual_retries_point_at_regeneration_instead_of_retry(application: QApplication, immediate: None) -> None:
repository = Repository()
repository.batches[0]["models"]["openai"].update(status="failed", error_code="UPSTREAM_TIMEOUT",
error_message="UPSTREAM_TIMEOUT", manual_retries=2)
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
application.processEvents()
text = dialog.model_views["openai"]["failure_text"].text()
assert "手动重试次数已用完" in text and "重新分析" in text
assert not dialog.model_views["openai"]["retry"].isVisible()
dialog.close()
def test_inline_review_keeps_model_drafts_and_saves_selected_model(application: QApplication, immediate: None) -> None:
repository = Repository()
repository.batches[0]["models"]["openai"].update(report_id=92, report={"summary": "已保存报告"})
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
application.processEvents()
dialog.model_views["qwen"]["comment"].setPlainText("千问复核草稿")
dialog.review_model.setCurrentIndex(1)
dialog.model_views["openai"]["comment"].setPlainText("OpenAI复核草稿")
dialog.model_views["openai"]["review_state"].setCurrentIndex(3)
dialog._poll()
application.processEvents()
assert dialog.review_model.currentData() == "openai"
assert dialog.model_views["qwen"]["comment"].toPlainText() == "千问复核草稿"
assert dialog.model_views["openai"]["comment"].toPlainText() == "OpenAI复核草稿"
dialog.save_review_button.click()
assert ("review", (40, "openai", "reviewed", "OpenAI复核草稿")) in repository.calls
assert not any(call[0] == "review" and call[1][1] == "qwen" for call in repository.calls)
dialog.close()
def test_workspace_links_reach_existing_supporting_reports(application: QApplication, immediate: None) -> None:
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
dialog.show()
# Each link opens the composed page that answers it, not the raw saved text.
for field, key in (("report", "report"), ("candidate", "candidate"), ("comparison", "per_herb"),
("sources", "gaps"), ("progress", "pipeline"), ("doctor", "original")):
dialog.comparison_panel.open_report.emit(field)
assert dialog.tabs.currentWidget() is dialog.tab_pages[key], field
dialog.nav_buttons["overview"].click()
assert dialog.tabs.currentWidget() is dialog.comparison_panel
assert dialog.nav_buttons["overview"].isChecked()
assert not dialog.nav_buttons["gaps"].isChecked()
dialog.nav_buttons["per_herb"].click()
assert dialog.tabs.currentWidget() is dialog.tab_pages["per_herb"]
dialog.close()
def test_retry_and_review_target_only_selected_model(application: QApplication, immediate: None) -> None:
repository = Repository()
repository.batches[0]["status"] = "partial"
@@ -681,6 +890,25 @@ def test_metadata_and_prose_remain_html_escaped(application: QApplication) -> No
browser.close()
def test_candidate_reading_view_keeps_full_medicine_instructions(application: QApplication) -> None:
candidate = {
"prescription_name": "测试候选方", "dose_basis": "per_dose", "unit": "g",
"herbs": [{"name": "测试药材", "dosage": 12, "formula_type": "主方",
"instructions": "先煎,具体时长由医生复核", "processing": "生品",
"evidence_references": ["diagnoses:501"]}],
"rationale": "保留完整方义", "risk_warnings": ["复核提示 <script>bad()</script>"],
}
html = ai._candidate_html(candidate)
assert html.index("测试药材") < html.index("保留完整方义")
assert "<script>" not in html
browser = ai._browser()
browser.setHtml(html)
text = browser.toPlainText()
for expected in ("测试药材", "12", "克", "每剂", "主方", "先煎", "生品", "诊单(编号:501)", "保留完整方义"):
assert expected in text
browser.close()
def chinese_history_batch() -> dict[str, Any]:
"""Synthetic fixture; no patient or network data is used in tests or visual QA."""
value = batch(39, status="success")
@@ -971,6 +1199,7 @@ def test_progress_updates_preserve_report_document_scroll_and_selection(applicat
repository.batches = [value]
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
dialog.tabs.setCurrentWidget(dialog.report_pages["report"])
application.processEvents()
report = dialog.model_views["qwen"]["report"]
report.verticalScrollBar().setValue(300)
@@ -1099,3 +1328,93 @@ def test_retry_wait_freezes_attempt_duration_while_countdown_advances_and_poll_r
def test_unknown_or_invalid_attempt_count_is_not_invented(attempt: Any) -> None:
view = ai.progress_view({"status": "retry_wait", "progress": {"stage": "retry_wait", "attempt": attempt}})
assert "次尝试" not in view.detail
def test_theme_switch_keeps_the_page_the_drafts_and_every_step(application: QApplication,
immediate: None) -> None:
"""The palette swaps in place; nothing the doctor typed or opened is lost."""
from doctor_workstation.ui.dialogs import issued_prescription_ai_theme as theme
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
dialog.resize(1280, 860)
dialog.show()
application.processEvents()
dialog._goto("gaps")
dialog.model_views["qwen"]["comment"].setPlainText("切换前写的复核意见")
dark = theme.CONSOLE["canvas"]
try:
dialog._switch_theme()
application.processEvents()
assert theme.CONSOLE["canvas"] != dark
assert theme.current_theme() == "light"
assert dialog.model_views["qwen"]["comment"].toPlainText() == "切换前写的复核意见"
assert dialog.tabs.currentWidget() is dialog.tab_pages["gaps"]
assert len(dialog.rail.buttons) == len(ai.NAV_PRIMARY)
assert all(button.isVisible() for button in dialog.rail.buttons.values())
finally:
dialog._switch_theme()
application.processEvents()
assert theme.CONSOLE["canvas"] == dark
dialog.close()
def test_every_step_stays_readable_in_both_palettes(application: QApplication, immediate: None) -> None:
"""A selected step paints its own labels: a descendant :checked rule would not reach them."""
from doctor_workstation.ui.dialogs import issued_prescription_ai_theme as theme
dialog = ai.IssuedPrescriptionAiDialog(Repository(), ["*"], prescription_id=801)
dialog.show()
application.processEvents()
rail = dialog.rail
assert theme.CONSOLE["heading"] in rail.names["overview"].styleSheet()
assert theme.CONSOLE["muted"] in rail.names["report"].styleSheet()
rail.set_current("report")
assert theme.CONSOLE["heading"] in rail.names["report"].styleSheet()
assert theme.CONSOLE["muted"] in rail.names["overview"].styleSheet()
dialog.close()
def test_native_title_bar_takes_the_console_palette() -> None:
"""The frame Windows draws follows the theme, so the dark body does not stop at the caption."""
from doctor_workstation.ui.dialogs import issued_prescription_ai_theme as theme
# DWM wants 0x00BBGGRR, not the #RRGGBB the palette is written in.
assert theme._colorref("#080D18") == 0x180D08
assert theme._colorref("#FFFFFF") == 0xFFFFFF
class Handleless:
def winId(self) -> int:
return 0
assert theme.apply_window_chrome(Handleless()) is False
def test_report_rail_lists_the_saved_sections_and_filters_to_differences(
application: QApplication, immediate: None) -> None:
repository = Repository()
repository.batches[0]["models"]["qwen"]["report"] = {"summary": "两侧一致的概要", "analysis": "千问的分析"}
repository.batches[0]["models"]["openai"]["report"] = {"summary": "两侧一致的概要", "analysis": "OpenAI 的分析"}
dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801)
dialog.show()
dialog._goto("report")
application.processEvents()
titles = [dialog.report_toc.itemAt(index).widget().text()
for index in range(dialog.report_toc.count())]
assert "概要" in titles and "综合分析" in titles
both = dialog.model_views["qwen"]["report"].toPlainText()
assert "两侧一致的概要" in both and "千问的分析" in both
dialog._set_report_mode("differences")
application.processEvents()
filtered = dialog.model_views["qwen"]["report"].toPlainText()
assert "千问的分析" in filtered
assert "两侧一致的概要" not in filtered # 两侧完全相同的段落不算差异
dialog._set_report_mode("qwen")
application.processEvents()
assert not dialog.report_columns["openai"].isVisible()
dialog._set_report_mode("both")
application.processEvents()
assert dialog.report_columns["openai"].isVisible()
dialog.close()
@@ -0,0 +1,399 @@
"""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()
@@ -0,0 +1,478 @@
"""The composed pages must show the saved batch exactly, including what is missing from it."""
from __future__ import annotations
from typing import Any
import pytest
from PySide6.QtWidgets import QApplication, QLabel
from doctor_workstation.ui.dialogs import issued_prescription_ai_pages as pages
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def comparison_rows(model: str) -> list[dict[str, Any]]:
doses = {"qwen": {"生地黄": 15, "生麦冬": 12, "麸炒白术": 12}, "openai": {"生地黄": 12, "茯苓": 8}}[model]
contributions = {"qwen": {"生地黄": 0.94, "生麦冬": 1.0}, "openai": {"生地黄": 0.75}}[model]
doctor = {"生地黄": 16, "生麦冬": 12, "红参片": 6, "茯苓": 10}
rows = []
for name in sorted(set(doses) | set(doctor)):
rows.append({
"name": name, "unit": "g",
"doctor_dosage": doctor.get(name),
"candidate_dosage": doses.get(name),
"contribution": contributions.get(name),
})
return rows
def batch(**overrides: Any) -> dict[str, Any]:
value = {
"id": 13, "status": "success", "coverage_status": "partial", "comparison_type": "latest_context",
"source_summary": {"attachment_count": 4, "diagnoses_count": 1, "video_calls_count": 3,
"source_record_count": 9},
"missing": [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
{"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "critical": False}],
"models": {
"qwen": {"status": "success", "algorithm_version": "prescription-soft-dice-v1.1.0",
"prompt_version": "manual-prescription-required-candidate-v4",
"comparison": {"status": "comparable", "rows": comparison_rows("qwen")},
"coverage": {"files": [{"status": "processed"}, {"status": "processed"},
{"status": "processed"}, {"status": "restricted"}]},
"progress": {"stage_label": "处理完成", "elapsed_seconds": 135, "attempt": 1},
"usage": {"total_calls": 2, "calls": [
{"stage": "text:0", "ok": True, "latency_ms": 3400, "file_count": 0,
"usage": {"completion_tokens": 1020}, "error_code": ""},
{"stage": "final", "ok": False, "latency_ms": 14400, "file_count": 0,
"usage": {"completion_tokens": 5617}, "error_code": "INVALID_REPORT_OUTPUT"}]}},
"openai": {"status": "success", "comparison": {"status": "comparable", "rows": comparison_rows("openai")},
"progress": {"stage_label": "处理完成", "elapsed_seconds": 593, "attempt": 1},
"usage": {"calls": []}},
},
}
value.update(overrides)
return value
# ---------------------------------------------------------------- candidates page
@pytest.fixture
def per_herb(application: QApplication) -> pages.CandidatesPage:
widget = pages.CandidatesPage()
widget.resize(1200, 700)
widget.show()
application.processEvents()
yield widget
widget.close()
def _dose_cells(page: pages.CandidatesPage, row: int) -> tuple[str, str]:
return (page.table.cellWidget(row, 1).dose, page.table.cellWidget(row, 2).dose)
def _verdict(page: pages.CandidatesPage, row: int) -> str:
return page.table.cellWidget(row, 4).findChildren(QLabel)[0].text()
def test_per_herb_merges_both_models_into_one_row(per_herb: pages.CandidatesPage) -> None:
per_herb.set_batch(batch())
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
assert names.count("生地黄") == 1
row = names.index("生地黄")
assert _dose_cells(per_herb, row) == ("15 g", "12 g")
assert per_herb.table.item(row, 3).text() == "16 g"
assert per_herb.table.cellWidget(row, 1).contribution == 0.94
assert per_herb.table.cellWidget(row, 2).contribution == 0.75
def test_per_herb_marks_absence_without_inventing_a_dose(per_herb: pages.CandidatesPage) -> None:
per_herb.set_batch(batch())
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
only_doctor = names.index("红参片")
assert _dose_cells(per_herb, only_doctor) == ("—", "—")
assert per_herb.table.cellWidget(only_doctor, 1).contribution is None
assert _verdict(per_herb, only_doctor) == "仅医方使用"
added = names.index("麸炒白术")
assert per_herb.table.item(added, 3).text() == "未收录"
assert _verdict(per_herb, added) == "仅 千问 收录"
def test_per_herb_conclusion_states_a_dose_gap_over_the_threshold(per_herb: pages.CandidatesPage) -> None:
per_herb.set_batch(batch())
names = [per_herb.table.item(row, 0).text() for row in range(per_herb.table.rowCount())]
row = names.index("生地黄")
assert _verdict(per_herb, row) == "两模型均收录" # 医方 16,两侧差 1 与 4,未过 5 克阈值
source = batch()
for model in source["models"].values():
for entry in model["comparison"]["rows"]:
if entry["name"] == "生地黄":
entry["candidate_dosage"] = 9
per_herb.set_batch(source)
names = [per_herb.table.item(index, 0).text() for index in range(per_herb.table.rowCount())]
assert _verdict(per_herb, names.index("生地黄")) == "剂量分歧 7 g"
def test_per_herb_filters_count_and_narrow_the_table(
per_herb: pages.CandidatesPage, application: QApplication) -> None:
per_herb.set_batch(batch())
total = per_herb.table.rowCount()
assert per_herb.filter_buttons["all"].text() == f"全部 {total}"
per_herb.search.setText("生地黄")
application.processEvents()
assert per_herb.table.rowCount() == 1
per_herb.search.clear()
per_herb.filter_buttons["doctor"].click()
application.processEvents()
assert 0 < per_herb.table.rowCount() < total
assert all(_verdict(per_herb, row) in {"仅医方使用", "两模型均未收录"}
for row in range(per_herb.table.rowCount()))
per_herb.filter_buttons["single"].click()
application.processEvents()
assert all("仅 " in _verdict(per_herb, row) for row in range(per_herb.table.rowCount()))
def test_per_herb_cards_show_each_saved_prescription(per_herb: pages.CandidatesPage) -> None:
per_herb.set_batch(batch(doctor_snapshot={"prescription": {
"herbs": [{"name": "生地黄", "dosage": 16, "unit": "g"}, {"name": "红参片", "dosage": 6, "unit": "g"}],
"prescription_type": "浓缩水丸", "dose_count": 1, "usage_instruction": "每日1剂,水煎分服。"}}))
doctor = per_herb.cards["doctor"]
assert doctor.summary.text() == "2 味 · 浓缩水丸"
assert "生地黄" in doctor.herbs.text() and "16 g" in doctor.herbs.text()
assert "每日1剂" in doctor.usage.text()
assert per_herb.cards["qwen"].summary.text() == "尚无候选方"
def test_per_herb_card_grows_for_a_long_usage_note(per_herb: pages.CandidatesPage,
application: QApplication) -> None:
"""A card must not cap itself and cut the herb list or the 方义 in half."""
note = "方中生黄芪益气固表,生地黄、生麦冬滋阴清热。" * 8
per_herb.set_batch(batch(doctor_snapshot={"prescription": {
"herbs": [{"name": f"药{index}", "dosage": 15, "unit": "g"} for index in range(20)],
"prescription_type": "浓缩水丸", "dose_count": 1, "usage_instruction": note}}))
per_herb.resize(1100, 700)
application.processEvents()
card = per_herb.cards["doctor"]
assert "另有 15 味" in card.herbs.text()
assert card.usage.height() >= card.usage.heightForWidth(card.usage.width())
assert card.height() >= card.herbs.height() + card.usage.height()
def test_per_herb_says_when_no_prescription_was_saved(per_herb: pages.CandidatesPage) -> None:
per_herb.set_batch({})
assert per_herb.cards["doctor"].summary.text() == "未保存原方"
assert per_herb.cards["doctor"].herbs.text() == "尚未保存药味"
assert per_herb.table.rowCount() == 0
# ---------------------------------------------------------------- sources page
@pytest.fixture
def sources(application: QApplication) -> pages.SourcesPage:
widget = pages.SourcesPage()
widget.resize(900, 600)
widget.show()
application.processEvents()
yield widget
widget.close()
def test_sources_groups_gaps_by_type_and_keeps_criticality(sources: pages.SourcesPage) -> None:
sources.set_batch(batch())
rows = {sources.gaps.item(row, 0).text(): (sources.gaps.item(row, 1).text(), sources.gaps.item(row, 2).text())
for row in range(sources.gaps.rowCount())}
transcript = next(key for key in rows if "转写" in key)
assert rows[transcript] == ("关键", "2")
archive = next(key for key in rows if "归档" in key)
assert rows[archive][0] == "一般"
def _composition(sources: pages.SourcesPage) -> dict[str, str]:
rows = {}
for index in range(sources.composition_layout.count()):
widget = sources.composition_layout.itemAt(index).widget()
labels = widget.findChildren(QLabel)
rows[labels[0].text()] = labels[-1].text()
return rows
def test_sources_reports_attachment_reading_and_composition(sources: pages.SourcesPage) -> None:
sources.set_batch(batch())
assert "模型实际读取 3 个" in sources.attachment_note.text()
assert sources.waffle.accessibleDescription() == "附件 4 个:已读 3,受限或不支持 1"
assert _composition(sources)["问诊通话"] == "3"
assert "soft-dice" in sources.meta.text()
def test_sources_reports_what_each_model_managed_to_read(sources: pages.SourcesPage) -> None:
sources.set_batch(batch())
rows = {sources.per_model.item(row, 0).text(): (sources.per_model.item(row, 1).text(),
sources.per_model.item(row, 3).text())
for row in range(sources.per_model.rowCount())}
assert rows["千问"] == ("3", "75%")
assert "已读取 3" in sources.attachment_legend.text()
def test_sources_meta_states_the_cutoff_and_reads_codes_in_chinese(sources: pages.SourcesPage) -> None:
sources.set_batch(batch(cutoff_at="2026-09-10 15:29"))
text = sources.meta.text()
assert "资料截止:2026-09-10 15:29" in text
assert "覆盖状态:部分资料缺失" in text # partial 在覆盖语境里说的是资料,不是进度
assert "对照类型:最新资料对照" in text
sources.set_batch(batch())
assert "资料截止:—" in sources.meta.text()
def test_sources_stays_empty_without_a_batch(sources: pages.SourcesPage) -> None:
sources.set_batch({})
assert _composition(sources) == {}
assert sources.gaps.rowCount() == 0
assert sources.attachment_note.text() == "本次没有附件"
assert not sources.gap_note.isVisible()
def test_sources_puts_critical_gaps_first_and_counts_them(sources: pages.SourcesPage) -> None:
sources.set_batch(batch())
assert "关键" in sources.gaps.item(0, 1).text()
assert "3 项 · 关键 2" in sources.gap_title.findChildren(QLabel)[-1].text()
# ---------------------------------------------------------------- progress page
@pytest.fixture
def progress(application: QApplication) -> pages.ProgressPage:
widget = pages.ProgressPage()
widget.resize(900, 600)
widget.show()
application.processEvents()
yield widget
widget.close()
def test_progress_lists_every_call_with_its_outcome(progress: pages.ProgressPage) -> None:
progress.set_batch(batch())
assert progress.calls.rowCount() == 2
assert progress.calls.item(0, 0).text() == "千问"
assert progress.calls.item(0, 1).text() == "文字资料 1" # 阶段键不直接露出
assert progress.calls.item(1, 1).text() == "生成候选与报告"
assert progress.calls.item(0, 3).text() == "3.4 s"
assert progress.calls.item(0, 6).text() == "通过"
assert progress.calls.item(1, 5).text() == "5617"
# 最慢的一次调用占满耗时分布条,其余按比例
assert progress.calls.cellWidget(1, 2)._fraction == 1.0
assert progress.calls.cellWidget(0, 2)._fraction < 0.3
assert "INVALID_REPORT_OUTPUT" not in progress.calls.item(1, 5).text()
def test_progress_shows_each_model_stage_and_attempt(progress: pages.ProgressPage) -> None:
progress.set_batch(batch())
assert "处理完成" in progress.stage_labels["qwen"].text()
assert "第 1 次尝试" in progress.stage_labels["qwen"].text()
assert "已用时 2 分 15 秒" in progress.stage_labels["qwen"].text()
assert "已用时 9 分 53 秒" in progress.stage_labels["openai"].text()
# ---------------------------------------------------------------- history page
@pytest.fixture
def history(application: QApplication) -> pages.HistoryPage:
widget = pages.HistoryPage()
widget.resize(900, 600)
widget.show()
application.processEvents()
yield widget
widget.close()
def test_history_orders_by_time_and_keeps_uncomparable_slots_empty(history: pages.HistoryPage) -> None:
history.set_history([
{"id": 13, "created_at": 300, "status": "success", "comparison_type": "latest_context",
"models": {"qwen": {"score": 18.9, "comparison_status": "comparable",
"algorithm_version": "prescription-soft-dice-v1.1.0"},
"openai": {"score": 16.7, "comparison_status": "comparable"}}},
{"id": 11, "created_at": 100, "status": "failed",
"models": {"qwen": {"score": None, "comparison_status": "not_comparable"},
"openai": {"score": None, "comparison_status": "not_comparable"}}},
])
description = history.chart.accessibleDescription()
assert description.index("11") < description.index("13") # oldest first on the chart
assert "11 千问 — OpenAI —" in description
assert history.table.item(0, 0).text() == "#13" # newest first in the list
assert history.table.item(0, 3).text() == "18.9%"
assert history.table.item(1, 3).text() == "—"
assert history.table.item(0, 5).text() == "v1.1.0"
assert history.chart.has_data()
def test_history_reads_the_score_from_either_payload_shape(history: pages.HistoryPage) -> None:
"""列表接口把分数摊平,详情接口留在 comparison 里,两种都要认。"""
history.set_history([
{"id": 40, "created_at": "2026-09-10 15:29:00", "status": "success", "comparison_type": "non_independent",
"models": {"qwen": {"comparison": {"status": "comparable", "score": 94.44}},
"openai": {"comparison": {"status": "not_comparable", "score": 93.3}}}},
{"id": 39, "created_at": "2026-09-09 09:00:00", "status": "success",
"models": {"qwen": {"score": 42.9, "comparison_status": "comparable"}}},
])
assert history.table.item(0, 0).text() == "#40"
assert history.table.item(0, 3).text() == "94.4%"
assert history.table.item(0, 4).text() == "—" # 不可比就不给分,哪怕载荷里带着数字
assert "分层" in history.strata.text() or "同一套" in history.strata.text()
assert history.table.item(1, 3).text() == "42.9%"
description = history.chart.accessibleDescription()
assert description.index("39") < description.index("40")
def test_history_without_any_comparable_batch_draws_nothing(history: pages.HistoryPage) -> None:
history.set_history([{"id": 1, "created_at": 1, "models": {"qwen": {"comparison_status": "not_comparable"}}}])
assert not history.chart.has_data()
assert history.table.rowCount() == 1
# ---------------------------------------------------------------- statistics panel
@pytest.fixture
def statistics(application: QApplication) -> pages.StatisticsPanel:
widget = pages.StatisticsPanel()
widget.resize(1000, 600)
widget.show()
application.processEvents()
yield widget
widget.close()
def statistics_payload() -> dict[str, Any]:
return {
"total_count": 10, "patient_count": 8,
"doctors": [
{"doctor_id": 26, "doctor_name": "何医生", "total_count": 6, "patient_count": 5, "paired_count": 3,
"models": {"qwen": {"eligible_count": 4, "mean": 18.4, "median": 16.9,
"excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 2}},
"openai": {"eligible_count": 3, "mean": 21.0, "median": 19.6,
"excluded_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3}}},
"review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}},
{"doctor_id": 31, "doctor_name": "李医生", "total_count": 4, "patient_count": 3, "paired_count": 1,
"models": {"qwen": {"eligible_count": 2, "mean": 25.0, "excluded_reasons": {"incomplete_coverage": 2}},
"openai": {"eligible_count": 0, "mean": None, "excluded_reasons": {"incomplete_coverage": 4}}},
"review": {"evaluated_count": 2, "qualified_count": 1, "qualified_rate": 50.0}},
],
}
def test_statistics_headline_counts_and_coverage(statistics: pages.StatisticsPanel) -> None:
statistics.set_statistics(statistics_payload())
assert statistics.kpi_values["events"].text() == "10"
assert "涉及患者 8 人" in statistics.kpi_notes["events"].text()
assert statistics.kpi_values["qwen"].text() == "6"
assert "覆盖率 60.0%" in statistics.kpi_notes["qwen"].text()
assert statistics.kpi_values["openai"].text() == "3"
def test_statistics_review_rate_needs_a_review_sample(statistics: pages.StatisticsPanel) -> None:
payload = statistics_payload()
for doctor in payload["doctors"]:
doctor["review"] = {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}
statistics.set_statistics(payload)
assert statistics.kpi_values["review"].text() == "—"
assert "尚未建立复核样本" in statistics.kpi_notes["review"].text()
statistics.set_statistics(statistics_payload())
assert statistics.kpi_values["review"].text() == "50.0%"
def _bars(layout) -> list[str]:
return [layout.itemAt(index).widget().accessibleDescription() for index in range(layout.count())]
def test_statistics_funnel_and_exclusions_are_aggregated(statistics: pages.StatisticsPanel) -> None:
statistics.set_statistics(statistics_payload())
funnel = _bars(statistics.funnel_layout)
assert "范围内开方事件 10" in funnel
assert "千问 有效基线比较 6" in funnel
assert "两模型配对共同样本 4" in funnel
reasons = _bars(statistics.exclusion_layout)
assert any(text.endswith(" 5") for text in reasons) # 来源历史版本无法重建 2 + 3
assert all("SOURCE_HISTORY" not in text for text in reasons)
def test_statistics_distribution_sums_the_saved_bins(statistics: pages.StatisticsPanel) -> None:
payload = statistics_payload()
payload["doctors"][0]["models"]["qwen"]["distribution"] = {"[0,20)": 3, "[20,40)": 1}
payload["doctors"][1]["models"]["qwen"]["distribution"] = {"[0,20)": 2}
statistics.set_statistics(payload)
assert statistics.distribution.has_data()
assert "千问 5/1/0/0/0" in statistics.distribution.accessibleDescription()
assert statistics.distribution.isVisible()
def test_statistics_draws_no_distribution_without_samples(statistics: pages.StatisticsPanel) -> None:
statistics.set_statistics(statistics_payload())
assert not statistics.distribution.has_data()
assert statistics.chart_empty.isVisible()
def test_statistics_summary_row_pairs_mean_with_median(statistics: pages.StatisticsPanel) -> None:
statistics.set_statistics(statistics_payload())
assert statistics.summary_values["qwen"].text() == "21.7% / 16.9%"
assert statistics.summary_values["paired"].text() == "4 例"
def test_statistics_lists_each_doctor_without_ranking(statistics: pages.StatisticsPanel) -> None:
statistics.set_statistics(statistics_payload())
assert statistics.doctors.rowCount() == 2
assert statistics.doctors.item(0, 0).text() == "何医生"
assert statistics.doctors.item(0, 3).text() == "4 / 18.4%"
assert statistics.doctors.item(1, 4).text() == "0 / —"
assert statistics.doctors.item(1, 6).text() == "50.0%"
assert "不是医生准确率" in statistics.footnote.text()
def test_progress_counts_the_batch_in_the_stat_row(progress: pages.ProgressPage) -> None:
progress.set_batch(batch())
assert progress.stat_values["calls"].text() == "2 次"
assert progress.stat_values["failures"].text() == "1 次"
assert progress.stat_values["repairs"].text() == "0 次"
assert progress.stat_values["elapsed"].text() == "9 分 53 秒"
def test_progress_derives_its_stages_from_the_saved_calls(progress: pages.ProgressPage) -> None:
progress.set_batch(batch())
names = []
layout = progress.stage_lists["qwen"]
for index in range(layout.count()):
widget = layout.itemAt(index).widget()
names.append(widget.findChildren(QLabel)[1].text())
assert names == ["文字资料分析", "生成候选与报告"]
assert progress.stage_lists["openai"].count() == 0 # 没有调用记录就不编造阶段
def test_history_names_the_version_change_between_two_batches(history: pages.HistoryPage) -> None:
history.set_history([
{"id": 5, "created_at": "2026-09-10 15:29:00", "status": "success",
"models": {"qwen": {"comparison": {"status": "comparable", "score": 15.3},
"algorithm_version": "prescription-soft-dice-v1.1.0",
"prompt_version": "v4"}}},
{"id": 4, "created_at": "2026-09-10 15:18:00", "status": "success",
"models": {"qwen": {"comparison": {"status": "comparable", "score": 18.9},
"algorithm_version": "prescription-soft-dice-v1.0.1",
"prompt_version": "v3"}}},
])
text = history.strata.text()
assert "比较算法 v1.0.1 → v1.1.0" in text
assert "提示词 v3 → v4" in text
assert "不能直接相减" in text
def test_history_says_when_every_batch_shares_one_version(history: pages.HistoryPage) -> None:
history.set_history([
{"id": 2, "created_at": "2026-09-10 15:29:00",
"models": {"qwen": {"algorithm_version": "prescription-soft-dice-v1.1.0"}}},
{"id": 1, "created_at": "2026-09-10 14:29:00",
"models": {"qwen": {"algorithm_version": "prescription-soft-dice-v1.1.0"}}},
])
assert "全部批次使用同一套算法" in history.strata.text()
def test_history_shows_why_a_batch_has_no_score(history: pages.HistoryPage) -> None:
history.set_history([{"id": 2, "created_at": "2026-09-10 13:25:00", "status": "failed",
"models": {"qwen": {"error_message": "模型返回未通过校验"}}}])
assert "模型返回未通过校验" in history.table.item(0, 2).text()
assert history.table.item(0, 3).text() == "—"
@@ -0,0 +1,337 @@
"""Data provenance and native UI checks for the prescription overview page."""
from __future__ import annotations
import os
from copy import deepcopy
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from doctor_workstation.ui.dialogs.issued_prescription_ai_workspace import (
PrescriptionReviewWorkspace,
checklist_items,
diagnosis_text,
dose_deltas,
gap_counts,
review_rows,
)
def batch(count: int = 3) -> dict:
rows = []
herbs = []
for index in range(count):
herb = {"name": f"药材{index}", "dosage": "15.00", "unit": "g", "dose_basis": "per_dose", "formula_type": "主方", "processing": "生品"}
rows.append({"key": f"saved-identity-{index}", "name": herb["name"], "doctor": {**herb, "dosage": "30.00"}, "candidate": {**herb, "source_rows": [index]}, "match_type": "matched"})
herbs.append(herb)
model = {"status": "succeeded", "report": {"diagnosis": "已保存的辨证意见", "summary": "不能当作诊断的摘要", "missing_information": ["缺少舌脉记录"]}, "candidate": {"status": "available_for_review", "herbs": herbs}, "comparison": {"status": "comparable", "rows": rows}}
return {"id": 4, "validity": "current", "models": {"qwen": deepcopy(model), "openai": deepcopy(model)}, "doctor_snapshot": {"patient": {"name": "测试患者", "gender": "male", "age": 50}, "diagnosis": {"western_diagnosis": "已记录西医诊断", "tcm_diagnosis": "已记录中医诊断", "syndrome": "已记录证候"}, "prescription": {"herbs": deepcopy(herbs), "usage_instruction": "水煎服", "usage_days": 7, "times_per_day": 2}}}
@pytest.fixture(scope="module")
def application():
return QApplication.instance() or QApplication([])
@pytest.fixture
def workspace(application):
host = QWidget()
host.resize(1024, 760)
layout = QVBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0)
widget = PrescriptionReviewWorkspace(host)
layout.addWidget(widget)
host.show()
application.processEvents()
yield widget
host.close()
host.deleteLater()
application.processEvents()
def test_three_series_need_saved_unique_identity_and_same_baseline():
source = batch()
before = deepcopy(source)
rows, _ = review_rows(source)
assert source == before
assert len(rows) == 3
assert all(set(row.doses) == {"doctor", "qwen", "openai"} for row in rows)
assert rows[0].scale == ("克", "每剂")
assert rows[0].changed
assert "15.00 克 / 每剂" in rows[0].description
assert "候选原方记录:第 1 项 药材0" in rows[0].description
assert "saved-identity" not in rows[0].description
@pytest.mark.parametrize("mutation", ["no_keys", "different_baseline", "duplicate_keys", "different_units", "different_identity"])
def test_no_guessed_cross_model_join(mutation):
source = batch(1)
target = source["models"]["openai"]["comparison"]["rows"][0]
if mutation == "no_keys":
for model in source["models"].values():
model["comparison"]["rows"][0].pop("key")
elif mutation == "different_baseline":
target["doctor"]["dosage"] = 31
elif mutation == "duplicate_keys":
source["models"]["openai"]["comparison"]["rows"].append(deepcopy(target))
elif mutation == "different_units":
target["candidate"]["unit"] = "mg"
else:
target["candidate"]["processing"] = "炙品"
rows, _ = review_rows(source)
assert len(rows) >= 2
assert all(len(row.entries) == 1 for row in rows)
@pytest.mark.parametrize("value", ["nan", "-3", "1/2", "Infinity", None])
def test_invalid_numbers_and_missing_are_never_zero(value):
source = batch(1)
for model in source["models"].values():
model["comparison"]["rows"][0]["candidate"]["dosage"] = value
rows, _ = review_rows(source)
assert rows[0].scale is None
assert not rows[0].changed
if value is None:
assert rows[0].entries["qwen"].candidate.label == "—"
@pytest.mark.parametrize("field,value", [("validity", "stale"), ("validity", "source_updated"), ("validity", None), ("status", "running"), ("status", "failed"), ("comparison", "not_comparable"), ("candidate", "withheld_for_risk")])
def test_pending_historical_and_error_are_text_only(field, value):
source = batch()
if field == "validity":
source[field] = value
else:
for model in source["models"].values():
if field == "status":
model[field] = value
else:
model[field]["status"] = value
rows, _ = review_rows(source)
assert rows
assert all(row.scale is None for row in rows)
assert all(not row.changed for row in rows)
def test_unaccounted_candidates_and_missing_trace_survive():
source = batch(1)
for model in source["models"].values():
model["candidate"]["herbs"].append({"name": "未规范炮制药", "dosage": "2.5", "unit": "g"})
model["comparison"]["rows"][0]["candidate"].pop("source_rows")
rows, _ = review_rows(source)
assert len(rows) == 5
originals = [row for row in rows if next(iter(row.entries.values())).origin != "comparison"]
assert len(originals) == 4
assert all(row.scale is None for row in originals)
assert sum(row.name == "未规范炮制药" for row in originals) == 2
def test_diagnosis_never_fabricated_from_summary():
assert diagnosis_text({"report": {"summary": "摘要疾病"}}) == "诊断意见未保存"
assert diagnosis_text({"report": {"diagnosis": "辨证原文"}}) == "辨证原文"
assert diagnosis_text({"report": {"diagnosis": {"western_diagnosis": "诊断原值"}}}) == "西医诊断:诊断原值"
def test_missing_side_is_not_a_zero_dose_difference():
source = batch(1)
for model in source["models"].values():
model["comparison"]["rows"][0]["doctor"] = None
rows, _ = review_rows(source)
assert rows[0].doctor is None
assert not rows[0].changed
def test_historical_report_states_itself_instead_of_reporting_zero_differences(workspace):
source = batch()
source["validity"] = "stale"
workspace.set_batch(source)
assert "历史或失效报告" in workspace.summary_text
assert workspace.doses.rows == [] # 不可比就不画,不是画成 0
assert workspace.doses.empty.isVisible()
def test_每味药与原方的距离按共同标尺画出(workspace):
workspace.set_batch(batch())
assert [delta.name for delta in workspace.doses.rows] == ["药材0", "药材1", "药材2"]
assert all(delta.deltas["qwen"] == -15 for delta in workspace.doses.rows)
assert "3 项剂量差异" in workspace.summary_text
axis = workspace.doses.body.findChildren(QWidget)
described = [widget.accessibleDescription() for widget in axis if widget.accessibleName() == "剂量差异条"]
assert "药材0 千问 −15克 OpenAI −15克" in described
def test_a_herb_one_model_never_listed_is_marked_absent_not_zero(workspace):
source = batch(1)
source["models"]["openai"]["comparison"]["rows"][0]["candidate"] = None
source["models"]["openai"]["candidate"]["herbs"] = []
workspace.set_batch(source)
delta = workspace.doses.rows[0]
assert delta.deltas["openai"] is None
assert delta.badge == "OpenAI 未收录"
assert delta.deltas["qwen"] == -15
def test_an_unreadable_saved_dose_takes_the_whole_row_off_the_chart(workspace):
source = batch(1)
source["models"]["openai"]["comparison"]["rows"][0]["candidate"]["dosage"] = "nan"
workspace.set_batch(source)
assert workspace.doses.rows == []
def test_an_incomparable_basis_keeps_the_row_off_the_chart(workspace):
source = batch(1)
for model in source["models"].values():
model["comparison"]["rows"][0]["doctor"]["dose_basis"] = "per_day"
workspace.set_batch(source)
assert workspace.doses.rows == []
def test_a_pending_model_adds_no_row_and_no_difference(workspace):
source = batch(1)
source["models"]["openai"] = {"status": "running"}
workspace.set_batch(source)
assert len(workspace.doses.rows) == 1
assert workspace.doses.rows[0].deltas["openai"] is None
def test_new_herbs_carry_their_full_dose_and_say_which_model_added_them(workspace):
source = batch(1)
for model in source["models"].values():
model["comparison"]["rows"][0]["doctor"] = None
workspace.set_batch(source)
delta = workspace.doses.rows[0]
assert delta.doctor is None
assert delta.deltas["qwen"] == 15
def test_gap_counts_split_the_saved_gaps_into_three_buckets() -> None:
source = batch(1)
source["missing"] = [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True},
{"code": "ATTACHMENT_STORAGE_RESTRICTED"},
{"code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE"}]
assert gap_counts(source) == {"critical": 1, "attachment": 1, "other": 1}
assert gap_counts(batch(1)) == {"critical": 0, "attachment": 0, "other": 0}
def _conclusion_texts(workspace) -> list[str]:
"""The numbered sentences, skipping the hairlines the design puts between them."""
texts = []
for index in range(workspace.conclusions.body_layout.count()):
widget = workspace.conclusions.body_layout.itemAt(index).widget()
labels = widget.findChildren(QLabel) if widget is not None else []
if labels:
texts.append(labels[-1].text())
return texts
def test_conclusions_state_the_counts_they_were_built_from(workspace) -> None:
workspace.set_batch(batch(3))
texts = _conclusion_texts(workspace)
assert any("两个模型都给出了候选方" in text for text in texts)
assert any("剂量偏差共" in text for text in texts)
assert any("没有记录资料缺口" in text for text in texts)
def test_conclusions_never_invent_a_score_comparison(workspace) -> None:
source = batch(1)
source["models"]["openai"]["comparison"]["status"] = "not_comparable"
workspace.set_batch(source)
texts = _conclusion_texts(workspace)
assert any("只有可比的一侧有分数" in text for text in texts)
def test_attribution_groups_every_herb_exactly_once(workspace) -> None:
source = batch(2)
source["models"]["openai"]["candidate"]["herbs"] = [{"name": "药材0", "dosage": "15.00", "unit": "g"}]
workspace.set_batch(source)
assert workspace.attribution.rows["all"]["count"].text() == "1 味"
assert workspace.attribution.rows["doctor_only"]["count"].text() == "0 味"
assert workspace.attribution.rows["openai_only"]["count"].text() == "0 味"
# 药材1 只有医方与千问共用,四个分组都不含它,标题必须说明这一点
assert "合计 2 味" in workspace.attribution.total_note.text()
assert "另 1 味为医方与单一模型共用" in workspace.attribution.total_note.text()
def test_risk_panel_flags_only_large_changes(workspace) -> None:
small = batch(1)
for model in small["models"].values():
model["comparison"]["rows"][0]["candidate"]["dosage"] = "29.00"
workspace.set_batch(small)
assert workspace.risks.empty.isVisible() # 差 1 克不进清单
large = batch(1)
for model in large["models"].values():
model["comparison"]["rows"][0]["candidate"]["dosage"] = "12.00"
workspace.set_batch(large)
assert workspace.risks.body.isVisible()
assert "2 项" in workspace.risks.hint.text()
def test_checklist_merges_the_models_and_keeps_the_severe_items_first():
source = batch(1)
source["models"]["qwen"]["report"]["risk_assessment"] = [{"level": "high", "label": "血压数据缺失"}]
source["missing"] = [{"code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": True}]
items = checklist_items(source)
assert items[0][0] == "血压数据缺失"
assert items[0][1] == "千问 关键"
shared = next(item for item in items if item[0] == "缺少舌脉记录")
assert shared[1] == "千问、OpenAI"
assert any("转写" in item[0] for item in items)
def test_checklist_shows_the_saved_items_and_says_so_when_empty(workspace):
workspace.set_batch(batch())
assert "1 条 · 按严重度排序" in workspace.checklist.count_note.text()
assert not workspace.checklist.empty.isVisible()
workspace.set_batch({"id": 9, "validity": "current", "models": {}})
assert workspace.checklist.empty.isVisible()
assert workspace.checklist.count_note.text() == "暂无待确认项"
def test_rerendering_leaves_no_stale_row_widgets(workspace, application):
workspace.set_batch(batch(20))
application.processEvents()
assert len(_axes(workspace)) == 20
workspace.set_batch(batch(3))
application.processEvents()
assert len(_axes(workspace)) == 3
def _axes(workspace):
return [widget for widget in workspace.doses.body.findChildren(QWidget)
if widget.accessibleName() == "剂量差异条" and widget.parentWidget() is not None]
def test_page_fits_a_1024_window_without_horizontal_scrolling(workspace, application):
workspace.set_batch(batch())
application.processEvents()
assert workspace.minimumSizeHint().width() <= 900
assert workspace.width() == 1024
assert workspace.doses.scroll.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
assert workspace.checklist.scroll.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
def test_dose_deltas_ignore_rows_without_a_shared_scale():
source = batch(1)
for model in source["models"].values():
model["comparison"]["rows"][0]["candidate"]["unit"] = "mg"
rows, _states = review_rows(source)
assert dose_deltas(rows) == []
def test_review_slot_has_no_visible_parentless_widget(workspace, application):
before = {widget for widget in application.topLevelWidgets() if widget.isVisible()}
first = QLabel("第一模型复核", workspace)
second = QLabel("双模型复核", workspace)
workspace.set_review_widget(first)
workspace.set_review_widget(second)
application.processEvents()
assert second.parentWidget() is workspace.checklist.review_slot
assert not first.isVisible()
assert first.parentWidget() is workspace.checklist.review_slot
assert {widget for widget in application.topLevelWidgets() if widget.isVisible()} == before
@@ -23,7 +23,6 @@ from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
from doctor_workstation.ui.widgets import BusinessPager
@pytest.fixture(scope="module")