"""Patient-level AI report contracts and reception history behaviour.""" from __future__ import annotations import os from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from datetime import date from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest from PySide6.QtWidgets import QApplication, QLabel, QScrollArea from doctor_workstation.core import PermissionSet from doctor_workstation.services.mock_repository import DemoDoctorRepository from doctor_workstation.services.repository import RemoteDoctorRepository from doctor_workstation.ui.pages import reception as reception_module from doctor_workstation.ui.pages.reception import ( AI_MEDICAL_DISCLAIMER, ReceptionPage, _ai_narrative_text, _generated_patient_report, _normalize_patient_report, _patient_report_rows, _ReceptionAiAnalysisDialog, ) @pytest.fixture(scope="module") def application() -> QApplication: return QApplication.instance() or QApplication([]) @pytest.fixture def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None: def run_immediately( function: Any, *args: Any, on_success: Any = None, on_error: Any = None, on_finished: Any = None, pool: Any = None, priority: int = 0, **kwargs: Any, ) -> object: del pool, priority try: result = function(*args, **kwargs) except Exception as error: if on_error: on_error(error) else: if on_success: on_success(result) finally: if on_finished: on_finished() return object() monkeypatch.setattr(reception_module, "run_async", run_immediately) def _detail(appointment_id: int, patient_id: int, diagnosis_id: int) -> dict[str, Any]: return { "appointment": { "id": appointment_id, "patient_id": patient_id, "patient_name": "快照患者", "status": 1, "appointment_date": date.today().isoformat(), }, "patient": {"id": patient_id, "patient_name": "快照患者", "age": 48}, "diagnosis": { "id": diagnosis_id, "patient_id": patient_id, "clinical_diagnosis": "气阴两虚证", }, } def _snapshot(model: str, version: int, stamp: str) -> dict[str, Any]: label = "OpenAI" if model == "openai" else "千问" return { "id": version * 10 + (2 if model == "openai" else 1), "patient_id": 301, "model_key": model, "model_label": label, "model_name": "gpt-demo" if model == "openai" else "qwen-demo", "version": version, "generated_at": stamp, "report": { "diagnosis": f"{label}第 {version} 版诊断建议", "risk_assessment": [{"label": "随访风险", "level": "low"}], "treatment_advice": f"{label}第 {version} 版治疗建议", "disclaimer": "服务端免责声明", }, } def test_remote_patient_report_contract_sends_only_patient_and_model() -> None: class Client: token = "token" def __init__(self) -> None: self.calls: list[tuple[str, str, dict[str, Any]]] = [] def get(self, endpoint: str, params: dict[str, Any], **_kwargs: Any) -> Any: self.calls.append(("get", endpoint, dict(params))) return {"patient_id": params["patient_id"], "reports": []} def post(self, endpoint: str, body: dict[str, Any], **_kwargs: Any) -> Any: self.calls.append(("post", endpoint, dict(body))) return {"patient_id": body["patient_id"], "reports": []} client = Client() repository = RemoteDoctorRepository(client) # type: ignore[arg-type] repository.list_patient_ai_reports(301) repository.generate_patient_ai_report(301, model="qwen") assert client.calls == [ ("get", "tcm.diagnosis/patientAiReports", {"patient_id": 301}), ( "post", "tcm.diagnosis/generatePatientAiReport", {"patient_id": 301, "model": "qwen"}, ), ] assert not ({"key", "api_key", "base_url", "provider"} & client.calls[-1][2].keys()) def test_demo_patient_history_has_two_versions_and_generation_appends() -> None: repository = DemoDoctorRepository() before = repository.list_patient_ai_reports(301) assert len(before["reports"]) == 4 assert [row["version"] for row in before["reports"] if row["model_key"] == "qwen"] == [2, 1] generated = repository.generate_patient_ai_report(301, model="qwen") assert "reports" not in generated assert "latest_by_model" not in generated assert generated["generated_report"]["version"] == 3 assert generated["generated_report"] == generated["report"] assert generated["disclaimer"] == AI_MEDICAL_DISCLAIMER assert generated["generated_report"]["disclaimer"] == AI_MEDICAL_DISCLAIMER assert isinstance(generated["source_summary"], dict) assert generated["source_summary"] == generated["generated_report"]["source_summary"] assert len(repository.list_patient_ai_reports(301)["reports"]) == 5 assert repository.list_patient_ai_reports(301)["disclaimer"] == AI_MEDICAL_DISCLAIMER def test_saved_history_is_rendered_without_automatic_generation( application: QApplication, immediate_async: None, ) -> None: detail = _detail(101, 301, 501) reports = [ _snapshot("qwen", 2, "2026-08-13 15:42:00"), _snapshot("openai", 2, "2026-08-13 15:43:00"), _snapshot("qwen", 1, "2026-08-12 09:18:00"), _snapshot("openai", 1, "2026-08-12 09:19:00"), ] class Repository: list_calls: list[int] = [] generate_calls: list[tuple[int, str]] = [] def get_reception(self, appointment_id: int) -> dict[str, Any]: assert appointment_id == 101 return detail def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: self.list_calls.append(patient_id) return {"patient_id": patient_id, "reports": reports} def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]: self.generate_calls.append((patient_id, model)) raise AssertionError("saved history must not auto-generate") repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) application.processEvents() assert repository.list_calls == [301] assert repository.generate_calls == [] assert page.ai_summary_label.text() == "千问第 2 版诊断建议" assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2] assert "第 2 版" in page.ai_analysis_snapshot_meta.text() assert page.ai_analysis_disclaimer.text() == AI_MEDICAL_DISCLAIMER assert not page.ai_analysis_disclaimer.isVisibleTo(page) assert page.ai_analysis_history_button.objectName() == "ReceptionAiHistoryButton" assert page.ai_analysis_regenerate_button.objectName() == "ReceptionAiRegenerateButton" dialog = _ReceptionAiAnalysisDialog(page._ai_analysis_histories, preferred_model="qwen") assert dialog.history_selector.count() == 2 assert dialog.disclaimer_label.text() == AI_MEDICAL_DISCLAIMER dialog.history_selector.setCurrentIndex(1) assert "第 1 版诊断建议" in dialog.diagnosis_label.text() dialog.close() page.close() def test_empty_database_and_manual_refresh_append_qwen_then_openai( application: QApplication, immediate_async: None, ) -> None: detail = _detail(102, 302, 502) class Repository: def __init__(self) -> None: self.reports: list[dict[str, Any]] = [] self.calls: list[tuple[str, Any]] = [] def get_reception(self, appointment_id: int) -> dict[str, Any]: return detail def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: self.calls.append(("list", patient_id)) return {"patient_id": patient_id, "reports": list(self.reports)} def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]: self.calls.append(("generate", model)) version = 1 + sum(row["model_key"] == model for row in self.reports) row = _snapshot(model, version, f"2026-08-14 10:0{len(self.reports)}:00") row["patient_id"] = patient_id self.reports.append(row) return { "patient_id": patient_id, "generated_report": row, "report": row, } repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) application.processEvents() assert repository.calls == [("list", 302), ("generate", "qwen"), ("generate", "openai")] assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [1, 1] page.ai_analysis_regenerate_button.click() application.processEvents() assert repository.calls[-2:] == [("generate", "qwen"), ("generate", "openai")] assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2] page.close() def test_openai_failure_keeps_new_qwen_snapshot( application: QApplication, immediate_async: None, ) -> None: detail = _detail(103, 303, 503) class Repository: def get_reception(self, appointment_id: int) -> dict[str, Any]: return detail def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: return {"patient_id": patient_id, "reports": []} def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]: if model == "openai": raise RuntimeError("OpenAI 暂时不可用") row = _snapshot("qwen", 1, "2026-08-14 10:30:00") row["patient_id"] = patient_id return { "patient_id": patient_id, "generated_report": row, "report": row, } page = ReceptionPage( Repository(), PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) application.processEvents() assert page._ai_analysis_model_states["qwen"] == "success" assert page.ai_summary_label.text() == "千问第 1 版诊断建议" assert len(page._ai_analysis_histories["qwen"]) == 1 assert page._ai_analysis_model_states["openai"] == "error" assert "千问新快照已保留" in page.ai_analysis_secondary_status.text() page.close() def test_finished_only_cached_regeneration_unlocks_retry_and_keeps_snapshot( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: detail = _detail(109, 301, 509) saved = _snapshot("qwen", 1, "2026-08-14 10:45:00") jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() monkeypatch.setattr(reception_module, "run_async", queue) class Repository: def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: return {"patient_id": patient_id, "reports": [saved]} def generate_patient_ai_report( self, patient_id: int, *, model: str, ) -> dict[str, Any]: raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}") page = ReceptionPage( Repository(), PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) jobs[0]["on_success"]({"detail": detail, "warnings": []}) jobs[1]["on_success"]({"patient_id": 301, "reports": [saved]}) jobs[1]["on_finished"]() assert page._ai_analysis_model_states["qwen"] == "success" assert page.ai_analysis_regenerate_button.isEnabled() page.ai_analysis_regenerate_button.click() assert len(jobs) == 3 assert page._ai_analysis_regenerating assert not page.ai_analysis_regenerate_button.isEnabled() jobs[2]["on_finished"]() assert not page._ai_analysis_regenerating assert page._ai_analysis_regeneration_model is None assert page._ai_analysis_model_states["qwen"] == "success" assert page.ai_summary_label.text() == "千问第 1 版诊断建议" assert page.ai_analysis_regenerate_button.isEnabled() assert "未返回有效结果" in page.ai_analysis_secondary_status.text() page.close() def test_late_patient_history_response_is_discarded_after_switch( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: first = _detail(104, 304, 504) second = _detail(105, 305, 505) jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() monkeypatch.setattr(reception_module, "run_async", queue) class Repository: def get_reception(self, appointment_id: int) -> dict[str, Any]: return first if appointment_id == 104 else second def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: row = _snapshot("qwen", 1, f"2026-08-14 10:{patient_id - 300:02d}:00") row["patient_id"] = patient_id row["report"]["diagnosis"] = f"患者 {patient_id} 的报告" return {"patient_id": patient_id, "reports": [row]} def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]: raise AssertionError("history exists") def finish(job: dict[str, Any]) -> None: result = job["function"](*job.get("args", ())) if job.get("on_success"): job["on_success"](result) if job.get("on_finished"): job["on_finished"]() page = ReceptionPage( Repository(), PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(first["appointment"]) finish(jobs[0]) first_history_job = jobs[1] page._select_record(second["appointment"]) assert first_history_job["function"]() is reception_module._ASYNC_REQUEST_CANCELLED finish(jobs[2]) second_history_job = jobs[3] finish(second_history_job) assert page.ai_summary_label.text() == "患者 305 的报告" finish(first_history_job) assert page._ai_analysis_patient_id == 305 assert page.ai_summary_label.text() == "患者 305 的报告" page.close() def test_aba_switch_attaches_to_inflight_patient_generation_without_duplicate_post( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: first = _detail(110, 301, 510) second = _detail(111, 302, 511) jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() monkeypatch.setattr(reception_module, "run_async", queue) class Repository: def __init__(self) -> None: self.generate_calls: list[tuple[int, str]] = [] def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: return {"patient_id": patient_id, "reports": []} def generate_patient_ai_report( self, patient_id: int, *, model: str, ) -> dict[str, Any]: self.generate_calls.append((patient_id, model)) generated = _snapshot(model, 1, "2026-08-14 12:00:00") generated["patient_id"] = patient_id return { "patient_id": patient_id, "generated_report": generated, "report": generated, } repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(first["appointment"]) jobs[0]["on_success"]({"detail": first, "warnings": []}) jobs[1]["on_success"]({"patient_id": 301, "reports": []}) first_qwen_job = jobs[2] qwen_result = first_qwen_job["function"]() page._select_record(second["appointment"]) page._select_record(first["appointment"]) jobs[4]["on_success"]({"detail": first, "warnings": []}) assert len(jobs) == 5 first_qwen_job["on_success"](qwen_result) first_qwen_job["on_finished"]() assert repository.generate_calls == [(301, "qwen")] assert len(jobs) == 6 assert page._ai_analysis_model_states["qwen"] == "success" assert page.ai_summary_label.text() == "千问第 1 版诊断建议" page.close() application.processEvents() def test_patient_history_get_is_singleflight_across_aba_switch( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: first = _detail(112, 301, 512) second = _detail(113, 302, 513) saved = _snapshot("qwen", 1, "2026-08-14 12:10:00") jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() monkeypatch.setattr(reception_module, "run_async", queue) class Repository: def __init__(self) -> None: self.list_calls: list[int] = [] def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: self.list_calls.append(patient_id) return {"patient_id": patient_id, "reports": [saved]} def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any: raise AssertionError(f"history exists: {patient_id}/{model}") repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(first["appointment"]) jobs[0]["on_success"]({"detail": first, "warnings": []}) first_list_job = jobs[1] list_result = first_list_job["function"]() page._select_record(second["appointment"]) page._select_record(first["appointment"]) jobs[3]["on_success"]({"detail": first, "warnings": []}) assert len(jobs) == 4 assert repository.list_calls == [301] first_list_job["on_success"](list_result) first_list_job["on_finished"]() assert page._ai_analysis_model_states["qwen"] == "success" assert page.ai_summary_label.text() == "千问第 1 版诊断建议" page.close() application.processEvents() def test_patient_ai_finished_only_tracks_qwen_workers_not_unrelated_openai( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: detail = _detail(114, 301, 514) jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() monkeypatch.setattr(reception_module, "run_async", queue) class Repository: def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: return {"patient_id": patient_id, "reports": []} def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any: raise AssertionError(f"worker must remain queued: {patient_id}/{model}") page = ReceptionPage( Repository(), PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) jobs[0]["on_success"]({"detail": detail, "warnings": []}) current_list_job = jobs[1] stale_qwen = (page._ai_analysis_generation - 2, 114, 301, "qwen") stale_openai = (page._ai_analysis_generation - 1, 114, 301, "openai") page._patient_ai_generation_requests.add(stale_qwen) page._patient_ai_generation_finished(*stale_qwen) assert page._ai_analysis_model_states["qwen"] == "loading" page._patient_ai_generation_requests.add(stale_openai) current_list_job["on_finished"]() page._patient_ai_generation_finished(*stale_openai) assert not page._patient_ai_list_requests assert not page._patient_ai_generation_requests assert page._ai_analysis_model_states["qwen"] == "error" assert not page._ai_analysis_loading assert "未返回有效结果" in page.ai_analysis_state_label.text() page.close() application.processEvents() def test_aba_cancelled_generation_is_replaced_instead_of_becoming_false_error( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: first = _detail(115, 301, 515) second = _detail(116, 302, 516) jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() monkeypatch.setattr(reception_module, "run_async", queue) monkeypatch.setattr( reception_module, "_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS", 5.0, ) class Repository: def __init__(self) -> None: self.generate_calls: list[tuple[int, str]] = [] def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: return {"patient_id": patient_id, "reports": []} def generate_patient_ai_report( self, patient_id: int, *, model: str, ) -> dict[str, Any]: self.generate_calls.append((patient_id, model)) generated = _snapshot(model, 1, "2026-08-14 12:20:00") generated["patient_id"] = patient_id return { "patient_id": patient_id, "generated_report": generated, "report": generated, } repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(first["appointment"]) jobs[0]["on_success"]({"detail": first, "warnings": []}) jobs[1]["on_success"]({"patient_id": 301, "reports": []}) old_qwen_job = jobs[2] with ThreadPoolExecutor(max_workers=1) as executor: cancelled_future = executor.submit(old_qwen_job["function"]) page._select_record(second["appointment"]) cancelled = cancelled_future.result(timeout=1.0) monkeypatch.setattr( reception_module, "_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS", 0.0, ) assert cancelled is reception_module._ASYNC_REQUEST_CANCELLED assert repository.generate_calls == [] page._select_record(first["appointment"]) jobs[4]["on_success"]({"detail": first, "warnings": []}) assert len(jobs) == 6 current_list_job = jobs[5] current_list_job["on_success"]({"patient_id": 301, "reports": []}) assert len(jobs) == 7 replacement_qwen_job = jobs[6] current_list_job["on_finished"]() assert len(jobs) == 7 qwen_result = replacement_qwen_job["function"]() replacement_qwen_job["on_success"](qwen_result) replacement_qwen_job["on_finished"]() jobs_after_replacement = len(jobs) old_qwen_job["on_success"](cancelled) old_qwen_job["on_finished"]() assert repository.generate_calls == [(301, "qwen")] assert len(jobs) == jobs_after_replacement assert page._ai_analysis_model_states["qwen"] == "success" assert page.ai_summary_label.text() == "千问第 1 版诊断建议" page.close() application.processEvents() @pytest.mark.parametrize("late_completion", ["cancelled", "error"]) def test_late_history_completion_cannot_clear_newer_generated_snapshot( application: QApplication, monkeypatch: pytest.MonkeyPatch, late_completion: str, ) -> None: detail = _detail(117, 301, 517) jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() monkeypatch.setattr(reception_module, "run_async", queue) class Repository: def __init__(self) -> None: self.generate_calls: list[tuple[int, str]] = [] def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: raise AssertionError(f"history worker remains pending: {patient_id}") def generate_patient_ai_report( self, patient_id: int, *, model: str, ) -> dict[str, Any]: self.generate_calls.append((patient_id, model)) generated = _snapshot(model, 9, "2026-08-14 12:30:00") generated["id"] = 901 if model == "qwen" else 902 generated["patient_id"] = patient_id return { "patient_id": patient_id, "generated_report": generated, "report": generated, } repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) jobs[0]["on_success"]({"detail": detail, "warnings": []}) history_job = jobs[1] current_context = page._current_patient_ai_context(301) assert current_context is not None generation, appointment_id, _patient_id = current_context page._request_patient_ai_generation("qwen", generation, appointment_id, 301) qwen_job = jobs[2] qwen_result = qwen_job["function"]() qwen_job["on_success"](qwen_result) qwen_job["on_finished"]() jobs_after_generation = len(jobs) if late_completion == "cancelled": history_job["on_success"](reception_module._ASYNC_REQUEST_CANCELLED) else: history_job["on_error"](RuntimeError("late history failure")) history_job["on_finished"]() assert repository.generate_calls == [(301, "qwen")] assert len(jobs) == jobs_after_generation assert page._ai_analysis_model_states["qwen"] == "success" assert page._ai_analysis_payloads["qwen"]["id"] == 901 assert page.ai_summary_label.text() == "千问第 9 版诊断建议" page.close() application.processEvents() def test_cancelled_queued_generation_does_not_invalidate_valid_history_get( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: first = _detail(118, 301, 518) second = _detail(119, 302, 519) saved = _snapshot("qwen", 10, "2026-08-14 12:40:00") jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() monkeypatch.setattr(reception_module, "run_async", queue) class Repository: def __init__(self) -> None: self.list_calls: list[int] = [] self.generate_calls: list[tuple[int, str]] = [] def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: self.list_calls.append(patient_id) return {"patient_id": patient_id, "reports": [saved]} def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any: self.generate_calls.append((patient_id, model)) raise AssertionError("cancelled queued POST must not enter repository") repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(first["appointment"]) jobs[0]["on_success"]({"detail": first, "warnings": []}) history_job = jobs[1] history_result = history_job["function"]() current_context = page._current_patient_ai_context(301) assert current_context is not None generation, appointment_id, _patient_id = current_context page._request_patient_ai_generation("qwen", generation, appointment_id, 301) queued_qwen_job = jobs[2] page._select_record(second["appointment"]) cancelled = queued_qwen_job["function"]() page._select_record(first["appointment"]) jobs[4]["on_success"]({"detail": first, "warnings": []}) queued_qwen_job["on_success"](cancelled) queued_qwen_job["on_finished"]() history_job["on_success"](history_result) history_job["on_finished"]() assert repository.list_calls == [301] assert repository.generate_calls == [] assert len(jobs) == 5 assert page._ai_analysis_model_states["qwen"] == "success" assert page.ai_summary_label.text() == "千问第 10 版诊断建议" page.close() application.processEvents() def test_saturated_automatic_ai_slots_end_in_retryable_state_without_post( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: detail = _detail(120, 301, 520) jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() class BusyAutomaticSlots: @staticmethod def acquire(*, blocking: bool) -> bool: assert not blocking return False @staticmethod def release() -> None: raise AssertionError("an unacquired slot must not be released") monkeypatch.setattr(reception_module, "run_async", queue) monkeypatch.setattr( reception_module, "_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS", 0.0, ) monkeypatch.setattr( reception_module, "_AI_AUTOMATIC_REQUEST_SLOTS", BusyAutomaticSlots(), ) class Repository: def __init__(self) -> None: self.generate_calls: list[tuple[int, str]] = [] def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: return {"patient_id": patient_id, "reports": []} def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any: self.generate_calls.append((patient_id, model)) raise AssertionError("busy automatic work must not submit a POST") repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) jobs[0]["on_success"]({"detail": detail, "warnings": []}) jobs[1]["on_success"]({"patient_id": 301, "reports": []}) automatic_qwen_job = jobs[2] deferred = automatic_qwen_job["function"]() automatic_qwen_job["on_success"](deferred) automatic_qwen_job["on_finished"]() assert deferred is reception_module._ASYNC_REQUEST_DEFERRED assert repository.generate_calls == [] assert page._ai_analysis_model_states["qwen"] == "error" assert not page._ai_analysis_loading assert not page._ai_analysis_regenerating assert page.ai_analysis_regenerate_button.isEnabled() assert "后台分析任务较多" in page.ai_analysis_state_label.text() page.close() application.processEvents() def test_saturated_history_slots_end_in_retryable_state_without_get( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: detail = _detail(121, 301, 521) jobs: list[dict[str, Any]] = [] def queue(function: Any, *args: Any, **options: Any) -> object: jobs.append({"function": function, "args": args, **options}) return object() class BusyAutomaticSlots: @staticmethod def acquire(*, blocking: bool) -> bool: assert not blocking return False @staticmethod def release() -> None: raise AssertionError("an unacquired slot must not be released") monkeypatch.setattr(reception_module, "run_async", queue) monkeypatch.setattr( reception_module, "_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS", 0.0, ) monkeypatch.setattr( reception_module, "_AI_AUTOMATIC_REQUEST_SLOTS", BusyAutomaticSlots(), ) class Repository: def __init__(self) -> None: self.list_calls: list[int] = [] def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: self.list_calls.append(patient_id) raise AssertionError("busy automatic read must not enter repository") def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any: raise AssertionError(f"history did not complete: {patient_id}/{model}") repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) jobs[0]["on_success"]({"detail": detail, "warnings": []}) history_job = jobs[1] deferred = history_job["function"]() history_job["on_success"](deferred) history_job["on_finished"]() assert deferred is reception_module._ASYNC_REQUEST_DEFERRED assert repository.list_calls == [] assert page._ai_analysis_model_states["qwen"] == "error" assert not page._ai_analysis_loading assert page.ai_analysis_retry_button.isEnabled() assert "查询任务较多" in page.ai_analysis_state_label.text() page.close() application.processEvents() def test_get_history_requires_exact_top_level_and_row_patient_ids() -> None: row = _snapshot("qwen", 1, "2026-08-14 11:00:00") valid = {"patient_id": 301, "reports": [row]} rows = _patient_report_rows(valid, expected_patient_id=301) assert rows is not None and len(rows) == 1 invalid_top_level_ids: tuple[Any, ...] = (None, 0, -1, True, "301", 302) for patient_id in invalid_top_level_ids: assert ( _patient_report_rows( {"patient_id": patient_id, "reports": [row]}, expected_patient_id=301, ) is None ) wrong_row = dict(row, patient_id=302) assert ( _patient_report_rows( {"patient_id": 301, "reports": [wrong_row]}, expected_patient_id=301, ) is None ) assert ( _patient_report_rows( { "patient_id": 302, "data": {"patient_id": 301, "reports": [row]}, }, expected_patient_id=301, ) is None ) def test_post_accepts_only_the_current_persisted_snapshot() -> None: valid = _snapshot("qwen", 3, "2026-08-14 11:05:00") accepted = _generated_patient_report( {"patient_id": 301, "generated_report": valid}, expected_patient_id=301, expected_model="qwen", ) assert accepted is not None and accepted["id"] == valid["id"] invalid_payloads = ( {"patient_id": 301, "reports": [valid], "report": valid}, {"patient_id": 301, "generated_report": {}, "reports": [valid]}, {"patient_id": 302, "generated_report": valid}, { "patient_id": 301, "generated_report": dict(valid, patient_id=302), }, {"patient_id": 301, "generated_report": dict(valid, id=0)}, {"patient_id": 301, "generated_report": dict(valid, id="31")}, { "patient_id": 301, "generated_report": dict(valid, model_key="openai"), }, ) for payload in invalid_payloads: assert ( _generated_patient_report( payload, expected_patient_id=301, expected_model="qwen", ) is None ) def test_stale_post_history_cannot_fake_generation_success( application: QApplication, immediate_async: None, ) -> None: detail = _detail(106, 306, 506) class Repository: def __init__(self) -> None: self.generate_calls: list[str] = [] def get_reception(self, appointment_id: int) -> dict[str, Any]: return detail def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: return {"patient_id": patient_id, "reports": []} def generate_patient_ai_report( self, patient_id: int, *, model: str, ) -> dict[str, Any]: self.generate_calls.append(model) old = _snapshot("qwen", 9, "2026-08-13 08:00:00") old["patient_id"] = patient_id return { "patient_id": patient_id, "generated_report": None, "reports": [old], "report": old, } repository = Repository() page = ReceptionPage( repository, PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) application.processEvents() assert repository.generate_calls == ["qwen"] assert not any(page._ai_analysis_histories.values()) assert page._ai_analysis_model_states["qwen"] == "error" page.close() def test_patient_report_generation_requires_read_and_generate_permissions( application: QApplication, immediate_async: None, ) -> None: detail = _detail(107, 307, 507) row = _snapshot("qwen", 1, "2026-08-14 11:10:00") row["patient_id"] = 307 class Repository: def __init__(self) -> None: self.list_calls = 0 self.generate_calls = 0 def get_reception(self, appointment_id: int) -> dict[str, Any]: return detail def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: self.list_calls += 1 return {"patient_id": patient_id, "reports": [row]} def generate_patient_ai_report( self, patient_id: int, *, model: str, ) -> dict[str, Any]: self.generate_calls += 1 return {"patient_id": patient_id, "generated_report": row} cases = ( ([], False, 0), (["tcm.diagnosis/patientAiReports"], False, 1), (["tcm.diagnosis/generatePatientAiReport"], False, 0), (["tcm.diagnosis/aiAnalysis"], False, 0), ( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ], True, 1, ), ) for permissions, expected_enabled, expected_list_calls in cases: repository = Repository() page = ReceptionPage(repository, PermissionSet(permissions)) page._select_record(detail["appointment"]) application.processEvents() assert page.ai_analysis_regenerate_button.isEnabled() is expected_enabled assert repository.list_calls == expected_list_calls assert repository.generate_calls == 0 page.close() def test_ui_never_displays_internal_prompt_version( application: QApplication, immediate_async: None, ) -> None: detail = _detail(108, 308, 508) reports = [ _snapshot("qwen", 2, "2026-08-14 11:20:00"), _snapshot("qwen", 1, "2026-08-13 11:20:00"), ] for row in reports: row["patient_id"] = 308 row.pop("version") row["prompt_version"] = "patient-longitudinal-report-internal-v99" class Repository: def get_reception(self, appointment_id: int) -> dict[str, Any]: return detail def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: return {"patient_id": patient_id, "reports": reports} def generate_patient_ai_report( self, patient_id: int, *, model: str, ) -> dict[str, Any]: raise AssertionError("saved history must not auto-generate") page = ReceptionPage( Repository(), PermissionSet( [ "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", ] ), ) page._select_record(detail["appointment"]) application.processEvents() assert page.ai_analysis_snapshot_meta.text().startswith("第 2 版") assert "internal-v99" not in page.ai_analysis_snapshot_meta.text() assert "internal-v99" not in page.ai_analysis_snapshot_meta.toolTip() dialog = _ReceptionAiAnalysisDialog(page._ai_analysis_histories, preferred_model="qwen") assert dialog.history_selector.itemText(0).startswith("第 2 版") assert dialog.history_selector.itemText(1).startswith("第 1 版") assert "internal-v99" not in dialog.meta_label.text() dialog.close() page.close() def test_patient_ai_disclaimer_remains_the_unified_text() -> None: assert AI_MEDICAL_DISCLAIMER == ( "仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。" "系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。" ) def test_ai_narrative_formatter_preserves_lists_arrays_and_medical_numbers() -> None: diagnosis: list[Any] = [ "2型糖尿病,HbA1c 7.5%,当前控制未达标。", {"text": r"二甲双胍 0.5g,每日2次。\n复查肾功能。"}, "建议:1. 监测空腹血糖 2. 记录餐后2小时血糖", ] original = deepcopy(diagnosis) rendered = _ai_narrative_text(diagnosis) assert diagnosis == original assert rendered == _ai_narrative_text(rendered) assert rendered.splitlines() == [ "• 2型糖尿病,HbA1c 7.5%,当前控制未达标。", "• 二甲双胍 0.5g,每日2次。", "复查肾功能。", "• 建议:", "1. 监测空腹血糖", "2. 记录餐后2小时血糖", ] assert "7.5%" in rendered assert "0.5g" in rendered assert "2型糖尿病" in rendered assert "7.\n5" not in rendered assert "0.\n5" not in rendered assert "2\n型糖尿病" not in rendered payload = { "model_key": "qwen", "diagnosis_advice": diagnosis, "treatment_advice": ["控制总热量", "规律复诊"], "risk_assessment": ["低血糖风险", {"label": "依从性风险", "level": "medium"}], } payload_before = deepcopy(payload) normalized = _normalize_patient_report(payload) assert payload == payload_before assert normalized is not None assert normalized["diagnosis_advice"] == rendered assert normalized["treatment_advice"] == "• 控制总热量\n• 规律复诊" assert normalized["risk_assessment"] == [ {"label": "低血糖风险", "level": "low"}, {"label": "依从性风险", "level": "medium"}, ] def test_patient_report_dialog_uses_one_scroll_owner_and_wrapped_risk_flow( application: QApplication, ) -> None: long_risk = ( "这是一个需要换行展示的较长风险项目,用于验证标签不会超出正文区域," "并且能够在流式布局中可靠折行。" ) payload = { "model_key": "qwen", "model_label": "千问", "generated_at": "2026-08-17 10:20:00", "diagnosis_advice": [ "2型糖尿病,HbA1c 7.5%,建议继续分层监测。", "1. 监测空腹血糖 2. 记录餐后2小时血糖", ] * 10 + ["[诊断末尾]"], "risk_assessment": [ {"label": "低血糖", "level": "high"}, {"label": "依从性风险", "level": "medium"}, {"label": "并发症筛查延误风险", "level": "low"}, {"label": "复诊中断风险", "level": "medium"}, {"label": long_risk, "level": "high"}, {"label": "饮食波动风险", "level": "low"}, ], "treatment_advice": [r"二甲双胍 0.5g,每日2次。\n复查肾功能。"] * 12 + ["[治疗末尾]"], } dialog = _ReceptionAiAnalysisDialog({"qwen": [payload]}) dialog.resize(720, 560) dialog.show() application.processEvents() assert dialog.minimumWidth() == 720 assert dialog.minimumHeight() == 560 scrolls = dialog.findChildren(QScrollArea) assert scrolls == [dialog.scroll_area] assert dialog.scroll_area.horizontalScrollBar().maximum() == 0 assert dialog.scroll_area.verticalScrollBar().maximum() > 0 body = dialog.scroll_area.widget() assert body is not None and body.layout() is not None assert body.height() <= max( dialog.scroll_area.viewport().height(), body.layout().sizeHint().height(), ) + 40 assert dialog.diagnosis_label.text().endswith("[诊断末尾]") assert dialog.treatment_label.text().endswith("[治疗末尾]") assert "7.5%" in dialog.diagnosis_label.text() assert "0.5g" in dialog.treatment_label.text() risk_labels = [ label for label in dialog.findChildren(QLabel) if label.property("dialogAiRisk") ] assert len(risk_labels) == 6 assert len({label.y() for label in risk_labels}) >= 2 short_risk = risk_labels[0] wrapped_risk = next(label for label in risk_labels if label.text() == long_risk) assert short_risk.width() < dialog.risk_items.width() // 2 assert wrapped_risk.width() <= 340 assert wrapped_risk.height() > short_risk.height() assert max(label.y() + label.height() for label in risk_labels) <= dialog.risk_items.height() dialog.close() application.processEvents() def test_reception_ai_card_is_compact_preview_without_nested_scroll( application: QApplication, ) -> None: payload = { "diagnosis_advice": ["2型糖尿病,HbA1c 7.5%,需要继续监测。"] * 12, "risk_assessment": [ {"label": "低血糖", "level": "high"}, {"label": "依从性风险", "level": "medium"}, { "label": "这是一个需要在紧凑卡片内自行换行而不能向右溢出的长风险项目。", "level": "low", }, {"label": "复诊中断", "level": "medium"}, {"label": "饮食波动", "level": "low"}, {"label": "并发症筛查延误", "level": "high"}, ], "treatment_advice": ["二甲双胍 0.5g,每日2次。"] * 10, "model_key": "qwen", "model_label": "千问", } payload_before = deepcopy(payload) page = ReceptionPage(object(), PermissionSet([])) page._render_ai_analysis_payload(payload, "qwen") page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page) page.detail_stack.setCurrentIndex(1) page.resize(1494, 832) page.show() application.processEvents() assert payload == payload_before assert not isinstance(page.ai_analysis_content_page, QScrollArea) assert page.ai_analysis_card.findChildren(QScrollArea) == [] assert page.ai_analysis_card.minimumHeight() < 470 assert page.ai_analysis_card.maximumHeight() > 520 assert page.ai_analysis_card.sizeHint().height() < 470 assert page.ai_summary_label.fullText() == _ai_narrative_text( payload["diagnosis_advice"] ) assert page.ai_treatment_label.fullText() == _ai_narrative_text( payload["treatment_advice"] ) assert page.ai_summary_label.text().count("\n") + 1 == 3 assert page.ai_treatment_label.text().count("\n") + 1 == 2 assert page.ai_summary_label.text().endswith("…") assert page.ai_treatment_label.text().endswith("…") chips = [ label for label in page.ai_risk_chip_host.findChildren(QLabel) if label.property("receptionRiskChip") ] overflow = [ label for label in page.ai_risk_chip_host.findChildren(QLabel) if label.property("receptionRiskOverflow") ] assert len(chips) == 3 assert [label.text() for label in overflow] == ["+3 项"] assert max(label.x() + label.width() for label in [*chips, *overflow]) <= ( page.ai_risk_chip_host.width() ) assert max(label.y() + label.height() for label in [*chips, *overflow]) <= ( page.ai_risk_chip_host.height() ) assert page.ai_risk_label.text().count("、") == 5 page.close() application.processEvents()