Files
zyt/app/tests/test_issued_prescription_ai.py
T
2026-09-10 15:19:17 +08:00

1038 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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": "已保存千问分析 <script>bad()</script>"},
"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 "&lt;script&gt;" 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_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()
item = page.table.item(1, 1)
expected_id = page.table.item(1, 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_COMPLETEcall_records:1820", "问诊转写完整性尚未核验:问诊通话(编号:1820)"),
("TRANSCRIPT_PARTIAL: video_calls:1821", "问诊转写仅部分完成:视频问诊(编号:1821)"),
("CRITICAL_CLINICAL_FACT_MISSINGclinical.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 weeksunknown"
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_CODEfuture_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"<p>{count}</p>" 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 = '<img src="https://example.invalid/tracker" onerror="alert(1)"><script>bad()</script>'
html = ai._html({"summary": attack, "missing_information": ["TRANSCRIPT_FAILEDcall_records:1820 " + attack],
"source_id": "call_records:1820 " + attack, "status": attack})
assert "<img" not in html and "<script" not in html
assert "&lt;img" in html and "&lt;script&gt;" in html
assert "TRANSCRIPT_FAILED" not in html and "call_records:" not in html
browser = ai._browser()
browser.setHtml(html)
assert not browser.openLinks() and not browser.openExternalLinks()
assert attack in browser.toPlainText()
browser.close()
def chinese_history_batch() -> dict[str, Any]:
"""Synthetic fixture; no patient or network data is used in tests or visual QA."""
value = batch(39, status="success")
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_COMPLETEcall_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": "<img src=x>", "elapsed_seconds": None}})
assert expected in view.headline
assert "future_stage" not in view.headline and "<img" not in view.headline
assert "0%" not in view.headline and view.total is None
@pytest.mark.parametrize("counts", [(None, None), (-1, 5), (3, 0), (8, 5), (True, 5), (1, 2**40), ("2", "5")])
def test_invalid_progress_counts_do_not_become_zero_or_overflow(counts: tuple[Any, Any]) -> 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 = '<img src="https://example.invalid/tracker" onerror="alert(1)"><script>bad()</script>'
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 "&lt;script&gt;" in views["sources"].toHtml()
assert "<img" not in ai.status_tooltip(value) and "&lt;img" in ai.status_tooltip(value)
assert not views["sources"].openLinks() and not views["sources"].openExternalLinks()
dialog.close()
def test_list_shows_compact_two_model_progress_with_full_detail_in_tooltip(application: QApplication, immediate: None, monkeypatch: pytest.MonkeyPatch) -> 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