first commit
This commit is contained in:
@@ -0,0 +1,760 @@
|
||||
"""No-network contracts for the audited five doctor workspaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError
|
||||
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import (
|
||||
DIAGNOSIS_AI_PERMISSIONS,
|
||||
PRESCRIPTION_LIBRARY_PERMISSIONS,
|
||||
PRESCRIPTION_PERMISSIONS,
|
||||
RemoteDoctorRepository,
|
||||
)
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
"""Small API client double retaining exact endpoint and DTO calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = "https://example.test/adminapi/"
|
||||
self.token = "token"
|
||||
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.post_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def set_token(self, token: str) -> None:
|
||||
"""Set the current synthetic token."""
|
||||
|
||||
self.token = token
|
||||
|
||||
def clear_token(self) -> None:
|
||||
"""Clear the current synthetic token."""
|
||||
|
||||
self.token = ""
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Record a GET and return a shape appropriate for its contract."""
|
||||
|
||||
self.get_calls.append((endpoint, dict(params or {})))
|
||||
if endpoint == "auth.admin/mySelf":
|
||||
return {
|
||||
"user": {"id": 1, "name": "Doctor", "role_ids": [1]},
|
||||
"permissions": ["tcm.diagnosis/lists"],
|
||||
"menu": [
|
||||
{
|
||||
"name": "问诊列表",
|
||||
"component": "tcm/diagnosis/index",
|
||||
"future": {"badge": 3},
|
||||
"children": [{"name": "只读", "perms": "tcm.diagnosis/readonlyDetail"}],
|
||||
"unsafe": lambda: None,
|
||||
7: "non-string key",
|
||||
}
|
||||
],
|
||||
}
|
||||
if endpoint in {
|
||||
"tcm.diagnosis/detail",
|
||||
"tcm.diagnosis/readonlyDetail",
|
||||
"firstvisit.myPatient/orderDetail",
|
||||
"tcm.prescriptionOrder/detail",
|
||||
}:
|
||||
return {"id": int((params or {}).get("id", 0)), "patient_name": "测试患者"}
|
||||
if endpoint == "tcm.prescription/getByAppointment":
|
||||
return {}
|
||||
if endpoint == "tcm.prescriptionLibrary/aiReports":
|
||||
return {
|
||||
"prescription_id": int((params or {}).get("id", 0)),
|
||||
"prescription_name": "疏肝健脾基础方",
|
||||
"formula_type": "主方",
|
||||
"reports": [],
|
||||
"missing_model_keys": ["qwen", "openai"],
|
||||
"can_view": True,
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"capabilities": {"can_view": True, "can_refresh": True, "can_edit": True},
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/aiReports":
|
||||
return {
|
||||
"diagnosis_id": int((params or {}).get("id", 0)),
|
||||
"patient_name": "林晓岚",
|
||||
"case_summary": "临床诊断:肝郁脾虚证",
|
||||
"reports": [],
|
||||
"missing_model_keys": ["qwen", "openai"],
|
||||
"can_view": True,
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"capabilities": {"can_view": True, "can_refresh": True, "can_edit": True},
|
||||
}
|
||||
if endpoint == "doctor.appointment/availableSlots":
|
||||
return {"slots": [{"time": "09:00", "available": True}]}
|
||||
if endpoint == "tcm.prescriptionOrder/paidPayOrders":
|
||||
return {"lists": [{"id": 9}], "deposit_min_amount": 50}
|
||||
return {"lists": [], "count": 0, "extend": {"scope": {"label": "server"}}}
|
||||
|
||||
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
"""Record a POST and return a stable synthetic mutation result."""
|
||||
|
||||
body = dict(payload or {})
|
||||
self.post_calls.append((endpoint, body))
|
||||
if endpoint in {"tcm.prescription/add", "tcm.prescriptionOrder/create"}:
|
||||
return {"id": 88}
|
||||
if endpoint == "tcm.diagnosis/startCall":
|
||||
return {"call_record_id": 901}
|
||||
if endpoint == "tcm.prescriptionLibrary/generateAiReports":
|
||||
return {
|
||||
"prescription_id": int(body.get("id", 0)),
|
||||
"reports": [],
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"status": "success",
|
||||
}
|
||||
if endpoint == "tcm.prescriptionLibrary/editAiReport":
|
||||
return {
|
||||
"prescription_id": int(body.get("id", 0)),
|
||||
"report": {
|
||||
"report_id": int(body.get("report_id", 0)),
|
||||
"model_key": "qwen",
|
||||
"content": body.get("content", ""),
|
||||
},
|
||||
"can_edit": True,
|
||||
"can_refresh": True,
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/generateAiReports":
|
||||
return {
|
||||
"diagnosis_id": int(body.get("id", 0)),
|
||||
"reports": [],
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"status": "success",
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/editAiReport":
|
||||
return {
|
||||
"diagnosis_id": int(body.get("id", 0)),
|
||||
"report": {
|
||||
"report_id": int(body.get("report_id", 0)),
|
||||
"model_key": "qwen",
|
||||
"content": body.get("content", ""),
|
||||
},
|
||||
"can_edit": True,
|
||||
"can_refresh": True,
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/aiAssistant":
|
||||
return {
|
||||
"diagnosis_id": int(body.get("id", 0)),
|
||||
"answer": "服务端分析结果",
|
||||
"model_key": "qwen",
|
||||
"task": body.get("task"),
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/aiAnalysis":
|
||||
model = str(body.get("model") or "")
|
||||
if model == "openai":
|
||||
return {
|
||||
"diagnosis_advice": "2 型糖尿病,需结合客观检查复核",
|
||||
"risk_assessment": [
|
||||
{"label": "用药安全风险", "level": "medium"},
|
||||
{"label": "肾功能风险", "level": "low"},
|
||||
],
|
||||
"treatment_advice": "复核近期检查趋势并评估联合用药安全性。",
|
||||
"model_key": "openai",
|
||||
"model_label": "OpenAI",
|
||||
"model_name": "gpt-5.2",
|
||||
"generated_at": "2026-08-14 10:31:00",
|
||||
}
|
||||
return {
|
||||
"diagnosis_advice": "2 型糖尿病,血糖控制不佳",
|
||||
"risk_assessment": [
|
||||
{"label": "高血糖风险", "level": "high"},
|
||||
{"label": "心血管风险", "level": "medium"},
|
||||
],
|
||||
"treatment_advice": "复核用药依从性并安排糖化血红蛋白检查。",
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"model_name": "qwen3.6-35b",
|
||||
"generated_at": "2026-08-14 10:30:00",
|
||||
}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def test_page_result_preserves_outer_and_nested_extend() -> None:
|
||||
"""Server scope metadata must survive nested data pagination envelopes."""
|
||||
|
||||
page = PageResult.from_payload(
|
||||
{
|
||||
"extend": {"scope": {"label": "doctor"}},
|
||||
"data": {
|
||||
"lists": [{"id": 1}],
|
||||
"count": 4,
|
||||
"extend": {"schedule_mode": "roster"},
|
||||
},
|
||||
},
|
||||
Appointment.from_dict,
|
||||
)
|
||||
|
||||
assert page.total == 4
|
||||
assert page.extend == {
|
||||
"scope": {"label": "doctor"},
|
||||
"schedule_mode": "roster",
|
||||
}
|
||||
|
||||
|
||||
def test_consultation_keeps_diagnosis_and_appointment_status_separate() -> None:
|
||||
"""Video eligibility fields cannot be overwritten by diagnosis enablement."""
|
||||
|
||||
row = Consultation.from_dict(
|
||||
{
|
||||
"id": 3,
|
||||
"status": 1,
|
||||
"status_desc": "启用",
|
||||
"has_appointment": 1,
|
||||
"appointment_status": 4,
|
||||
"appointment_status_text": "已过号",
|
||||
"appointments": [{"id": 8, "status": 4}],
|
||||
"DiagnosisViewRecord": [{"is_confirmed": 1}],
|
||||
}
|
||||
)
|
||||
|
||||
assert row.status == 1
|
||||
assert row.appointment_status == 4
|
||||
assert row.has_appointment
|
||||
assert row.confirmed
|
||||
assert row.appointments == [{"id": 8, "status": 4}]
|
||||
|
||||
|
||||
def test_prescription_round_trips_appointment_and_case_record() -> None:
|
||||
"""Appointment authority and immutable case snapshot survive model DTOs."""
|
||||
|
||||
prescription = Prescription.from_dict(
|
||||
{
|
||||
"id": 8,
|
||||
"diagnosis_id": 5,
|
||||
"appointment_id": 17,
|
||||
"case_record": {
|
||||
"diagnosis_id": 5,
|
||||
"appointment_id": 17,
|
||||
"clinical_diagnosis": "气阴两虚证",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert prescription.appointment_id == 17
|
||||
assert prescription.case_record["clinical_diagnosis"] == "气阴两虚证"
|
||||
payload = prescription.to_api_dict()
|
||||
assert payload["appointment_id"] == 17
|
||||
assert payload["case_record"] == {
|
||||
"diagnosis_id": 5,
|
||||
"appointment_id": 17,
|
||||
"clinical_diagnosis": "气阴两虚证",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_reception_is_forcibly_scoped_to_today() -> None:
|
||||
"""Status 1/4 queue reads always carry the audited same-day date range."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.list_appointments(status=1, keyword="林", page_no=2, page_size=15)
|
||||
|
||||
endpoint, params = client.get_calls[-1]
|
||||
assert endpoint == "doctor.appointment/lists"
|
||||
assert params == {
|
||||
"status": 1,
|
||||
"patient_name": "林",
|
||||
"start_date": date.today().isoformat(),
|
||||
"end_date": date.today().isoformat(),
|
||||
"page_no": 2,
|
||||
"page_size": 15,
|
||||
}
|
||||
|
||||
|
||||
def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
||||
"""Prescription, patient and diagnosis methods remain thin endpoint adapters."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.create_prescription(
|
||||
patient_name="测试患者",
|
||||
diagnosis_id=5,
|
||||
herbs=[{"medicine_id": 1, "name": "黄芪", "dosage": 10}],
|
||||
)
|
||||
repository.patch_prescription_patient(8, patient_name="修正患者", phone="13800000000", gender=2)
|
||||
repository.audit_prescription(8, action="reject", remark="剂量不符")
|
||||
repository.create_prescription_order(
|
||||
prescription_id=8,
|
||||
diagnosis_id=5,
|
||||
recipient_name="修正患者",
|
||||
recipient_phone="13800000000",
|
||||
)
|
||||
repository.list_paid_prescription_orders(5, prescription_order_id=3)
|
||||
repository.list_medicines(name="黄芪")
|
||||
repository.patient_orders(page_no=1, page_size=15, fulfillment_status=2)
|
||||
repository.patient_progress(page_no=1, page_size=15, status=1)
|
||||
repository.patient_detail(5)
|
||||
repository.appointment_history(5)
|
||||
repository.assign_history(5)
|
||||
repository.assign_patient(5, 20, is_inherit=1)
|
||||
repository.fill_patient_id_card(5, "410000199001010000")
|
||||
repository.book_patient_appointment({"diagnosis_id": 5, "appointment_date": "2026-08-10"})
|
||||
repository.cancel_patient_appointment(7)
|
||||
repository.update_diagnosis(5, {"clinical_diagnosis": "气虚证"})
|
||||
repository.list_appointment_rosters(
|
||||
doctor_id=1,
|
||||
start_date="2026-08-10",
|
||||
end_date="2026-08-16",
|
||||
)
|
||||
slots = repository.get_available_appointment_slots(
|
||||
doctor_id=1,
|
||||
appointment_date="2026-08-10",
|
||||
)
|
||||
|
||||
assert (
|
||||
"tcm.prescription/patchPatient",
|
||||
{
|
||||
"id": 8,
|
||||
"patient_name": "修正患者",
|
||||
"phone": "13800000000",
|
||||
"gender": 2,
|
||||
},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"tcm.prescription/audit",
|
||||
{
|
||||
"id": 8,
|
||||
"action": "reject",
|
||||
"remark": "剂量不符",
|
||||
},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"firstvisit.myPatient/assign",
|
||||
{
|
||||
"id": 5,
|
||||
"assistant_id": 20,
|
||||
"is_inherit": 1,
|
||||
},
|
||||
) in client.post_calls
|
||||
assert ("tcm.diagnosis/edit", {"id": 5, "clinical_diagnosis": "气虚证"}) in client.post_calls
|
||||
assert slots == {"slots": [{"time": "09:00", "available": True}]}
|
||||
get_endpoints = {endpoint for endpoint, _ in client.get_calls}
|
||||
assert {
|
||||
"tcm.prescriptionOrder/paidPayOrders",
|
||||
"doctor.medicine/lists",
|
||||
"firstvisit.myPatient/orders",
|
||||
"firstvisit.myPatient/progress",
|
||||
"tcm.diagnosis/readonlyDetail",
|
||||
"doctor.appointment/lists",
|
||||
"tcm.diagnosis/assignLogList",
|
||||
"doctor.roster/lists",
|
||||
"doctor.appointment/availableSlots",
|
||||
} <= get_endpoints
|
||||
|
||||
|
||||
def test_remote_transcription_endpoints_use_exact_normalized_dtos() -> None:
|
||||
"""Realtime transcript persistence stays within the three audited POST DTOs."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.start_call_transcription(501, 901, " session-1 ", language=" zh-CN ")
|
||||
repository.upsert_call_transcript_segments(
|
||||
501,
|
||||
901,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": "1200",
|
||||
"text": " patient words ",
|
||||
}
|
||||
],
|
||||
)
|
||||
repository.finish_call_transcription(
|
||||
501,
|
||||
901,
|
||||
"session-1",
|
||||
expected_segment_count=1,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/startCallTranscription",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"language": "zh-CN",
|
||||
},
|
||||
),
|
||||
(
|
||||
"tcm.diagnosis/upsertCallTranscriptSegments",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"segments": [
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "patient words",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
"tcm.diagnosis/finishCallTranscription",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"expected_segment_count": 1,
|
||||
"status": "completed",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
assert repository.start_call(501, 301) == {"call_record_id": 901}
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/startCall",
|
||||
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[None, {}, {"ok": True}, {"call_record_id": 0}, {"callRecordId": -1}, {"id": True}],
|
||||
)
|
||||
def test_remote_start_call_rejects_missing_or_invalid_record_id(response: Any) -> None:
|
||||
class StartCallClient(RecordingClient):
|
||||
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
if endpoint == "tcm.diagnosis/startCall":
|
||||
self.post_calls.append((endpoint, dict(payload or {})))
|
||||
return response
|
||||
return super().post(endpoint, payload)
|
||||
|
||||
repository = RemoteDoctorRepository(StartCallClient()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ApiProtocolError, match="call_record"):
|
||||
repository.start_call(501, 301)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe_reference",
|
||||
[r"C:\records\tongue.jpg", r"\\server\share\report.pdf", "file:///tmp/a.jpg"],
|
||||
)
|
||||
def test_remote_note_rejects_local_material_references(unsafe_reference: str) -> None:
|
||||
"""No drive, UNC or file URI can reach addDoctorNote JSON."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ValueError, match="server uri/url"):
|
||||
repository.add_doctor_note(5, tongue_images=[unsafe_reference])
|
||||
assert not any(
|
||||
endpoint == "doctor.appointment/addDoctorNote" for endpoint, _payload in client.post_calls
|
||||
)
|
||||
|
||||
|
||||
def test_remote_dynamic_menu_preserves_json_metadata_but_drops_runtime_objects() -> None:
|
||||
"""Future menu fields pass through safely without evaluating arbitrary values."""
|
||||
|
||||
repository = RemoteDoctorRepository(RecordingClient()) # type: ignore[arg-type]
|
||||
|
||||
menu = repository.get_session().menu
|
||||
|
||||
assert menu[0]["component"] == "tcm/diagnosis/index"
|
||||
assert menu[0]["future"] == {"badge": 3}
|
||||
assert menu[0]["children"][0]["perms"] == "tcm.diagnosis/readonlyDetail"
|
||||
assert "unsafe" not in menu[0]
|
||||
assert 7 not in menu[0]
|
||||
|
||||
|
||||
def test_canonical_prescription_permissions_match_routed_views() -> None:
|
||||
"""Service exports one canonical spelling for each routed action."""
|
||||
|
||||
assert PRESCRIPTION_LIBRARY_PERMISSIONS == {
|
||||
"create": "wcf.prescription/add",
|
||||
"read": "wcf.prescription/read",
|
||||
"update": "wcf.prescription/edit",
|
||||
"delete": "wcf.prescription/delete",
|
||||
"ai_reports": "tcm.prescriptionLibrary/aiReports",
|
||||
"generate_ai_reports": "tcm.prescriptionLibrary/generateAiReports",
|
||||
"edit_ai_report": "tcm.prescriptionLibrary/editAiReport",
|
||||
}
|
||||
assert PRESCRIPTION_PERMISSIONS["delete"] == "cf.prescription/del"
|
||||
assert PRESCRIPTION_PERMISSIONS["patch_patient"] == "tcm.prescription/patchPatient"
|
||||
assert DIAGNOSIS_AI_PERMISSIONS == {
|
||||
"ai_reports": "tcm.diagnosis/aiReports",
|
||||
"generate_ai_reports": "tcm.diagnosis/generateAiReports",
|
||||
"edit_ai_report": "tcm.diagnosis/editAiReport",
|
||||
"analysis": "tcm.diagnosis/aiAnalysis",
|
||||
"assistant": "tcm.diagnosis/aiAssistant",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_prescription_library_ai_report_endpoints() -> None:
|
||||
"""AI interpretation uses the same adminapi contract as the Vue library page."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
listed = repository.list_prescription_template_ai_reports(701)
|
||||
generated = repository.generate_prescription_template_ai_reports(701)
|
||||
edited = repository.edit_prescription_template_ai_report(
|
||||
701, report_id=9, content='{"summary":"演示"}'
|
||||
)
|
||||
|
||||
assert listed["prescription_id"] == 701
|
||||
assert generated["status"] == "success"
|
||||
assert edited["report"]["report_id"] == 9
|
||||
assert client.get_calls[-1] == (
|
||||
"tcm.prescriptionLibrary/aiReports",
|
||||
{"id": 701},
|
||||
)
|
||||
assert (
|
||||
"tcm.prescriptionLibrary/generateAiReports",
|
||||
{"id": 701},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"tcm.prescriptionLibrary/editAiReport",
|
||||
{"id": 701, "report_id": 9, "content": '{"summary":"演示"}'},
|
||||
) in client.post_calls
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_report_endpoints() -> None:
|
||||
"""Patient-profile AI reports use the diagnosis adminapi contract."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
listed = repository.list_diagnosis_ai_reports(501)
|
||||
generated = repository.generate_diagnosis_ai_reports(501)
|
||||
edited = repository.edit_diagnosis_ai_report(
|
||||
501, report_id=9, content='{"summary":"演示"}'
|
||||
)
|
||||
|
||||
assert listed["diagnosis_id"] == 501
|
||||
assert generated["status"] == "success"
|
||||
assert edited["report"]["report_id"] == 9
|
||||
assert client.get_calls[-1] == (
|
||||
"tcm.diagnosis/aiReports",
|
||||
{"id": 501},
|
||||
)
|
||||
assert (
|
||||
"tcm.diagnosis/generateAiReports",
|
||||
{"id": 501},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"tcm.diagnosis/editAiReport",
|
||||
{"id": 501, "report_id": 9, "content": '{"summary":"演示"}'},
|
||||
) in client.post_calls
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
|
||||
class TimeoutRecordingClient(RecordingClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.timeouts: list[float | None] = []
|
||||
|
||||
def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
self.timeouts.append(timeout)
|
||||
return super().post(endpoint, payload)
|
||||
|
||||
client = TimeoutRecordingClient()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
|
||||
result = repository.analyze_diagnosis_ai(
|
||||
501,
|
||||
"请给出用药调整建议",
|
||||
task="medication_review",
|
||||
)
|
||||
|
||||
assert result["answer"] == "服务端分析结果"
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/aiAssistant",
|
||||
{"id": 501, "prompt": "请给出用药调整建议", "task": "medication_review"},
|
||||
)
|
||||
]
|
||||
body = client.post_calls[0][1]
|
||||
assert not ({"key", "api_key", "base_url", "provider", "model"} & body.keys())
|
||||
assert client.timeouts == [105.0]
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_analysis_uses_exact_post_contract() -> None:
|
||||
"""The legacy default is qwen, followed by an explicit OpenAI request."""
|
||||
|
||||
class TimeoutRecordingClient(RecordingClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.timeouts: list[float | None] = []
|
||||
|
||||
def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
self.timeouts.append(timeout)
|
||||
return super().post(endpoint, payload)
|
||||
|
||||
client = TimeoutRecordingClient()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
|
||||
qwen_result = repository.get_diagnosis_ai_analysis(501)
|
||||
openai_result = repository.get_diagnosis_ai_analysis(501, model="openai")
|
||||
|
||||
assert client.post_calls == [
|
||||
("tcm.diagnosis/aiAnalysis", {"id": 501, "model": "qwen"}),
|
||||
("tcm.diagnosis/aiAnalysis", {"id": 501, "model": "openai"}),
|
||||
]
|
||||
assert client.timeouts == [105.0, 105.0]
|
||||
assert qwen_result == {
|
||||
"diagnosis_advice": "2 型糖尿病,血糖控制不佳",
|
||||
"risk_assessment": [
|
||||
{"label": "高血糖风险", "level": "high"},
|
||||
{"label": "心血管风险", "level": "medium"},
|
||||
],
|
||||
"treatment_advice": "复核用药依从性并安排糖化血红蛋白检查。",
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"model_name": "qwen3.6-35b",
|
||||
"generated_at": "2026-08-14 10:30:00",
|
||||
}
|
||||
assert openai_result == {
|
||||
"diagnosis_advice": "2 型糖尿病,需结合客观检查复核",
|
||||
"risk_assessment": [
|
||||
{"label": "用药安全风险", "level": "medium"},
|
||||
{"label": "肾功能风险", "level": "low"},
|
||||
],
|
||||
"treatment_advice": "复核近期检查趋势并评估联合用药安全性。",
|
||||
"model_key": "openai",
|
||||
"model_label": "OpenAI",
|
||||
"model_name": "gpt-5.2",
|
||||
"generated_at": "2026-08-14 10:31:00",
|
||||
}
|
||||
|
||||
calls_before_validation = list(client.post_calls)
|
||||
timeouts_before_validation = list(client.timeouts)
|
||||
with pytest.raises(ValueError, match="qwen or openai"):
|
||||
repository.get_diagnosis_ai_analysis(501, model="invalid") # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
repository.get_diagnosis_ai_analysis(0)
|
||||
assert client.post_calls == calls_before_validation
|
||||
assert client.timeouts == timeouts_before_validation
|
||||
|
||||
|
||||
def test_demo_diagnosis_ai_analysis_matches_structured_contract() -> None:
|
||||
repository = DemoDoctorRepository(today=date(2026, 8, 14))
|
||||
|
||||
qwen_result = repository.get_diagnosis_ai_analysis(501)
|
||||
openai_result = repository.get_diagnosis_ai_analysis(501, model="openai")
|
||||
|
||||
expected_keys = {
|
||||
"diagnosis_advice",
|
||||
"risk_assessment",
|
||||
"treatment_advice",
|
||||
"model_key",
|
||||
"model_label",
|
||||
"model_name",
|
||||
"generated_at",
|
||||
}
|
||||
for result in (qwen_result, openai_result):
|
||||
assert set(result) == expected_keys
|
||||
assert "肝郁脾虚证" in result["diagnosis_advice"]
|
||||
assert result["treatment_advice"]
|
||||
assert result["risk_assessment"]
|
||||
assert all(
|
||||
set(item) == {"label", "level"}
|
||||
and item["level"] in {"high", "medium", "low"}
|
||||
for item in result["risk_assessment"]
|
||||
)
|
||||
assert (qwen_result["model_key"], qwen_result["model_label"]) == ("qwen", "千问")
|
||||
assert (openai_result["model_key"], openai_result["model_label"]) == (
|
||||
"openai",
|
||||
"OpenAI",
|
||||
)
|
||||
assert qwen_result["model_name"] != openai_result["model_name"]
|
||||
assert qwen_result["diagnosis_advice"] != openai_result["diagnosis_advice"]
|
||||
assert qwen_result["treatment_advice"] != openai_result["treatment_advice"]
|
||||
|
||||
|
||||
def test_demo_mutates_prescriptions_orders_and_patient_workspaces() -> None:
|
||||
"""Offline mode supports the full workflow rather than static placeholders."""
|
||||
|
||||
repository = DemoDoctorRepository(today=date(2026, 8, 10))
|
||||
created = repository.create_prescription(
|
||||
diagnosis_id=501,
|
||||
patient_name="林晓岚",
|
||||
phone="13800131203",
|
||||
gender=2,
|
||||
herbs=[{"medicine_id": 17, "name": "黄芪", "dosage": 20}],
|
||||
doctor_name="陈医生(演示)",
|
||||
doctor_signature="data:image/png;base64,demo",
|
||||
)
|
||||
updated = repository.update_prescription(created.id, {"clinical_diagnosis": "气虚证"})
|
||||
repository.patch_prescription_patient(
|
||||
created.id, patient_name="林晓岚(修正)", phone="13800131203", gender=2
|
||||
)
|
||||
audit = repository.audit_prescription(created.id, action="approve")
|
||||
order = repository.create_prescription_order(
|
||||
prescription_id=created.id,
|
||||
diagnosis_id=501,
|
||||
recipient_name="林晓岚(修正)",
|
||||
recipient_phone="13800131203",
|
||||
amount=268,
|
||||
)
|
||||
|
||||
assert updated.clinical_diagnosis == "气虚证"
|
||||
assert audit["audit_status"] == 1
|
||||
assert repository.get_prescription(created.id).has_prescription_order
|
||||
assert repository.get_prescription_order(order["id"])["prescription_id"] == created.id
|
||||
assert repository.patient_orders().extend["summary"]["order_count"] == 2
|
||||
|
||||
assigned = repository.assign_patient(501, 2002)
|
||||
repository.fill_patient_id_card(501, "410000199001010000")
|
||||
appointment = repository.book_patient_appointment(
|
||||
diagnosis_id=501,
|
||||
appointment_date="2026-08-11",
|
||||
appointment_time="15:00-15:30",
|
||||
)
|
||||
repository.update_diagnosis(501, {"chief_complaint": "乏力"})
|
||||
|
||||
assert assigned["assistant_name"] == "许医助"
|
||||
assert repository.assign_history(501).total == 2
|
||||
assert repository.patient_detail(501)["diagnosis"]["chief_complaint"] == "乏力"
|
||||
assert repository.appointment_history(501).total == 2
|
||||
repository.cancel_patient_appointment(appointment["id"])
|
||||
assert repository.appointment_history(501).items[-1].status == 2
|
||||
assert repository.list_medicines(name="黄芪").items[0]["name"] == "黄芪"
|
||||
|
||||
|
||||
def test_reject_actions_require_a_remark() -> None:
|
||||
"""Audit rejection mirrors the admin dialog's mandatory reason boundary."""
|
||||
|
||||
repository = DemoDoctorRepository(today=date(2026, 8, 10))
|
||||
|
||||
with pytest.raises(ValueError, match="remark"):
|
||||
repository.audit_prescription(802, action="reject")
|
||||
with pytest.raises(ValueError, match="remark"):
|
||||
repository.audit_patient_order_payment(901, "reject")
|
||||
Reference in New Issue
Block a user