"""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 Qt from PySide6.QtWidgets import QApplication, QPushButton 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_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 " dict[str, Any]: """Synthetic fixture; no patient or network data is used in tests or visual QA.""" value = batch(39, status="success") value.update( error_message="SOURCE_HISTORY_VERSIONS_UNAVAILABLE", coverage_status="partial", baseline_eligible=False, baseline_exclusion_reasons=["SOURCE_HISTORY_VERSIONS_UNAVAILABLE", "ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED"], source_summary={"diagnoses_count": 2, "video_calls_count": 1, "source_record_count": 3, "attachment_count": 1, "missing_count": 2, "snapshot_complete": False, "history_versioning": "unavailable", "archive_sync_verified": False}, missing=[{"source_id": "chat_records", "code": "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "critical": False}, {"source_id": "call_records:1820", "code": "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "critical": False}], ) for model in value["models"].values(): model.update(status="success", error_message="SOURCE_HISTORY_VERSIONS_UNAVAILABLE", report={"summary": "测试病例:依据已读取病历形成辅助分析,CT 与 HbA1c 结果需结合后续复查。", "missing_information": ["ARCHIVE_SYNC_WATERMARK_UNAVAILABLE: chat_records", "TRANSCRIPT_NOT_VERIFIED_COMPLETE:call_records:1820"], "evidence_references": ["diagnoses:501", "video_calls:1820"]}, coverage={"status": "partial", "complete": False, "source_ids": ["diagnoses:501", "video_calls:1820"]}, candidate={"status": "available_for_review", "reason": "基于已读资料生成,资料尚不完整,须由医生核对后决定是否采用。", "herbs": [{"name": "测试药项", "dosage": 12, "unit": "g", "dose_basis": "per_dose", "formula_type": "main"}]}, comparison={"status": "not_comparable", "reason": "SOURCE_HISTORY_VERSIONS_UNAVAILABLE", "score": None, "prompt_version": "manual-prescription-independent-v1"}) return value def test_historical_reports_localize_every_panel_without_rewriting_saved_data(application: QApplication, immediate: None) -> None: repository = Repository() repository.batches.append(chinese_history_batch()) original = deepcopy(repository.batches) dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() dialog.history.setCurrentIndex(1) views = dialog.model_views["qwen"] content = "\n".join([dialog.batch_summary.text(), views["title"].text()] + [views[field].toPlainText() for field in ("report", "candidate", "comparison", "sources")]) for raw in ("SOURCE_HISTORY_VERSIONS_UNAVAILABLE", "ARCHIVE_SYNC_WATERMARK_UNAVAILABLE", "TRANSCRIPT_NOT_VERIFIED_COMPLETE", "call_records:", "diagnoses:", "main", "per_dose", "manual-prescription-independent-v1"): assert raw not in content for translated in ("来源历史版本无法核验", "归档同步完整性尚未核验", "问诊通话(编号:1820)", "CT 与 HbA1c", "资料不全", "手动处方独立分析 · 第 1 版"): assert translated in content assert dialog._batch == original[1] and repository.batches == original assert dialog.batch_summary.textFormat() == Qt.TextFormat.PlainText assert views["title"].textFormat() == Qt.TextFormat.PlainText assert all(call[0] in {"reports", "detail"} for call in repository.calls) assert not dialog._timer.isActive() dialog.close() @pytest.mark.parametrize(("version", "expected"), [ ("manual-prescription-independent-v1", "手动处方独立分析 · 第 1 版"), ("manual-prescription-available-evidence-v2", "手动处方已读资料分析 · 第 2 版"), ("prescription-soft-dice-v1.0.1", "处方药味剂量一致度算法 · 第 1.0.1 版"), ]) def test_internal_version_labels_keep_revision_numbers(version: str, expected: str) -> None: html = ai._html({"prompt_version": version}) assert expected in html and version not in html class ChineseStatisticsRepository: def prescription_ai_statistics(self, *_args: Any) -> dict[str, Any]: exclusions = {"SOURCE_HISTORY_VERSIONS_UNAVAILABLE": 3, "transcript_not_final": 1, "ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED": 2, "future_reason_a": 4, "future_reason_b": 5} return {"total_count": 15, "patient_count": 12, "doctors": [{"doctor_name": "测试医生", "total_count": 15, "patient_count": 12, "paired_count": 0, "models": {key: {"eligible_count": 0, "coverage_rate": 0, "mean": None, "median": None, "excluded_reasons": exclusions} for key in ("qwen", "openai")}, "review": {"evaluated_count": 0, "qualified_count": 0, "qualified_rate": None}}]} def test_statistics_localizes_exclusion_keys_without_combining_unknown_reasons(application: QApplication, immediate: None) -> None: dialog = ai.PrescriptionAiStatisticsDialog(ChineseStatisticsRepository(), ["*"]) dialog.show() content = dialog.report.toPlainText() assert "来源历史版本无法核验" in content and "附件可能包含本次处方,独立性未核验" in content assert "问诊转写尚未完整归档" in content assert content.count("未识别的系统标识") == 4 for raw in ("SOURCE_HISTORY", "transcript_not_final", "ATTACHMENT_TARGET", "future_reason"): assert raw not in content assert "4" in content and "5" in content assert "均值:—" in content and "覆盖率:0.0%" in content and "未建立复核样本" in content assert "开方事件:15" in dialog.summary.text() dialog.close() def progress_batch() -> dict[str, Any]: """Synthetic progress fixture shared by tests and offscreen visual review.""" value = batch() value["progress"] = {"stage": "unknown", "phase": "running", "elapsed_seconds": 190, "stage_elapsed_seconds": None, "updated_at": 1000, "server_time": 1008, "notice": "模型正在分别处理,具体进度见各模型。"} value["models"]["qwen"].update(status="running", report=None, candidate=None, comparison=None) value["models"]["qwen"]["progress"] = { "stage": "files", "stage_label": "处理附件", "phase": "waiting", "completed_units": 2, "total_units": 5, "elapsed_seconds": 176, "stage_elapsed_seconds": 46, "updated_at": 1000, "server_time": 1008, "notice": "等待模型返回。组数包含已处理及已明确无法读取的附件组;不代表附件全部读懂。", } value["models"]["openai"]["progress"] = { "stage": "final", "stage_label": "生成分析报告", "phase": "waiting", "completed_units": None, "total_units": None, "elapsed_seconds": 174, "stage_elapsed_seconds": 70, "updated_at": 970, "server_time": 1008, "notice": "等待模型返回。完成报告后还需校验和用药对照。", } return value def test_progress_two_models_show_distinct_real_stages_and_counts(application: QApplication, immediate: None) -> None: repository = Repository() repository.batches = [progress_batch()] original = deepcopy(repository.batches) dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() qwen, openai = dialog.model_views["qwen"], dialog.model_views["openai"] assert qwen["progress_title"].text() == "分析附件 · 本阶段 2/5 组" assert qwen["progress_bar"].maximum() == 5 and qwen["progress_bar"].value() == 2 assert openai["progress_title"].text() == "生成完整报告" assert openai["progress_bar"].maximum() == 0 assert "校验和用药对照" in openai["progress_detail"].text() assert "%" not in qwen["progress_title"].text() + openai["progress_title"].text() assert "用药对照:待处理" in dialog.progress_flow.text() assert dialog._timer.isActive() and dialog._progress_timer.isActive() assert "千问 附件 2/5\nOpenAI 生成报告" in ai.state_text(original[0]) assert "本阶段 2/5 组" in ai.status_tooltip(original[0]) assert repository.batches == original assert all(call[0] in {"reports", "detail"} for call in repository.calls) dialog.close() assert not dialog._timer.isActive() and not dialog._progress_timer.isActive() def test_progress_local_clock_uses_server_durations_without_additional_requests(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: ticks = [100.0] monkeypatch.setattr(ai, "monotonic", lambda: ticks[0]) repository = Repository() repository.batches = [progress_batch()] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() calls = list(repository.calls) ticks[0] = 112.0 dialog._render_progress() detail = dialog.model_views["qwen"]["progress_detail"].text() assert "已用时 3 分 08 秒" in detail and "本阶段 58 秒" in detail assert "阶段更新于 20 秒前" in detail assert repository.calls == calls dialog.hide() frozen = dialog.model_views["qwen"]["progress_detail"].text() ticks[0] = 200.0 dialog._render_progress() assert dialog.model_views["qwen"]["progress_detail"].text() == frozen assert not dialog._timer.isActive() and not dialog._progress_timer.isActive() assert dialog.model_views["openai"]["progress_bar"].maximum() != 0 def test_preparing_countdown_reaches_deadline_without_implying_report_completion(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: ticks = [100.0] monkeypatch.setattr(ai, "monotonic", lambda: ticks[0]) repository = Repository() value = batch(status="waiting_sources") value["models"] = {} value["progress"] = {"stage": "waiting_sources", "phase": "waiting", "elapsed_seconds": 12, "wait_remaining_seconds": 10, "updated_at": 1000, "server_time": 1002} repository.batches = [value] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert "资料等待窗口剩余 10 秒" in dialog.batch_progress.text() assert "双模型分析:待开始" in dialog.progress_flow.text() ticks[0] = 115.0 dialog._render_progress() assert "等待窗口已到,等待服务端确认" in dialog.batch_progress.text() assert "已完成" not in dialog.batch_progress.text() assert dialog._progress_timer.isActive() assert all(view["progress_bar"].maximum() == 0 for view in dialog.model_views.values()) dialog.close() @pytest.mark.parametrize(("stage", "expected"), [("text", "分析文字资料"), ("reduce", "汇总资料要点"), ("validating", "校验报告"), ("comparing", "计算用药对照"), ("future_stage", "等待阶段详情")]) def test_all_progress_stages_have_safe_chinese_fallback(stage: str, expected: str) -> None: view = ai.progress_view({"status": "running", "progress": {"stage": stage, "stage_label": "", "elapsed_seconds": None}}) assert expected in view.headline assert "future_stage" not in view.headline and " None: view = ai.progress_view({"status": "running", "progress": {"stage": "files", "completed_units": counts[0], "total_units": counts[1]}}) assert view.completed is None and view.total is None and view.busy def test_old_api_and_unknown_progress_fields_remain_readable(application: QApplication, immediate: None) -> None: repository = Repository() repository.batches[0]["models"]["openai"]["progress"] = {"stage": "future_stage", "future_field": "untrusted", "server_time": 2000, "updated_at": 0} dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert dialog.model_views["qwen"]["progress_title"].text() == "已完成" assert "等待阶段详情" in dialog.model_views["openai"]["progress_title"].text() assert "阶段更新" not in dialog.model_views["openai"]["progress_detail"].text() repository.batches[0]["models"]["openai"].pop("progress") dialog._poll() assert "服务端暂未提供分阶段进度" in dialog.model_views["openai"]["progress_detail"].text() dialog.close() def test_partial_completion_stops_both_timers_and_keeps_retry_target(application: QApplication, immediate: None) -> None: repository = Repository() value = progress_batch() value["status"] = "partial" value["models"]["qwen"].update(status="success", report={"summary": "已保存结果"}) value["models"]["openai"].update(status="failed", error_code="UPSTREAM_TIMEOUT") repository.batches = [value] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert dialog.model_views["qwen"]["progress_title"].text() == "已完成" assert dialog.model_views["openai"]["progress_title"].text() == "处理失败" assert "1/2 完成,1 个失败" in dialog.progress_flow.text() assert "部分完成" in dialog.progress_flow.text() assert not dialog._timer.isActive() and not dialog._progress_timer.isActive() assert all(view["progress_bar"].maximum() != 0 for view in dialog.model_views.values()) assert dialog.model_views["openai"]["retry"].isEnabled() assert not dialog.model_views["qwen"]["retry"].isEnabled() dialog.close() @pytest.mark.parametrize("validity", ["stale", "revoked", "superseded"]) def test_invalid_batch_progress_is_only_a_saved_record(application: QApplication, immediate: None, validity: str) -> None: repository = Repository() value = progress_batch() value["validity"] = validity repository.batches = [value] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() assert "上次记录" in dialog.model_views["openai"]["progress_title"].text() assert "已失效" in dialog.progress_flow.text() assert not dialog._timer.isActive() and not dialog._progress_timer.isActive() assert dialog.model_views["openai"]["progress_bar"].maximum() != 0 dialog.close() def test_poll_failure_marks_cached_progress_and_refresh_recovers_without_losing_draft(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> None: ticks = [100.0] monkeypatch.setattr(ai, "monotonic", lambda: ticks[0]) repository = Repository() value = progress_batch() value["models"]["qwen"].update(status="success", report={"summary": "可供复核的已完成报告"}) repository.batches = [value] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() dialog.model_views["qwen"]["comment"].setPlainText("尚未保存的复核意见") ticks[0] = 120.0 def offline(_batch_id: int) -> None: raise RuntimeError("connection refused") monkeypatch.setattr(repository, "get_prescription_ai_report", offline) dialog._poll() assert "上次读取的数据" in dialog.message.text() assert "上次记录" in dialog.model_views["openai"]["progress_title"].text() assert "进度同步已暂停" in dialog.model_views["openai"]["progress_detail"].text() assert not dialog._timer.isActive() and not dialog._progress_timer.isActive() frozen = dialog.model_views["openai"]["progress_detail"].text() ticks[0] = 200.0 dialog._render_progress() assert dialog.model_views["openai"]["progress_detail"].text() == frozen assert "可供复核" in dialog.model_views["qwen"]["report"].toPlainText() assert not dialog.model_views["qwen"]["save"].isEnabled() monkeypatch.setattr(repository, "get_prescription_ai_report", Repository.get_prescription_ai_report.__get__(repository)) dialog.refresh() assert dialog._timer.isActive() and dialog._progress_timer.isActive() assert "上次记录" not in dialog.model_views["openai"]["progress_title"].text() assert "失败" not in dialog.message.text() assert dialog.model_views["qwen"]["comment"].toPlainText() == "尚未保存的复核意见" assert dialog.model_views["qwen"]["save"].isEnabled() dialog.close() def test_access_revocation_clears_reports_and_all_progress(application: QApplication, immediate: None) -> None: from doctor_workstation.core.errors import AuthenticationExpiredError repository = Repository() repository.batches = [progress_batch()] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() dialog._detail_error(AuthenticationExpiredError("Expired"), dialog._generation) assert dialog._batch == {} assert not dialog._timer.isActive() and not dialog._progress_timer.isActive() assert all(view["progress_bar"].maximum() != 0 for view in dialog.model_views.values()) assert not dialog.model_views["qwen"]["report"].toPlainText() assert not dialog.model_views["qwen"]["comment"].toPlainText() assert "登录" in dialog.message.text() assert not dialog.regenerate_button.isEnabled() dialog.close() def test_progress_updates_preserve_report_document_scroll_and_selection(application: QApplication, immediate: None) -> None: repository = Repository() value = progress_batch() value["models"]["qwen"].update(status="success", report={"summary": "\n".join(f"报告第 {index} 行" for index in range(180))}) repository.batches = [value] dialog = ai.IssuedPrescriptionAiDialog(repository, ["*"], prescription_id=801) dialog.show() application.processEvents() report = dialog.model_views["qwen"]["report"] report.verticalScrollBar().setValue(300) scroll = report.verticalScrollBar().value() revision = report.document().revision() changes = [] report.textChanged.connect(lambda: changes.append(True)) repository.batches[0]["models"]["openai"]["progress"]["stage_elapsed_seconds"] += 5 dialog._poll() assert report.document().revision() == revision assert report.verticalScrollBar().value() == scroll assert changes == [] dialog.close() def test_progress_notice_and_source_references_remain_plain_and_escaped(application: QApplication, immediate: None) -> None: repository = Repository() value = progress_batch() attack = '' 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