更新
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user