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()