1092 lines
38 KiB
Python
1092 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
import pytest
|
|
from PySide6.QtWidgets import (
|
|
QApplication,
|
|
QLabel,
|
|
QLineEdit,
|
|
QPushButton,
|
|
QScrollArea,
|
|
QTextBrowser,
|
|
QTextEdit,
|
|
QWidget,
|
|
)
|
|
|
|
from doctor_workstation.core import PermissionSet
|
|
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
|
from doctor_workstation.ui.dialogs import prescription as prescription_module
|
|
from doctor_workstation.ui.dialogs.ai_consult import AiConsultDialog
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def application() -> QApplication:
|
|
return QApplication.instance() or QApplication([])
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def offline_thumbnails(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""离线传输:缩略图停在 loading 状态,工作站用例永不真正联网。"""
|
|
|
|
def hold(self: Any, source: str) -> None:
|
|
self._source = str(source).strip()
|
|
self._invalidate_request()
|
|
self.setToolTip(self._source)
|
|
self._show_loading()
|
|
|
|
monkeypatch.setattr(ai_consult_module._RemoteImageButton, "load_url", hold)
|
|
|
|
|
|
@pytest.fixture
|
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def run_immediately(
|
|
function: Callable[..., Any],
|
|
*args: Any,
|
|
on_success: Callable[[Any], Any] | None = None,
|
|
on_error: Callable[[Exception], Any] | None = None,
|
|
on_finished: Callable[[], Any] | None = None,
|
|
**_kwargs: Any,
|
|
) -> object:
|
|
try:
|
|
result = function(*args)
|
|
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(ai_consult_module, "run_async", run_immediately)
|
|
|
|
|
|
class DeferredAsync:
|
|
def __init__(self) -> None:
|
|
self.pending: list[dict[str, Any]] = []
|
|
|
|
def __call__(
|
|
self,
|
|
function: Callable[..., Any],
|
|
*args: Any,
|
|
on_success: Callable[[Any], Any] | None = None,
|
|
on_error: Callable[[Exception], Any] | None = None,
|
|
on_finished: Callable[[], Any] | None = None,
|
|
**_kwargs: Any,
|
|
) -> object:
|
|
self.pending.append(
|
|
{
|
|
"function": function,
|
|
"args": args,
|
|
"on_success": on_success,
|
|
"on_error": on_error,
|
|
"on_finished": on_finished,
|
|
}
|
|
)
|
|
return object()
|
|
|
|
def complete(self, index: int) -> None:
|
|
pending = self.pending[index]
|
|
try:
|
|
result = pending["function"](*pending["args"])
|
|
except Exception as error:
|
|
if pending["on_error"]:
|
|
pending["on_error"](error)
|
|
else:
|
|
if pending["on_success"]:
|
|
pending["on_success"](result)
|
|
finally:
|
|
if pending["on_finished"]:
|
|
pending["on_finished"]()
|
|
|
|
|
|
def _detail(diagnosis_id: int, marker: str) -> dict[str, Any]:
|
|
diagnosis = {
|
|
"id": diagnosis_id,
|
|
"patient_id": diagnosis_id + 1000,
|
|
"patient_name": f"{marker}患者",
|
|
"phone": "13800138000",
|
|
"id_card": "110105199203071234",
|
|
"gender": 0,
|
|
"age": 34,
|
|
"region": f"{marker}杭州",
|
|
"address": f"{marker}健康路 8 号",
|
|
"height": 162,
|
|
"weight": 54.5,
|
|
"bmi": 20.8,
|
|
"systolic_pressure": 146,
|
|
"diastolic_pressure": 92,
|
|
"fasting_blood_sugar": 8.2,
|
|
"chief_complaint": f"{marker}主诉口渴乏力",
|
|
"present_illness": f"{marker}现病史半年血糖波动",
|
|
"past_history": f"{marker}既往高血压五年",
|
|
"allergy_history": f"{marker}青霉素过敏",
|
|
"family_history": f"{marker}父亲糖尿病",
|
|
"clinical_diagnosis": f"{marker}气阴两虚",
|
|
"diabetes_discovery_year": 6,
|
|
"current_medications": [f"{marker}二甲双胍", "阿卡波糖"],
|
|
"smoking": "不吸烟",
|
|
"sleep_condition": [f"{marker}易醒", "多梦"],
|
|
"local_hospital_diagnosis": [f"{marker}2 型糖尿病", "高血压"],
|
|
"diet_condition": [f"{marker}偏甜", "夜宵"],
|
|
"body_feeling": [f"{marker}乏力", "四肢沉重"],
|
|
"tongue": f"{marker}舌淡红",
|
|
"tongue_coating": f"{marker}苔薄白",
|
|
"pulse": f"{marker}脉细",
|
|
"remark": f"{marker}继续监测",
|
|
"latest_prescription_order": {
|
|
"id": f"{marker}-RX-09",
|
|
"status_text": "待配药",
|
|
},
|
|
}
|
|
for index in range(12):
|
|
diagnosis[f"custom_field_{index}"] = f"{marker}扩展病历字段 {index}"
|
|
return {
|
|
"diagnosis": diagnosis,
|
|
"patient": {
|
|
"id": diagnosis_id + 1000,
|
|
"patient_name": f"{marker}患者",
|
|
"phone": "13800138000",
|
|
"id_card": "110105199203071234",
|
|
"gender": 0,
|
|
"age": 34,
|
|
"region": f"{marker}杭州",
|
|
"address": f"{marker}健康路 8 号",
|
|
},
|
|
"appointment": {"doctor_name": f"{marker}陈医生"},
|
|
}
|
|
|
|
|
|
class WorkspaceRepository:
|
|
def __init__(self, *, include_foreign_rows: bool = True) -> None:
|
|
self.details = {501: _detail(501, "甲"), 502: _detail(502, "乙")}
|
|
self.include_foreign_rows = include_foreign_rows
|
|
self.failures: set[tuple[str, int]] = set()
|
|
self.calls: list[tuple[str, int]] = []
|
|
self.prescription_detail_calls: list[int] = []
|
|
self.prescription_overrides: dict[int, dict[str, Any]] = {}
|
|
self.report_payload: Any = []
|
|
|
|
def _check(self, name: str, diagnosis_id: int) -> None:
|
|
self.calls.append((name, diagnosis_id))
|
|
if (name, diagnosis_id) in self.failures:
|
|
raise RuntimeError(f"{name} 暂时不可用")
|
|
|
|
def get_diagnosis_detail(
|
|
self,
|
|
diagnosis_id: int,
|
|
*,
|
|
readonly: bool = False,
|
|
) -> dict[str, Any]:
|
|
del readonly
|
|
self._check("get_diagnosis_detail", diagnosis_id)
|
|
return self.details[diagnosis_id]
|
|
|
|
def list_im_chat_messages(
|
|
self,
|
|
diagnosis_id: int,
|
|
*,
|
|
only_archived: bool = True,
|
|
) -> list[dict[str, Any]]:
|
|
del only_archived
|
|
self._check("list_im_chat_messages", diagnosis_id)
|
|
return []
|
|
|
|
def list_patient_ai_reports(self, patient_id: int) -> Any:
|
|
self.calls.append(("list_patient_ai_reports", patient_id))
|
|
return self.report_payload
|
|
|
|
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
|
self._check("get_doctor_notes", diagnosis_id)
|
|
marker = "甲" if diagnosis_id == 501 else "乙"
|
|
rows = [
|
|
{
|
|
"id": diagnosis_id * 10 + 1,
|
|
"diagnosis_id": diagnosis_id,
|
|
"create_time": "2026-08-18 09:20",
|
|
"content": f"{marker}医生检查记录",
|
|
"tongue_images": [
|
|
{
|
|
"name": f"{marker}舌苔照片.jpg",
|
|
"url": f"https://media.example.invalid/{marker}/tongue.jpg",
|
|
}
|
|
],
|
|
"report_files": [
|
|
{
|
|
"name": f"{marker}血糖报告.pdf",
|
|
"url": f"https://media.example.invalid/{marker}/report.pdf",
|
|
},
|
|
{
|
|
"name": f"{marker}本地危险附件.pdf",
|
|
"url": "file:///C:/private/unsafe.pdf",
|
|
},
|
|
],
|
|
}
|
|
]
|
|
if self.include_foreign_rows:
|
|
rows.append(
|
|
{
|
|
"id": 99901,
|
|
"diagnosis_id": 999,
|
|
"content": "错误诊单附件哨兵",
|
|
"tongue_images": [
|
|
{
|
|
"name": "错误诊单舌苔.jpg",
|
|
"url": "https://media.example.invalid/wrong.jpg",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
return rows
|
|
|
|
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
|
|
self._check("get_tracking_window", diagnosis_id)
|
|
marker = "甲" if diagnosis_id == 501 else "乙"
|
|
blood_records = [
|
|
{
|
|
"id": diagnosis_id * 10 + 2,
|
|
"diagnosis_id": diagnosis_id,
|
|
"record_date": "2026-08-18",
|
|
"fasting_blood_sugar": f"{marker}8.2",
|
|
"postprandial_blood_sugar": f"{marker}12.4",
|
|
"systolic_pressure": f"{marker}146",
|
|
"diastolic_pressure": f"{marker}92",
|
|
}
|
|
]
|
|
if self.include_foreign_rows:
|
|
blood_records.append(
|
|
{
|
|
"id": 99902,
|
|
"diagnosis_id": 999,
|
|
"record_date": "2026-08-18",
|
|
"fasting_blood_sugar": "错误诊单血糖 19.9",
|
|
}
|
|
)
|
|
return {
|
|
"diagnosis_id": diagnosis_id,
|
|
"blood_records": blood_records,
|
|
"diet_records": [
|
|
{
|
|
"id": diagnosis_id * 10 + 3,
|
|
"diagnosis_id": diagnosis_id,
|
|
"record_date": "2026-08-18",
|
|
"breakfast_foods": f"{marker}燕麦鸡蛋",
|
|
"lunch_foods": f"{marker}杂粮饭",
|
|
}
|
|
],
|
|
"exercise_records": [
|
|
{
|
|
"id": diagnosis_id * 10 + 4,
|
|
"diagnosis_id": diagnosis_id,
|
|
"record_date": "2026-08-17",
|
|
"exercise_type": f"{marker}散步",
|
|
"duration": 35,
|
|
}
|
|
],
|
|
}
|
|
|
|
def list_prescriptions_by_diagnosis(
|
|
self,
|
|
diagnosis_id: int,
|
|
) -> list[dict[str, Any]]:
|
|
self._check("list_prescriptions_by_diagnosis", diagnosis_id)
|
|
marker = "甲" if diagnosis_id == 501 else "乙"
|
|
return [
|
|
{
|
|
"id": diagnosis_id * 10 + index,
|
|
"diagnosis_id": diagnosis_id,
|
|
"sn": f"{marker}-RX-{index}",
|
|
"prescription_date": f"2026-08-{10 + index}",
|
|
"prescription_summary": f"{marker}方剂 {index}",
|
|
"doctor_name": f"{marker}陈医生",
|
|
"status_text": "已审核",
|
|
"herbs": [{"name": f"{marker}黄芪", "dosage": index * 5, "unit": "g"}],
|
|
}
|
|
for index in range(1, 4)
|
|
]
|
|
|
|
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
|
|
self.prescription_detail_calls.append(prescription_id)
|
|
if prescription_id in self.prescription_overrides:
|
|
return self.prescription_overrides[prescription_id]
|
|
diagnosis_id = prescription_id // 10
|
|
return {
|
|
"id": prescription_id,
|
|
"diagnosis_id": diagnosis_id,
|
|
"sn": f"FULL-{prescription_id}",
|
|
"clinical_diagnosis": "气阴两虚",
|
|
"herbs": [{"name": "黄芪", "dosage": 15, "unit": "g"}],
|
|
}
|
|
|
|
|
|
def _permissions() -> PermissionSet:
|
|
return PermissionSet(["tcm.diagnosis/aiAssistant", "cf.prescription/read"])
|
|
|
|
|
|
def _pane_text(widget: QWidget) -> str:
|
|
parts = [child.text() for child in widget.findChildren(QLabel)]
|
|
parts.extend(child.text() for child in widget.findChildren(QPushButton))
|
|
parts.extend(child.text() for child in widget.findChildren(QLineEdit))
|
|
parts.extend(child.toPlainText() for child in widget.findChildren(QTextBrowser))
|
|
parts.extend(child.toPlainText() for child in widget.findChildren(QTextEdit))
|
|
return "\n".join(part for part in parts if part)
|
|
|
|
|
|
def _open_dialog(
|
|
application: QApplication,
|
|
repository: WorkspaceRepository,
|
|
diagnosis_id: int = 501,
|
|
) -> AiConsultDialog:
|
|
dialog = AiConsultDialog(repository, _permissions())
|
|
dialog.open_for(diagnosis_id=diagnosis_id, patient_id=diagnosis_id + 1000)
|
|
dialog.show()
|
|
application.processEvents()
|
|
return dialog
|
|
|
|
|
|
def test_case_tab_renders_complete_owned_detail_as_readable_chinese(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
dialog = _open_dialog(application, WorkspaceRepository())
|
|
pane = dialog.records["病历资料"]
|
|
dialog.tabs.setCurrentIndex(1)
|
|
application.processEvents()
|
|
|
|
assert pane.findChild(QWidget, "AiConsultCaseGrid") is not None
|
|
text = _pane_text(pane)
|
|
for sentinel in (
|
|
"甲主诉口渴乏力",
|
|
"甲现病史半年血糖波动",
|
|
"甲既往高血压五年",
|
|
"甲青霉素过敏",
|
|
"甲父亲糖尿病",
|
|
"甲气阴两虚",
|
|
"甲2 型糖尿病",
|
|
"高血压",
|
|
"甲偏甜",
|
|
"夜宵",
|
|
"甲-RX-09",
|
|
"待配药",
|
|
):
|
|
assert sentinel in text
|
|
assert "['" not in text
|
|
assert "{'" not in text
|
|
|
|
scroll = pane.findChild(QScrollArea)
|
|
assert scroll is not None and scroll.widgetResizable()
|
|
assert pane.geometry().isValid() and scroll.viewport().geometry().isValid()
|
|
assert scroll.verticalScrollBar().maximum() > 0
|
|
dialog.close()
|
|
|
|
|
|
def test_all_four_record_tabs_use_the_selected_diagnosis_id(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
repository = WorkspaceRepository(include_foreign_rows=False)
|
|
dialog = _open_dialog(application, repository, diagnosis_id=501)
|
|
|
|
for method in (
|
|
"get_diagnosis_detail",
|
|
"get_doctor_notes",
|
|
"get_tracking_window",
|
|
"list_prescriptions_by_diagnosis",
|
|
):
|
|
assert (method, 501) in repository.calls
|
|
assert all(
|
|
called_id == 501
|
|
for called_method, called_id in repository.calls
|
|
if called_method == method
|
|
)
|
|
assert ("list_patient_ai_reports", 1501) in repository.calls
|
|
dialog.close()
|
|
|
|
|
|
def test_seed_cannot_replace_the_authoritative_patient_id(
|
|
application: QApplication,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
deferred = DeferredAsync()
|
|
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
|
|
repository = WorkspaceRepository(include_foreign_rows=False)
|
|
dialog = AiConsultDialog(repository, _permissions())
|
|
|
|
dialog.open_for(
|
|
diagnosis_id=501,
|
|
patient_id=1501,
|
|
seed={"patient_id": 501, "patient_name": "错误种子"},
|
|
)
|
|
|
|
assert dialog.patient_id == 1501
|
|
deferred.complete(0)
|
|
application.processEvents()
|
|
assert dialog.patient_id == 1501
|
|
assert ("list_patient_ai_reports", 1501) in repository.calls
|
|
dialog.close()
|
|
|
|
|
|
def test_detail_failure_or_wrong_owner_never_requests_patient_reports(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
failed_repository = WorkspaceRepository(include_foreign_rows=False)
|
|
failed_repository.failures.add(("get_diagnosis_detail", 501))
|
|
failed = _open_dialog(application, failed_repository)
|
|
assert all(
|
|
method != "list_patient_ai_reports"
|
|
for method, _owner in failed_repository.calls
|
|
)
|
|
failed.close()
|
|
|
|
wrong_repository = WorkspaceRepository(include_foreign_rows=False)
|
|
wrong_repository.details[501] = _detail(999, "越权")
|
|
wrong = _open_dialog(application, wrong_repository)
|
|
assert all(
|
|
method != "list_patient_ai_reports"
|
|
for method, _owner in wrong_repository.calls
|
|
)
|
|
wrong.close()
|
|
|
|
|
|
def test_patient_report_response_owner_must_match_exactly(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
repository = WorkspaceRepository(include_foreign_rows=False)
|
|
repository.report_payload = {
|
|
"patient_id": "1501",
|
|
"reports": [
|
|
{
|
|
"patient_id": 1501,
|
|
"report": {"diagnosis": "不应显示的越权报告"},
|
|
}
|
|
],
|
|
}
|
|
dialog = _open_dialog(application, repository)
|
|
|
|
assert ("list_patient_ai_reports", 1501) in repository.calls
|
|
assert "不应显示的越权报告" not in _pane_text(dialog)
|
|
assert not ai_consult_module._report_response_matches_patient(
|
|
repository.report_payload,
|
|
1501,
|
|
)
|
|
dialog.close()
|
|
|
|
|
|
def test_exam_tab_previews_images_in_app_and_blocks_file_urls(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
opened: list[str] = []
|
|
previews: list[tuple[tuple[str, ...], int]] = []
|
|
monkeypatch.setattr(
|
|
ai_consult_module,
|
|
"open_safe_http_url",
|
|
lambda target: opened.append(target) or True,
|
|
)
|
|
|
|
class RecordingPreviewDialog(QWidget):
|
|
def __init__(
|
|
self,
|
|
sources: Any,
|
|
*,
|
|
index: int = 0,
|
|
names: Any = None,
|
|
title: str = "",
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
super().__init__(parent)
|
|
previews.append((tuple(sources), int(index)))
|
|
|
|
monkeypatch.setattr(ai_consult_module, "ImagePreviewDialog", RecordingPreviewDialog)
|
|
dialog = _open_dialog(application, WorkspaceRepository())
|
|
pane = dialog.records["检查检验"]
|
|
dialog.tabs.setCurrentIndex(2)
|
|
application.processEvents()
|
|
|
|
assert pane.findChild(QWidget, "AiConsultExamTimeline") is not None
|
|
text = _pane_text(pane)
|
|
assert "甲舌苔照片.jpg" in text
|
|
assert "甲血糖报告.pdf" in text
|
|
assert "甲本地危险附件.pdf" in text
|
|
assert "错误诊单附件哨兵" not in text
|
|
assert "错误诊单舌苔.jpg" not in text
|
|
|
|
buttons = pane.findChildren(QPushButton, "AiConsultMediaOpen")
|
|
assert len(buttons) == 3
|
|
thumbnails = pane.findChildren(QPushButton, "AiConsultTongueThumb")
|
|
assert len(thumbnails) == 1
|
|
assert thumbnails[0].isEnabled()
|
|
assert thumbnails[0].accessibleName() == "舌苔图片点击查看"
|
|
assert thumbnails[0].property("loadState") == "loading"
|
|
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
|
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
|
image_button = next(button for button in buttons if "甲舌苔照片.jpg" in button.text())
|
|
assert image_button.text().endswith("· 预览")
|
|
report_button = next(button for button in buttons if "甲血糖报告.pdf" in button.text())
|
|
assert report_button.text().endswith("· 打开")
|
|
unsafe = next(button for button in buttons if "本地危险附件" in button.text())
|
|
assert not unsafe.isEnabled()
|
|
|
|
for button in buttons:
|
|
button.click()
|
|
thumbnails[0].click()
|
|
# 图片留在工作站内预览,只有非图片附件才交给系统打开。
|
|
assert opened == ["https://media.example.invalid/甲/report.pdf"]
|
|
assert previews == [(("https://media.example.invalid/甲/tongue.jpg",), 0)] * 2
|
|
dialog.close()
|
|
|
|
|
|
class ProductionShapeRepository(WorkspaceRepository):
|
|
"""按线上 readonlyDetail 的真实返回构造:既有 code,也有后端补的 *_text。"""
|
|
|
|
def get_diagnosis_detail(
|
|
self,
|
|
diagnosis_id: int,
|
|
*,
|
|
readonly: bool = False,
|
|
) -> dict[str, Any]:
|
|
detail = dict(super().get_diagnosis_detail(diagnosis_id, readonly=readonly))
|
|
diagnosis = dict(detail["diagnosis"])
|
|
diagnosis.update(
|
|
{
|
|
"gender": 1,
|
|
"gender_text": "男",
|
|
"diagnosis_type": "follow_up",
|
|
"diagnosis_type_text": "复诊",
|
|
"eye_condition": "blurred,dry",
|
|
"eye_condition_text": "模糊、干涩",
|
|
"skin_condition": "dry,itching",
|
|
"skin_condition_text": "干燥、瘙痒",
|
|
"urine_condition": "yellow_urine",
|
|
"urine_condition_text": "尿黄",
|
|
"fatty_liver_degree": "mild",
|
|
"fatty_liver_degree_text": "轻度",
|
|
"past_history": "hypertension",
|
|
"past_history_text": "高血压",
|
|
"trauma_history": 0,
|
|
"trauma_history_text": "无",
|
|
# 诊单表里的技术列:医生页面不应出现这些英文列名。
|
|
"status": 1,
|
|
"show_card": 1,
|
|
"revisit_slot_start_offset": 0,
|
|
"delete_time": None,
|
|
"assistant_id": 131,
|
|
"assign_read_at": 1787882294,
|
|
"shipped_non_er_assistant_cleared_at": 0,
|
|
"external_userid": "",
|
|
"is_view": 0,
|
|
"create_time": 1787882294,
|
|
"update_time": 1787882303,
|
|
"tongue_images": [
|
|
"https://media.example.invalid/11702/a.jpg",
|
|
"https://media.example.invalid/11702/b.jpg",
|
|
],
|
|
}
|
|
)
|
|
detail["diagnosis"] = diagnosis
|
|
return detail
|
|
|
|
|
|
def test_case_tab_hides_raw_columns_and_never_shows_text_mirror_fields(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
dialog = _open_dialog(application, ProductionShapeRepository())
|
|
dialog.tabs.setCurrentIndex(1)
|
|
application.processEvents()
|
|
text = _pane_text(dialog.records["病历资料"])
|
|
|
|
# 后端补的 *_text 只用来取值,不再作为独立英文字段列出来。
|
|
for mirror in (
|
|
"diagnosis type text",
|
|
"eye condition text",
|
|
"gender text",
|
|
"past history text",
|
|
):
|
|
assert mirror not in text
|
|
# 技术列不再泄漏英文列名。
|
|
for internal in (
|
|
"assign read at",
|
|
"shipped non er assistant cleared at",
|
|
"external userid",
|
|
"is view",
|
|
"assistant id",
|
|
"show card",
|
|
"revisit slot start offset",
|
|
"delete time",
|
|
):
|
|
assert internal not in text
|
|
# 有中文名的字段照常显示,取的是后端翻译过的值。
|
|
assert "复诊" in text
|
|
assert "模糊、干涩" in text
|
|
assert "1787882294" not in text
|
|
assert "https://media.example.invalid/11702/a.jpg" not in text
|
|
assert len(dialog.records["病历资料"].findChildren(QPushButton, "AiConsultTongueThumb")) == 2
|
|
dialog.close()
|
|
|
|
|
|
class RawCodeRepository(WorkspaceRepository):
|
|
"""只读接口偶尔缺少 `*_text`(历史数据 / 快照),此时必须自己翻译字典 code。"""
|
|
|
|
def get_diagnosis_detail(
|
|
self,
|
|
diagnosis_id: int,
|
|
*,
|
|
readonly: bool = False,
|
|
) -> dict[str, Any]:
|
|
detail = dict(super().get_diagnosis_detail(diagnosis_id, readonly=readonly))
|
|
diagnosis = dict(detail["diagnosis"])
|
|
diagnosis.update(
|
|
{
|
|
"gender": 1,
|
|
"diagnosis_type": "follow_up",
|
|
"appetite": "dry,bitter",
|
|
"weight_change": "lose_10_jin",
|
|
"fatty_liver_degree": "mild",
|
|
"allergy_history": 0,
|
|
"status": 1,
|
|
"show_card": 1,
|
|
"revisit_slot_start_offset": 0,
|
|
"delete_time": None,
|
|
"create_source": "admin",
|
|
"create_time": 1783838927,
|
|
"tongue_images": ["https://media.example.invalid/501/tongue-raw.jpg"],
|
|
}
|
|
)
|
|
detail["diagnosis"] = diagnosis
|
|
patient = dict(detail.get("patient") or {})
|
|
patient.update({"gender": 1, "marital_status": 1})
|
|
detail["patient"] = patient
|
|
return detail
|
|
|
|
|
|
def test_case_and_health_tabs_translate_codes_and_hide_internal_columns(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
dialog = _open_dialog(application, RawCodeRepository())
|
|
dialog.tabs.setCurrentIndex(1)
|
|
application.processEvents()
|
|
case_text = _pane_text(dialog.records["病历资料"])
|
|
|
|
# 字典 code、性别与是否类枚举、时间戳都按后台只读页的口径显示。
|
|
assert "干、苦" in case_text
|
|
assert "瘦10斤" in case_text
|
|
assert "轻度" in case_text
|
|
assert "复诊" in case_text
|
|
assert "后台创建" in case_text
|
|
assert "dry,bitter" not in case_text
|
|
assert "lose_10_jin" not in case_text
|
|
assert "follow_up" not in case_text
|
|
assert "1783838927" not in case_text
|
|
# 纯内部列不再泄漏给医生。
|
|
for internal in ("show card", "revisit slot start offset", "delete time"):
|
|
assert internal not in case_text
|
|
# 舌象附件渲染成缩略图,不再是一长串 URL 文本。
|
|
assert "https://media.example.invalid/501/tongue-raw.jpg" not in case_text
|
|
thumbnails = dialog.records["病历资料"].findChildren(QPushButton, "AiConsultTongueThumb")
|
|
assert len(thumbnails) == 1
|
|
|
|
dialog.tabs.setCurrentIndex(4)
|
|
application.processEvents()
|
|
health_text = _pane_text(dialog.records["健康档案"])
|
|
assert "性别\n男" in health_text
|
|
assert "过敏史\n无" in health_text
|
|
assert "162 cm" in health_text
|
|
assert dialog.records["健康档案"].findChildren(QPushButton, "AiConsultTongueThumb")
|
|
dialog.close()
|
|
|
|
|
|
def test_tongue_thumbnails_load_safe_http_sources_and_skip_unsafe_schemes(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
requested: list[str] = []
|
|
|
|
class RecordingRemoteImageButton(QPushButton):
|
|
def __init__(self, source: str, **kwargs: Any) -> None:
|
|
super().__init__(kwargs.get("parent"))
|
|
requested.append(source)
|
|
self.setObjectName(str(kwargs.get("object_name") or ""))
|
|
self.setAccessibleName(
|
|
str(kwargs.get("fallback_text") or "").replace("\n", "")
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
ai_consult_module,
|
|
"_RemoteImageButton",
|
|
RecordingRemoteImageButton,
|
|
)
|
|
|
|
dialog = _open_dialog(application, WorkspaceRepository())
|
|
# 舌象照片按字段直接渲染,PDF 报告与 file:// 附件不会发起任何图片请求。
|
|
assert requested == ["https://media.example.invalid/甲/tongue.jpg"]
|
|
assert not ai_consult_module._previewable_attachment(
|
|
"tongue_images",
|
|
"file:///C:/private/tongue.jpg",
|
|
)
|
|
assert not ai_consult_module._previewable_attachment(
|
|
"report_files",
|
|
"https://media.example.invalid/甲/report.pdf",
|
|
)
|
|
assert ai_consult_module._previewable_attachment(
|
|
"report_files",
|
|
"https://media.example.invalid/甲/report.PNG",
|
|
)
|
|
dialog.close()
|
|
|
|
|
|
def test_three_prescription_cards_open_exact_details_and_reject_wrong_or_late_ids(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository = WorkspaceRepository(include_foreign_rows=False)
|
|
opened: list[int] = []
|
|
|
|
class FakePrescriptionDetailDialog:
|
|
def __init__(self, prescription: Any, **_kwargs: Any) -> None:
|
|
self.prescription = prescription
|
|
|
|
def exec(self) -> None:
|
|
opened.append(int(self.prescription["id"]))
|
|
|
|
monkeypatch.setattr(
|
|
prescription_module,
|
|
"PrescriptionDetailDialog",
|
|
FakePrescriptionDetailDialog,
|
|
)
|
|
dialog = _open_dialog(application, repository)
|
|
pane = dialog.records["处方记录"]
|
|
dialog.tabs.setCurrentIndex(3)
|
|
application.processEvents()
|
|
|
|
cards = pane.findChildren(QWidget, "AiConsultPrescriptionCard")
|
|
buttons = sorted(
|
|
pane.findChildren(QPushButton, "AiConsultPrescriptionOpen"),
|
|
key=lambda button: int(button.property("prescriptionId")),
|
|
)
|
|
expected_ids = [5011, 5012, 5013]
|
|
assert len(cards) == len(buttons) == 3
|
|
assert [int(button.property("prescriptionId")) for button in buttons] == expected_ids
|
|
assert all(button.text() == "查看详情" for button in buttons)
|
|
for button in buttons:
|
|
button.click()
|
|
assert repository.prescription_detail_calls == expected_ids
|
|
assert opened == expected_ids
|
|
|
|
repository.prescription_overrides[5011] = {
|
|
"id": 9999,
|
|
"diagnosis_id": 501,
|
|
}
|
|
buttons[0].click()
|
|
assert repository.prescription_detail_calls[-1] == 5011
|
|
assert opened == expected_ids
|
|
repository.prescription_overrides.pop(5011)
|
|
|
|
repository.prescription_overrides[5011] = {"id": 5011}
|
|
buttons[0].click()
|
|
assert repository.prescription_detail_calls[-1] == 5011
|
|
assert opened == expected_ids
|
|
repository.prescription_overrides.pop(5011)
|
|
|
|
calls_before_unowned_source = list(repository.prescription_detail_calls)
|
|
dialog._open_prescription_detail({"id": 5011})
|
|
assert repository.prescription_detail_calls == calls_before_unowned_source
|
|
|
|
deferred = DeferredAsync()
|
|
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
|
|
buttons[0].click()
|
|
buttons[1].click()
|
|
assert len(deferred.pending) == 2
|
|
deferred.complete(1)
|
|
application.processEvents()
|
|
deferred.complete(0)
|
|
application.processEvents()
|
|
assert repository.prescription_detail_calls[-2:] == [5012, 5011]
|
|
assert opened == [*expected_ids, 5012]
|
|
dialog.close()
|
|
|
|
|
|
def test_health_tab_masks_sensitive_patient_data_and_renders_tracking_window(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
dialog = _open_dialog(application, WorkspaceRepository())
|
|
pane = dialog.records["健康档案"]
|
|
dialog.tabs.setCurrentIndex(4)
|
|
application.processEvents()
|
|
|
|
assert pane.findChild(QWidget, "AiConsultHealthGrid") is not None
|
|
text = _pane_text(pane)
|
|
for sentinel in (
|
|
"甲患者",
|
|
"甲杭州",
|
|
"甲健康路 8 号",
|
|
"138****8000",
|
|
"110***********1234",
|
|
"甲8.2",
|
|
"甲12.4",
|
|
"甲146",
|
|
"甲92",
|
|
"甲燕麦鸡蛋",
|
|
"甲杂粮饭",
|
|
"甲散步",
|
|
"35",
|
|
"甲气阴两虚",
|
|
"甲二甲双胍",
|
|
"阿卡波糖",
|
|
"不吸烟",
|
|
"甲易醒",
|
|
"多梦",
|
|
):
|
|
assert sentinel in text
|
|
assert pane.findChild(QWidget, "AiConsultDiagnosisHealthSummary") is not None
|
|
assert "13800138000" not in text
|
|
assert "110105199203071234" not in text
|
|
assert "错误诊单血糖 19.9" not in text
|
|
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
|
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
|
dialog.close()
|
|
|
|
|
|
def test_ownerless_notes_prescriptions_and_tracking_rows_fail_closed_as_warning(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
class OwnerlessRepository(WorkspaceRepository):
|
|
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
|
rows = super().get_doctor_notes(diagnosis_id)
|
|
for row in rows:
|
|
row.pop("diagnosis_id", None)
|
|
return rows
|
|
|
|
def list_prescriptions_by_diagnosis(
|
|
self,
|
|
diagnosis_id: int,
|
|
) -> list[dict[str, Any]]:
|
|
rows = super().list_prescriptions_by_diagnosis(diagnosis_id)
|
|
for row in rows:
|
|
row.pop("diagnosis_id", None)
|
|
return rows
|
|
|
|
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
|
|
result = super().get_tracking_window(diagnosis_id)
|
|
for key in ("blood_records", "diet_records", "exercise_records"):
|
|
for row in result[key]:
|
|
row.pop("diagnosis_id", None)
|
|
return result
|
|
|
|
dialog = _open_dialog(
|
|
application,
|
|
OwnerlessRepository(include_foreign_rows=False),
|
|
)
|
|
exam = dialog.records["检查检验"]
|
|
prescriptions = dialog.records["处方记录"]
|
|
health = dialog.records["健康档案"]
|
|
|
|
assert "甲医生检查记录" not in _pane_text(exam)
|
|
assert not prescriptions.findChildren(QWidget, "AiConsultPrescriptionCard")
|
|
assert "甲燕麦鸡蛋" not in _pane_text(health)
|
|
for pane in (exam, prescriptions, health):
|
|
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
|
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
|
dialog.close()
|
|
|
|
|
|
def test_ownerless_im_messages_fail_closed_before_chat_rendering(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
class OwnerlessMessageRepository(WorkspaceRepository):
|
|
def list_im_chat_messages(
|
|
self,
|
|
diagnosis_id: int,
|
|
*,
|
|
only_archived: bool = True,
|
|
) -> list[dict[str, Any]]:
|
|
del diagnosis_id, only_archived
|
|
return [
|
|
{
|
|
"msg_id": "ownerless-message",
|
|
"msg_type": "text",
|
|
"text": "不应展示的无归属会话",
|
|
"is_from_doctor": False,
|
|
}
|
|
]
|
|
|
|
dialog = _open_dialog(application, OwnerlessMessageRepository())
|
|
chat_text = "\n".join(
|
|
label.text() for label in dialog.chat_host.findChildren(QLabel)
|
|
)
|
|
|
|
assert "不应展示的无归属会话" not in chat_text
|
|
dialog.close()
|
|
|
|
|
|
def test_tracking_response_without_diagnosis_owner_is_filtered_without_retry(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
class OwnerlessTrackingRepository(WorkspaceRepository):
|
|
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
|
|
result = super().get_tracking_window(diagnosis_id)
|
|
result.pop("diagnosis_id", None)
|
|
return result
|
|
|
|
dialog = _open_dialog(
|
|
application,
|
|
OwnerlessTrackingRepository(include_foreign_rows=False),
|
|
)
|
|
pane = dialog.records["健康档案"]
|
|
|
|
assert "甲燕麦鸡蛋" not in _pane_text(pane)
|
|
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
|
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
|
dialog.close()
|
|
|
|
|
|
def test_late_workspace_a_response_cannot_pollute_selected_workspace_b(
|
|
application: QApplication,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository = WorkspaceRepository(include_foreign_rows=False)
|
|
deferred = DeferredAsync()
|
|
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
|
|
dialog = AiConsultDialog(repository, _permissions())
|
|
dialog.open_for(diagnosis_id=501, patient_id=1501)
|
|
dialog.open_for(diagnosis_id=502, patient_id=1502)
|
|
dialog.show()
|
|
assert len(deferred.pending) == 2
|
|
|
|
deferred.complete(1)
|
|
application.processEvents()
|
|
deferred.complete(0)
|
|
application.processEvents()
|
|
for title in ("病历资料", "检查检验", "处方记录", "健康档案"):
|
|
text = _pane_text(dialog.records[title])
|
|
assert "乙" in text
|
|
assert "甲主诉口渴乏力" not in text
|
|
assert "甲医生检查记录" not in text
|
|
assert "甲-RX-1" not in text
|
|
assert "甲燕麦鸡蛋" not in text
|
|
assert dialog.diagnosis_id == 502
|
|
assert dialog._detail["diagnosis"]["id"] == 502
|
|
dialog.close()
|
|
|
|
|
|
def test_mismatched_detail_owner_fails_closed_across_all_record_tabs(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
repository = WorkspaceRepository()
|
|
repository.details[501] = _detail(999, "越权")
|
|
dialog = _open_dialog(application, repository)
|
|
|
|
forbidden = (
|
|
"越权主诉口渴乏力",
|
|
"甲医生检查记录",
|
|
"甲舌苔照片.jpg",
|
|
"甲-RX-1",
|
|
"甲燕麦鸡蛋",
|
|
)
|
|
for title in ("病历资料", "检查检验", "处方记录", "健康档案"):
|
|
pane = dialog.records[title]
|
|
text = _pane_text(pane)
|
|
assert all(sentinel not in text for sentinel in forbidden)
|
|
state = pane.state_label # type: ignore[attr-defined]
|
|
retry = pane.retry_button # type: ignore[attr-defined]
|
|
assert state.objectName() == "AiConsultRecordState"
|
|
assert retry.objectName() == "AiConsultRecordRetry"
|
|
assert state is not None and state.property("state") == "warning"
|
|
assert retry is not None and retry.isHidden()
|
|
assert all(
|
|
method != "list_patient_ai_reports" for method, _owner in repository.calls
|
|
)
|
|
dialog.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("method", "error_tabs", "success_sentinels"),
|
|
[
|
|
(
|
|
"get_diagnosis_detail",
|
|
{"病历资料", "健康档案"},
|
|
{"检查检验": "甲医生检查记录", "处方记录": "甲-RX-1"},
|
|
),
|
|
(
|
|
"get_doctor_notes",
|
|
{"检查检验"},
|
|
{
|
|
"病历资料": "甲主诉口渴乏力",
|
|
"处方记录": "甲-RX-1",
|
|
"健康档案": "甲燕麦鸡蛋",
|
|
},
|
|
),
|
|
(
|
|
"get_tracking_window",
|
|
{"健康档案"},
|
|
{
|
|
"病历资料": "甲主诉口渴乏力",
|
|
"检查检验": "甲医生检查记录",
|
|
"处方记录": "甲-RX-1",
|
|
},
|
|
),
|
|
(
|
|
"list_prescriptions_by_diagnosis",
|
|
{"处方记录"},
|
|
{
|
|
"病历资料": "甲主诉口渴乏力",
|
|
"检查检验": "甲医生检查记录",
|
|
"健康档案": "甲燕麦鸡蛋",
|
|
},
|
|
),
|
|
],
|
|
)
|
|
def test_one_failed_source_has_local_error_retry_and_preserves_other_sections(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
method: str,
|
|
error_tabs: set[str],
|
|
success_sentinels: dict[str, str],
|
|
) -> None:
|
|
repository = WorkspaceRepository(include_foreign_rows=False)
|
|
repository.failures.add((method, 501))
|
|
dialog = _open_dialog(application, repository)
|
|
|
|
for title in error_tabs:
|
|
pane = dialog.records[title]
|
|
state = pane.state_label # type: ignore[attr-defined]
|
|
retry = pane.retry_button # type: ignore[attr-defined]
|
|
assert state.objectName() == "AiConsultRecordState"
|
|
assert retry.objectName() == "AiConsultRecordRetry"
|
|
assert state is not None and state.property("state") == "error"
|
|
assert retry is not None and not retry.isHidden() and retry.isEnabled()
|
|
for title, sentinel in success_sentinels.items():
|
|
assert sentinel in _pane_text(dialog.records[title])
|
|
state = dialog.records[title].state_label # type: ignore[attr-defined]
|
|
assert state is not None and state.property("state") != "error", (
|
|
title,
|
|
state.text(),
|
|
state.property("state"),
|
|
)
|
|
|
|
repository.failures.clear()
|
|
dialog.records[next(iter(error_tabs))].retry_button.click() # type: ignore[attr-defined]
|
|
application.processEvents()
|
|
for pane in dialog.records.values():
|
|
state = pane.state_label # type: ignore[attr-defined]
|
|
assert state is not None and state.property("state") != "error"
|
|
dialog.close()
|