更新
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()
|
||||
|
||||
Reference in New Issue
Block a user