Files
zyt/app/tests/test_patient_ai_report_desktop.py
T
2026-08-20 17:47:14 +08:00

1406 lines
49 KiB
Python

"""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()