"""Dual-model report and API contracts, exercised offscreen without network access.""" from __future__ import annotations import os import socket from copy import deepcopy from typing import Any from uuid import UUID os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest 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 from doctor_workstation.ui.dialogs import issued_prescription_ai as ai from doctor_workstation.ui.dialogs import prescription as editor_module from doctor_workstation.ui.pages import prescriptions as page_module def batch(batch_id: int = 40, prescription_id: int = 801, status: str = "running") -> dict[str, Any]: return { "id": batch_id, "prescription_id": prescription_id, "prescription_revision": 2, "patient_id": 301, "diagnosis_id": 501, "status": status, "validity": "current", "comparison_type": "independent_baseline", "source_summary": {"diagnosis_count": 2, "attachment_count": 4}, "missing": [{"source": "tongue", "reason": "第4张图片不可读"}], "cutoff_at": "2026-09-09 10:00:00", "created_at": "2026-09-09 10:01:00", "models": { "qwen": { "status": "succeeded", "report": {"summary": "已保存千问分析 "}, "candidate": {"status": "available_for_review", "herbs": [{"name": "黄芪", "dosage": 12, "unit": "g", "dose_basis": "每剂", "formula_type": "主方"}]}, "comparison": {"status": "comparable", "score": 0, "herb_score": 0, "algorithm_version": "v1", "rows": [{"name": "黄芪", "doctor_dose": None, "candidate_dose": 12, "contribution": 0}], "usage_differences": ["疗程需复核"]}, "coverage": {"status": "incomplete", "manifest": [{"name": "舌象4", "status": "unreadable"}]}, "review": {"status": "needs_information", "comment": "需补充清晰舌象"}, }, "openai": {"status": "running", "report": None, "candidate": None, "comparison": None, "coverage": {"status": "pending"}}, }, } class Repository(DemoDoctorRepository): def __init__(self) -> None: super().__init__() self.calls: list[tuple[str, Any]] = [] self.batches = [batch()] self.enabled = True def list_prescription_ai_reports(self, **params: Any) -> dict[str, Any]: self.calls.append(("reports", params)) return {"enabled": self.enabled, "lists": deepcopy(self.batches), "count": len(self.batches), "page_no": 1, "page_size": 20} def get_prescription_ai_report(self, batch_id: int) -> dict[str, Any]: self.calls.append(("detail", batch_id)) return deepcopy(next(row for row in self.batches if row["id"] == batch_id)) def retry_prescription_ai(self, batch_id: int, model_key: str) -> dict[str, Any]: self.calls.append(("retry", (batch_id, model_key))) return {"batch_id": batch_id, "status": "queued"} def regenerate_prescription_ai(self, prescription_id: int, reason: str) -> dict[str, Any]: self.calls.append(("regenerate", (prescription_id, reason))) return {"batch_id": 41, "status": "queued"} def review_prescription_ai(self, batch_id: int, model_key: str, status: str, comment: str) -> dict[str, Any]: self.calls.append(("review", (batch_id, model_key, status, comment))) return {"saved": True} def list_prescription_ai_statuses(self, ids: list[int]) -> dict[str, Any]: self.calls.append(("statuses", list(ids))) return {"enabled": self.enabled, "items": [{"prescription_id": value, "batch_id": 40, "status": "running", "validity": "current", "comparison_type": "independent_baseline", "models": {"qwen": {"status": "succeeded", "score": 0, "comparison_status": "comparable"}, "openai": {"status": "running", "score": None}}} for value in ids] if self.enabled else []} @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("These tests 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 immediate(monkeypatch: pytest.MonkeyPatch) -> None: def run(function: Any, *, on_success: Any = None, on_error: Any = None, on_finished: Any = None) -> object: try: result = function() except Exception as error: if on_error: on_error(error) else: raise else: if on_success: on_success(result) finally: if on_finished: on_finished() return object() for module in (ai, page_module, editor_module): monkeypatch.setattr(module, "run_async", run) @pytest.mark.parametrize(("value", "expected"), [(None, "—"), (0, "0%"), (100, "100%"), (42.3, "42%"), (float("nan"), "—"), (float("inf"), "—"), (False, "—"), (-1, "—")]) def test_percentage_never_turns_missing_into_zero(value: Any, expected: str) -> None: assert ai.percentage(value) == expected def test_shared_report_reads_only_and_renders_each_model_independently(application: QApplication, immediate: None) -> None: repository = Repository() dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() application.processEvents() assert [call[0] for call in repository.calls] == ["reports", "detail"] assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText() assert dialog.model_views["qwen"]["score"].text() == "0.0%" assert dialog.model_views["openai"]["score"].text() == "—" assert "资料不全" in dialog.model_views["qwen"]["title"].text() assert "黄芪" in dialog.model_views["qwen"]["candidate"].toPlainText() assert "医生剂量" in dialog.model_views["qwen"]["comparison"].toPlainText() assert "第4张图片不可读" in dialog.model_views["qwen"]["sources"].toPlainText() assert "<script>" in dialog.model_views["qwen"]["report"].toHtml() assert dialog._timer.isActive() assert not dialog.model_views["qwen"]["retry"].isEnabled() assert not dialog.model_views["openai"]["retry"].isEnabled() dialog.hide() assert not dialog._timer.isActive() calls = list(repository.calls) dialog._poll() 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" repository.batches[0]["models"]["openai"].update(status="failed", error_code="timeout", error_message="模型超时") dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert dialog.model_views["openai"]["retry"].isEnabled() assert not dialog._timer.isActive() dialog._retry("openai") assert ("retry", (40, "openai")) in repository.calls dialog.model_views["qwen"]["review_state"].setCurrentIndex(3) dialog.model_views["qwen"]["comment"].setPlainText("已核对逐味差异") dialog._review("qwen") assert ("review", (40, "qwen", "reviewed", "已核对逐味差异")) in repository.calls assert not any(call[0] == "regenerate" for call in repository.calls) dialog.close() def test_history_and_hidden_dialog_ignore_late_detail(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: repository = Repository() repository.batches.append(batch(41)) repository.batches[1]["models"]["qwen"]["report"] = {"summary": "第二批报告"} dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() pending = [] monkeypatch.setattr(ai, "run_async", lambda function, **callbacks: pending.append((function, callbacks))) dialog._poll() assert len(pending) == 1 old_generation = dialog._generation dialog.history.setCurrentIndex(1) assert dialog._generation > old_generation old_function, old_callbacks = pending[0] old_callbacks["on_success"](old_function()) assert dialog._batch == {} new_function, new_callbacks = pending[1] new_callbacks["on_success"](new_function()) assert dialog._batch["id"] == 41 assert "第二批报告" in dialog.model_views["qwen"]["report"].toPlainText() dialog.hide() new_callbacks["on_success"](batch(41)) assert "第二批报告" in dialog.model_views["qwen"]["report"].toPlainText() def test_wrong_patient_batch_is_not_displayed(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: repository = Repository() wrong_patient = batch() wrong_patient["patient_id"] = 999 monkeypatch.setattr(repository, "get_prescription_ai_report", lambda _batch_id: wrong_patient) dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], diagnosis_id=501) dialog.show() assert repository.calls[0][1]["diagnosis_id"] == 501 assert dialog._batch == {} assert "归属" in dialog.message.text() assert not dialog.model_views["qwen"]["report"].toPlainText() dialog.close() def test_patient_history_accepts_other_authorized_visits(application: QApplication, immediate: None) -> None: repository = Repository() repository.batches[0]["diagnosis_id"] = 499 dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], diagnosis_id=501) dialog.show() assert dialog._batch["diagnosis_id"] == 499 assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText() dialog.close() def test_disabled_generation_keeps_history_readable(application: QApplication, immediate: None) -> None: repository = Repository() repository.enabled = False dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert "千问分析" in dialog.model_views["qwen"]["report"].toPlainText() assert not dialog.regenerate_button.isEnabled() assert "未启用" in dialog.message.text() dialog.close() def test_view_permission_cannot_mutate(application: QApplication, immediate: None) -> None: repository = Repository() permissions = ["tcm.prescriptionAi/reports", "tcm.prescriptionAi/detail"] dialog = ai.IssuedPrescriptionAiDialog(repository, permissions, prescription_id=801) dialog.show() assert dialog.regenerate_button.isHidden() dialog._review("qwen") dialog._retry("openai") assert not any(call[0] in {"review", "retry", "regenerate"} for call in repository.calls) dialog.close() def test_list_batches_visible_ids_and_stops_on_hide(application: QApplication, immediate: None) -> None: repository = Repository() page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() statuses = [call for call in repository.calls if call[0] == "statuses"] assert statuses and set(statuses[-1][1]) <= {801, 802} assert len(statuses[-1][1]) <= 100 assert not page.table.isColumnHidden(11) assert not page.table.isColumnHidden(12) assert "千问 0%" in page.table.item(0, 12).text() assert "OpenAI —" in page.table.item(0, 12).text() assert any(button.accessibleName() == "AI 报告" for button in page.table.findChildren(QPushButton)) assert page._ai_timer.isActive() page.hide() assert not page._ai_timer.isActive() calls = list(repository.calls) page._load_ai_statuses() assert repository.calls == calls def test_blank_list_row_has_no_ai_display_or_entry_points(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: repository = Repository() opened = [] monkeypatch.setattr(page_module, "present_issued_prescription_ai", lambda *args, **kwargs: opened.append(kwargs)) page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() # Demo row 801 contains herbs; row 802 is an empty manual prescription. assert all(call[1] == [801] for call in repository.calls if call[0] == "statuses") assert not page.table.isColumnHidden(11) and not page.table.isColumnHidden(12) page._ai_statuses[802] = batch(prescription_id=802) page._render_ai_statuses() for column in (11, 12): item = page.table.item(1, column) assert item.text() == item.toolTip() == item.data(Qt.ItemDataRole.AccessibleTextRole) == "" assert not any(button.accessibleName() == "AI 报告" for button in page.table.cellWidget(1, 2).findChildren(QPushButton)) page.table.selectRow(1) assert page.ai_report_button.isHidden() and not page.ai_report_button.isEnabled() page._open_ai_report() page.table.itemClicked.emit(page.table.item(1, 11)) page.table.itemClicked.emit(page.table.item(1, 12)) page._open_ai_context_menu(page.table.visualItemRect(page.table.item(1, 1)).center()) assert not hasattr(page, "_ai_context_menu") assert opened == [] page.table.selectRow(0) assert page.ai_report_button.isVisible() and page.ai_report_button.isEnabled() page.ai_report_button.click() assert opened == [{"prescription_id": 801}] page.close() @pytest.mark.parametrize("blank_fields", [ {"is_system_auto": "1", "herbs": []}, {"is_system_auto": True, "herbs": [{"name": "黄芪"}]}, {"is_system_auto": 0, "herbs": [{}, {"name": " "}]}, {"is_system_auto": 0, "herbs": None}, ]) def test_all_blank_list_hides_ai_until_prescription_is_saved(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch, blank_fields: dict[str, Any]) -> None: repository = Repository() row = {"id": 803, "sn": "RX-BLANK", "patient_name": "测试患者", **blank_fields} monkeypatch.setattr(repository, "list_prescriptions", lambda **_filters: {"lists": [row], "count": 1}) page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12) assert page.ai_report_button.isHidden() and page.ai_status_notice.isHidden() assert not page._ai_timer.isActive() assert not any(call[0] == "statuses" for call in repository.calls) assert not any(button.accessibleName() == "AI 报告" for button in page.table.cellWidget(0, 2).findChildren(QPushButton)) row.update(is_system_auto=0, herbs=[{"name": "黄芪", "dosage": 12}]) page.refresh() application.processEvents() assert ("statuses", [803]) in repository.calls assert not page.table.isColumnHidden(11) and not page.table.isColumnHidden(12) assert page.ai_report_button.isVisible() and page.ai_status_notice.isVisible() assert "千问 0%" in page.table.item(0, 12).text() assert any(button.accessibleName() == "AI 报告" for button in page.table.cellWidget(0, 2).findChildren(QPushButton)) page.close() def test_list_disabled_explains_availability_and_keeps_history(application: QApplication, immediate: None) -> None: repository = Repository() repository.enabled = False page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12) assert not page._ai_timer.isActive() assert page.ai_report_button.isVisible() assert page.ai_status_notice.isVisible() assert "未启用" in page.ai_status_notice.text() page.close() def test_list_service_error_is_visible_and_clears_after_recovery(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: repository = Repository() def unavailable(_ids: list[int]) -> dict[str, Any]: raise RuntimeError("AI service unavailable") monkeypatch.setattr(repository, "list_prescription_ai_statuses", unavailable) page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() assert page.ai_status_notice.isVisible() assert "暂不可用" in page.ai_status_notice.text() assert page.table.isColumnHidden(12) assert not page._ai_timer.isActive() monkeypatch.setattr(repository, "list_prescription_ai_statuses", Repository.list_prescription_ai_statuses.__get__(repository)) page.refresh() application.processEvents() assert "已启用" in page.ai_status_notice.text() assert not page.ai_status_notice.toolTip() assert not page.table.isColumnHidden(12) page.close() def test_list_missing_permission_explains_without_loading_ai(application: QApplication, immediate: None) -> None: repository = Repository() page = page_module.PrescriptionsPage(repository, ["cf.prescription/read"]) page.resize(1366, 800) page.show() application.processEvents() assert page.ai_status_notice.isVisible() assert "权限" in page.ai_status_notice.text() assert page.table.isColumnHidden(11) and page.table.isColumnHidden(12) assert not page.ai_report_button.isVisible() assert not any(call[0] in {"statuses", "reports", "detail"} for call in repository.calls) page.close() def test_list_ignores_response_after_filter_generation_changes(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: repository = Repository() page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() callbacks = [] monkeypatch.setattr(page_module, "run_async", lambda function, **kwargs: callbacks.append((function, kwargs))) page._load_ai_statuses() assert callbacks page._generation += 1 page._ai_statuses = {} function, call = callbacks[0] call["on_success"](function()) assert page._ai_statuses == {} page.close() def test_statistics_nulls_and_absent_review_samples(application: QApplication, immediate: None) -> None: class StatisticsRepository: def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]: return {"total_count": 3, "patient_count": 2, "doctors": [{"doctor_name": "张医生", "total_count": 3, "patient_count": 2, "models": {"qwen": {"eligible_count": 0, "coverage_rate": 0, "mean": None, "median": None, "excluded_reasons": {"non_independent": 3}}, "openai": {"eligible_count": 1, "coverage_rate": 33.3, "mean": 0, "median": 0}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]} dialog = ai.PrescriptionAiStatisticsDialog(StatisticsRepository(), ["*"]) dialog.show() text = dialog.report.toPlainText() assert "均值:—" in text and "均值:0.0%" in text assert "未建立复核样本" in text assert "开方事件:3" in dialog.summary.text() dialog.close() def test_editor_keeps_request_key_for_identical_content_and_preserves_ai_assistance(application: QApplication, immediate: None) -> None: editor = editor_module.PrescriptionEditorDialog(DemoDoctorRepository(), {"ai_assisted": True}) first = editor.payload() assert first["ai_assisted"] is True assert str(UUID(first["request_key"])) == first["request_key"] assert first["request_key"] == editor.payload()["request_key"] editor.patient_name.setText("新患者") assert first["request_key"] != editor.payload()["request_key"] editor.close() unknown = editor_module.PrescriptionEditorDialog(DemoDoctorRepository()) assert "ai_assisted" not in unknown.payload() unknown.close() unconfirmed = editor_module.PrescriptionEditorDialog(DemoDoctorRepository(), {"ai_assisted": False}) assert "ai_assisted" not in unconfirmed.payload() unconfirmed.close() def test_repository_exact_read_and_mutation_contracts() -> None: class Client: def __init__(self) -> None: self.calls = [] def get(self, endpoint: str, params: Any) -> dict[str, Any]: self.calls.append(("GET", endpoint, params)) return {"enabled": False, "items": []} def post(self, endpoint: str, params: Any) -> dict[str, Any]: self.calls.append(("POST", endpoint, params)) return {"saved": True} client = Client() repository = RemoteDoctorRepository(client) assert repository.list_prescription_ai_statuses([801, 802, 801]) == {"enabled": False, "items": []} repository.list_prescription_ai_reports(diagnosis_id=501, page_no=2, page_size=20) repository.get_prescription_ai_report(40) assert all(call[0] == "GET" for call in client.calls) repository.regenerate_prescription_ai(801, "补充病历") repository.retry_prescription_ai(40, "openai") repository.review_prescription_ai(40, "qwen", "reviewed", "核对完成") repository.prescription_ai_statistics("2026-09-01", "2026-09-09", 7) assert client.calls == [ ("GET", "tcm.prescriptionAi/statuses", {"ids": "801,802"}), ("GET", "tcm.prescriptionAi/reports", {"diagnosis_id": 501, "page_no": 2, "page_size": 20}), ("GET", "tcm.prescriptionAi/detail", {"batch_id": 40}), ("POST", "tcm.prescriptionAi/regenerate", {"prescription_id": 801, "reason": "补充病历"}), ("POST", "tcm.prescriptionAi/retry", {"batch_id": 40, "model_key": "openai"}), ("POST", "tcm.prescriptionAi/review", {"batch_id": 40, "model_key": "qwen", "status": "reviewed", "comment": "核对完成"}), ("GET", "tcm.prescriptionAi/statistics", {"date_from": "2026-09-01", "date_to": "2026-09-09", "doctor_id": 7}), ] with pytest.raises(ValueError): repository.list_prescription_ai_statuses(list(range(1, 102))) with pytest.raises(ValueError): repository.list_prescription_ai_reports(prescription_id=801, diagnosis_id=501) with pytest.raises(ValueError): repository.retry_prescription_ai(40, "other") def test_stale_list_score_is_not_attached_to_current_prescription() -> None: value = {"validity": "superseded", "models": {"qwen": {"score": 100}, "openai": {"score": 90}}} assert ai.agreement_text(value) == "千问 —\nOpenAI —" assert ai.state_text(value) == "处方已变更" assert not ai.batch_running({**value, "status": "running"}) @pytest.mark.parametrize("status", ["preparing", "waiting_sources", "retry_wait", "queued", "running"]) def test_server_pending_states_continue_polling(status: str) -> None: assert ai.batch_running({"status": status, "validity": "current"}) assert not ai.batch_running({"status": status, "validity": "prescription_changed"}) def test_regeneration_selects_new_batch_and_preserves_old_history(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: repository = Repository() repository.batches[0]["status"] = "success" repository.batches[0]["models"]["openai"]["status"] = "success" def regenerate(prescription_id: int, reason: str) -> dict[str, Any]: repository.calls.append(("regenerate", (prescription_id, reason))) repository.batches.insert(0, batch(41)) return {"batch_id": 41, "status": "queued"} monkeypatch.setattr(repository, "regenerate_prescription_ai", regenerate) monkeypatch.setattr(ai.QInputDialog, "getMultiLineText", lambda *_args: ("已补充资料", True)) dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() dialog._regenerate() assert dialog.history.count() == 2 assert dialog.history.currentData() == 41 assert dialog._batch["id"] == 41 assert dialog._timer.isActive() assert ("regenerate", (801, "已补充资料")) in repository.calls dialog.close() def test_list_append_retains_finished_cached_scores(application: QApplication, immediate: None) -> None: repository = Repository() page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() for cached in page._ai_statuses.values(): cached["status"] = "success" cached["models"]["openai"].update(status="success", score=62) calls = len([call for call in repository.calls if call[0] == "statuses"]) page._apply_result(repository.list_prescriptions(), page._generation, 1) assert "OpenAI 62%" in page.table.item(0, 12).text() assert len([call for call in repository.calls if call[0] == "statuses"]) == calls assert not page._ai_timer.isActive() page.close() def test_context_menu_targets_clicked_prescription(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: repository = Repository() opened = [] monkeypatch.setattr(page_module, "present_issued_prescription_ai", lambda *args, **kwargs: opened.append(kwargs)) page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() page.table.selectRow(1) item = page.table.item(0, 1) expected_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole).id page._open_ai_context_menu(page.table.visualItemRect(item).center()) page._ai_context_menu.actions()[0].trigger() assert opened == [{"prescription_id": expected_id}] page._ai_context_menu.close() page.close() def test_real_server_comparison_rows_keep_both_doses_and_basis() -> None: rows = ai._comparison_rows([{ "key": "1|raw|main", "medicine_id": 1, "name": "黄芪", "processing": "生品", "formula_type": "主方", "administration_route": "口服", "group": "", "doctor": {"dosage": 30, "unit": "g", "dose_basis": "per_dose"}, "candidate": {"dosage": 15, "unit": "g", "dose_basis": "per_dose"}, "doctor_dosage": 30, "candidate_dosage": 15, "unit": "g", "dose_basis": "per_dose", "match_type": "matched", "contribution": 0.5, }]) assert rows[0]["doctor_dosage"] == "30 克" assert rows[0]["candidate_dosage"] == "15 克" assert rows[0]["dose_basis"] == "per_dose" assert rows[0]["contribution"] == 0.5 assert "生品 / 主方 / 口服" in rows[0]["name"] def test_unavailable_candidate_has_reason_and_pending_review_is_disabled(application: QApplication, immediate: None) -> None: repository = Repository() repository.batches[0]["models"]["qwen"]["candidate"] = {"status": "withheld_for_risk", "reason": "过敏用药信息待核实", "herbs": []} dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() text = dialog.model_views["qwen"]["candidate"].toPlainText() assert "因风险暂缓候选用药" in text and "过敏用药信息待核实" in text assert not dialog.model_views["openai"]["save"].isEnabled() assert "分析尚未完成" in dialog.model_views["openai"]["candidate"].toPlainText() dialog.close() def test_preparation_failure_reason_is_visible_without_models(application: QApplication, immediate: None) -> None: repository = Repository() repository.batches[0].update(status="blocked", models={}, error_message="患者关联冲突,需要核对诊单", coverage_status="partial") dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert "患者关联冲突" in dialog.batch_summary.text() assert "资料不全" in dialog.batch_summary.text() assert "患者关联冲突" in dialog.model_views["qwen"]["candidate"].toPlainText() dialog.close() def test_actual_history_pagination_contract(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: repository = Repository() repository.batches = [batch(value) for value in range(61, 40, -1)] def reports(**params: Any) -> dict[str, Any]: repository.calls.append(("reports", params)) page = params["page_no"] return {"lists": repository.batches[(page - 1) * 20:page * 20], "count": 21, "page_no": page, "page_size": 20} monkeypatch.setattr(repository, "list_prescription_ai_reports", reports) dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert dialog.history.count() == 20 and dialog.next.isEnabled() dialog._history_page(1) assert dialog.history.count() == 1 assert dialog._batch["id"] == 41 assert dialog.previous.isEnabled() and not dialog.next.isEnabled() assert repository.calls[-2] == ("reports", {"prescription_id": 801, "diagnosis_id": 0, "page_no": 2, "page_size": 20}) dialog.close() @pytest.mark.parametrize(("status", "expected"), [("blank", "尚未开方"), ("not_generated", "尚无分析记录")]) def test_server_empty_batch_statuses_are_not_fake_scores(status: str, expected: str) -> None: data = {"status": status, "models": {}, "coverage_status": "pending"} assert ai.state_text(data) == expected assert ai.agreement_text(data) == "千问 —\nOpenAI —" def test_statistics_displays_actual_version_strata_without_filling_null_mean(application: QApplication, immediate: None) -> None: class StatisticsRepository: def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]: strata = [{"versions": {"model_version": version, "algorithm_version": "v1", "prompt_version": "p1", "dictionary_version": "d1"}, "count": 1, "mean": mean, "median": mean, "distribution": {"[0,20)": 1 if mean == 0 else 0}, "sample_status": "insufficient_sample"} for version, mean in (("model-1", 0), ("model-2", 80))] return {"total_count": 2, "patient_count": 1, "doctors": [{"doctor_id": 7, "doctor_name": "张医生", "total_count": 2, "patient_count": 1, "paired_count": 0, "models": {"qwen": {"eligible_count": 2, "coverage_rate": 100, "mean": None, "median": None, "excluded_reasons": {}, "strata": strata}}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]} dialog = ai.PrescriptionAiStatisticsDialog(StatisticsRepository(), ["*"]) dialog.show() text = dialog.report.toPlainText() assert "双模型共同有效样本:0" in text assert "整体均值与中位数不合并" in text assert "model-1" in text and "model-2" in text assert "均值:—" in text and "均值:0.0%" in text and "均值:80.0%" in text assert "样本不足" in text and "未建立复核样本" in text dialog.close() @pytest.mark.parametrize(("code", "expected"), [ ("SOURCE_HISTORY_VERSIONS_UNAVAILABLE", "来源历史版本无法核验"), ("ARCHIVE_SYNC_WATERMARK_UNAVAILABLE: chat_records", "归档同步完整性尚未核验:聊天记录"), ("TRANSCRIPT_NOT_VERIFIED_COMPLETE:call_records:1820", "问诊转写完整性尚未核验:问诊通话(编号:1820)"), ("TRANSCRIPT_PARTIAL: video_calls:1821", "问诊转写仅部分完成:视频问诊(编号:1821)"), ("CRITICAL_CLINICAL_FACT_MISSING:clinical.allergy_history", "关键临床资料缺失:临床资料 · 过敏史"), ("MODEL_FILE_OUTPUT_INVALID", "模型未能正确解析这组附件"), ("missing_or_unknown_dose_basis", "每剂或每日剂量基准不明确"), ("review_not_independent", "复核不具独立性"), ]) def test_metadata_and_historical_composite_reasons_are_chinese(code: str, expected: str) -> None: assert ai.label(code) == expected assert expected in ai._html({"missing_information": [code]}) def test_source_labels_keep_exact_ids_and_distinguish_source_kinds() -> None: fingerprint = "0123456789abcdef" * 4 payload = {"evidence_references": ["diagnoses:501", "video_calls:1820", "call_records:1820", "file:" + fingerprint], "source_id": "clinical.pregnancy_history", "redaction_manifest": ["diagnoses:501:doctor_advice"]} original = deepcopy(payload) rendered = ai._html(payload) assert "诊单(编号:501)" in rendered assert "视频问诊(编号:1820)" in rendered and "问诊通话(编号:1820)" in rendered assert f"附件(编号:{fingerprint})" in rendered assert "临床资料 · 妊娠与哺乳情况" in rendered assert "诊单(编号:501) · 医嘱" in rendered assert all(raw not in rendered for raw in ("diagnoses:", "video_calls:", "file:", "clinical.", "doctor_advice")) assert payload == original def test_unknown_metadata_has_chinese_fallback_without_translating_clinical_prose() -> None: clinical_text = "CT、MRI、HbA1c 6.2%,HbA1c_result 待复查,Follow-up in 2 weeks;unknown" payload = {"summary": clinical_text, "treatment_advice": "ALT_AST 需结合 COPD 病史评估", "status": "newphase", "error_code": "FUTURE_FAILURE_CODE", "future_field": 3, "future_status": "newstate", "future_enum": "new_enum_value", "missing_information": ["FUTURE_GAP_CODE:future_records:1820"], "source_id": "future_records:1821"} original = deepcopy(payload) rendered = ai._html(payload) assert clinical_text in rendered and "ALT_AST 需结合 COPD 病史评估" in rendered assert "未识别的系统状态" in rendered and "其他来源(编号:1820)" in rendered assert "其他来源(编号:1821)" in rendered and "3" in rendered for raw in ("newphase", "newstate", "new_enum_value", "FUTURE_FAILURE_CODE", "FUTURE_GAP_CODE", "future_field", "future_records"): assert raw not in rendered assert payload == original def test_nested_metadata_translates_fields_values_and_retains_all_statistics() -> None: payload = { "coverage": {"status": "partial", "source_complete": True, "files": {"file:42": {"status": "unreadable", "version_verified": False}}}, "source_summary": {"diagnoses_count": 2, "video_calls_count": 3, "source_record_count": 5, "history_versioning": "unavailable"}, "normalization": {"doctor": {"defaults": [{"field": "unit", "value": "g"}, {"field": "dose_basis", "value": "per_dose"}]}, "issues": [{"side": "both", "code": "unit_mismatch", "row": 0}], "unit_policy": "spelling_aliases_only_no_quantity_conversion"}, "exclusion_reasons": {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 12, "future_reason_a": 7, "future_reason_b": 8}, "review": {"sampling_groups": [{"sampling_method": "risk_directed", "outcomes": {"qualified": 2, "needs_revision": 1}}]}, } original = deepcopy(payload) rendered = ai._html(payload) for expected in ("资料不全", "历史版本无法核验", "诊单数", "视频问诊数", "克", "每剂", "剂量单位不同", "双方", "按风险抽样", "合格", "需修订"): assert expected in rendered for count in (12, 7, 8, 0): assert f"

{count}

" in rendered assert rendered.count("未识别的系统标识") == 2 for raw in ("partial", "version_verified", "diagnoses_count", "per_dose", "SOURCE_HISTORY", "future_reason", "risk_directed", "qualified"): assert raw not in rendered assert payload == original def test_metadata_and_prose_remain_html_escaped(application: QApplication) -> None: attack = '' html = ai._html({"summary": attack, "missing_information": ["TRANSCRIPT_FAILED:call_records:1820 " + attack], "source_id": "call_records:1820 " + attack, "status": attack}) assert " 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": ["复核提示 "], } html = ai._candidate_html(candidate) assert html.index("测试药材") < html.index("保留完整方义") assert "' value["models"]["qwen"]["progress"]["notice"] = attack value["models"]["qwen"]["coverage"] = {"source_ids": ["call_records:1820 " + attack]} repository.batches = [value] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() views = dialog.model_views["qwen"] assert views["progress_detail"].textFormat() == Qt.TextFormat.PlainText assert attack in views["progress_detail"].text() assert "<script>" in views["sources"].toHtml() assert " None: repository = Repository() monkeypatch.setattr(repository, "list_prescription_ai_statuses", lambda ids: {"enabled": True, "items": [dict(progress_batch(), prescription_id=value) for value in ids]}) page = page_module.PrescriptionsPage(repository, ["*"]) page.resize(1366, 800) page.show() application.processEvents() item = page.table.item(0, 11) assert item.text() == "千问 附件 2/5\nOpenAI 生成报告" assert "本阶段 2/5 组" in item.toolTip() assert "不代表附件全部读懂" in item.toolTip() assert page.table.rowHeight(0) >= 2 * page.table.fontMetrics().height() assert page._ai_timer.isActive() page.close() @pytest.mark.parametrize("status", ["success", "failed", "cancelled", "partial"]) def test_terminal_batch_overrides_obsolete_running_model_checkpoint(application: QApplication, immediate: None, status: str) -> None: repository = Repository() value = progress_batch() value["status"] = status repository.batches = [value] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert not ai.batch_running(value) assert not dialog._timer.isActive() and not dialog._progress_timer.isActive() assert "最后记录" in dialog.model_views["openai"]["progress_title"].text() assert dialog.model_views["openai"]["progress_bar"].maximum() != 0 dialog.close() @pytest.mark.parametrize(("status", "notice"), [ ("failed", "处理未完成,请查看失败原因。 上次进度:校验报告。 耗时按本次尝试计算。"), ("cancelled", "任务已取消。 耗时按本次尝试计算。"), ]) def test_terminal_progress_preserves_matching_server_failure_notice(status: str, notice: str) -> None: value = {"status": status, "progress": {"stage": status, "phase": "failed", "notice": notice, "elapsed_seconds": 180, "attempt": 2}} view = ai.progress_view(value, seconds=45) assert notice in view.detail and "第 2 次尝试" in view.detail assert "已用时 3 分 00 秒" in view.detail assert not view.busy and view.total is None assert notice in ai.status_tooltip({"models": {"qwen": value}}) @pytest.mark.parametrize("status", ["failed", "cancelled", "success"]) def test_terminal_progress_discards_obsolete_waiting_notice(status: str) -> None: view = ai.progress_view({"status": status, "progress": {"stage": "final", "phase": "waiting", "notice": "等待模型返回。正在生成报告。", "elapsed_seconds": 90}}, seconds=45) assert "等待模型返回" not in view.detail and "正在生成报告" not in view.detail assert "已用时 1 分 30 秒" in view.detail assert not view.busy def test_retry_wait_freezes_attempt_duration_while_countdown_advances_and_poll_remains_consistent(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: ticks = [100.0] monkeypatch.setattr(ai, "monotonic", lambda: ticks[0]) repository = Repository() value = batch(status="running") value["models"]["openai"].update(status="retry_wait", progress={ "stage": "retry_wait", "phase": "waiting", "elapsed_seconds": 180, "stage_elapsed_seconds": 0, "wait_remaining_seconds": 30, "updated_at": 1000, "server_time": 1010, "attempt": 2, "notice": "本次未完成,已安排自动重试。 上次进度:校验报告。 耗时按本次尝试计算。", }) repository.batches = [value] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() detail = dialog.model_views["openai"]["progress_detail"] assert "第 2 次尝试" in detail.text() and "距下次重试 30 秒" in detail.text() ticks[0] = 104.0 dialog._render_progress() assert "已用时 3 分 00 秒" in detail.text() and "距下次重试 26 秒" in detail.text() assert "阶段更新于 14 秒前" in detail.text() assert "本阶段" not in detail.text() progress = repository.batches[0]["models"]["openai"]["progress"] progress.update(server_time=1015, wait_remaining_seconds=25) ticks[0] = 105.0 dialog._poll() assert "已用时 3 分 00 秒" in detail.text() and "距下次重试 25 秒" in detail.text() assert "上次进度:校验报告。" in detail.text() repository.batches[0]["models"]["openai"].update(status="running", progress={ "stage": "final", "phase": "waiting", "elapsed_seconds": 0, "stage_elapsed_seconds": 0, "attempt": 3, "updated_at": 1040, "server_time": 1040, }) ticks[0] = 130.0 dialog._poll() ticks[0] = 133.0 dialog._render_progress() assert "第 3 次尝试" in detail.text() and "已用时 3 秒" in detail.text() assert "距下次重试" not in detail.text() dialog.close() @pytest.mark.parametrize("attempt", [None, 0, -1, True, "2", 1_000_001]) 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()