geng
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
||||
from doctor_workstation.ui.dialogs.ai_consult import (
|
||||
AiConsultDialog,
|
||||
can_open_ai_consult,
|
||||
present_ai_consult,
|
||||
render_chat_payload,
|
||||
)
|
||||
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||||
from doctor_workstation.ui.pages.patients import PatientListWorkspace
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
|
||||
|
||||
@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,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
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(ai_consult_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def test_ai_consult_dialog_matches_workspace_chrome(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
dialog = AiConsultDialog(repository, PermissionSet(["tcm.diagnosis/aiAssistant"]))
|
||||
dialog.open_for(
|
||||
diagnosis_id=501,
|
||||
patient_id=301,
|
||||
seed={"patient_name": "杨永", "age": 52, "clinical_diagnosis": "2型糖尿病"},
|
||||
source_title="问诊列表",
|
||||
)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
labels = [widget.text() for widget in dialog.findChildren(QLabel) if widget.text()]
|
||||
assert "问诊详情" in labels
|
||||
assert "AI 助手" in labels
|
||||
assert "智能分析" in labels
|
||||
assert "快捷工具" in labels
|
||||
assert "对话建议" in labels
|
||||
assert dialog.tabs.tabText(0) == "问诊对话"
|
||||
assert dialog.send_button.objectName() == "AiConsultSend"
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_present_ai_consult_requires_diagnosis_id(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
opened: list[int] = []
|
||||
monkeypatch.setattr(ai_consult_module.AiConsultDialog, "exec", lambda self: opened.append(self.diagnosis_id))
|
||||
present_ai_consult(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
None,
|
||||
diagnosis_id=0,
|
||||
)
|
||||
assert opened == []
|
||||
present_ai_consult(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
None,
|
||||
diagnosis_id=501,
|
||||
seed={"patient_name": "杨永"},
|
||||
)
|
||||
assert opened == [501]
|
||||
|
||||
|
||||
def test_four_entry_points_expose_ai_consult_action(application: QApplication) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
allowed = PermissionSet(["*", "tcm.diagnosis/aiAssistant"])
|
||||
assert can_open_ai_consult(allowed)
|
||||
|
||||
patients = PatientListWorkspace(repository, allowed)
|
||||
patients.show()
|
||||
application.processEvents()
|
||||
assert patients.ai_consult_button.text() == "AI 分析"
|
||||
assert not patients.ai_consult_button.isHidden()
|
||||
|
||||
reception = ReceptionPage(repository, allowed)
|
||||
reception.show()
|
||||
application.processEvents()
|
||||
menu_titles = [action.text() for action in reception.more_button.menu().actions()]
|
||||
assert "AI 分析" in menu_titles
|
||||
assert reception.ai_consult_button.text() == "AI 分析"
|
||||
|
||||
appointments = AppointmentsPage(repository, permissions=allowed)
|
||||
appointments.show()
|
||||
application.processEvents()
|
||||
assert appointments.toolbar_ai_consult_button.text() == "AI 分析"
|
||||
assert not appointments.toolbar_ai_consult_button.isHidden()
|
||||
|
||||
consultations = ConsultationsPage(repository, permissions=allowed)
|
||||
assert consultations.table_host.action_policy.get("ai_consult") is True
|
||||
patients.close()
|
||||
reception.close()
|
||||
appointments.close()
|
||||
consultations.close()
|
||||
|
||||
|
||||
def test_ai_consult_sidebar_loads_patient_facts_and_reports(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.open_for(diagnosis_id=501, patient_id=301, seed={"patient_name": "林晓岚"})
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
values = {
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultKeyValue"
|
||||
}
|
||||
assert "22.1" in values
|
||||
assert any("病程" in text or "3" in text for text in values)
|
||||
titles = {
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultRecordTitle"
|
||||
}
|
||||
assert "血糖控制评估" in titles
|
||||
assert "并发症风险评估" in titles
|
||||
bodies = [
|
||||
widget.toPlainText()
|
||||
for widget in dialog.findChildren(QTextBrowser)
|
||||
if widget.objectName() == "AiConsultBubbleText"
|
||||
]
|
||||
assert any("病情与证候分析" in text for text in bodies)
|
||||
assert any("###" not in text for text in bodies if "病情与证候分析" in text)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_consult_sidebar_survives_chat_archive_errors(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class BrokenChatRepository(DemoDoctorRepository):
|
||||
def list_im_chat_messages(self, diagnosis_id: int, *, only_archived: bool = True):
|
||||
raise RuntimeError("archive unavailable")
|
||||
|
||||
dialog = AiConsultDialog(
|
||||
BrokenChatRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
values = {
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultKeyValue"
|
||||
}
|
||||
titles = {
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultRecordTitle"
|
||||
}
|
||||
assert "22.1" in values
|
||||
assert "血糖控制评估" in titles
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -> None:
|
||||
browser = QTextBrowser()
|
||||
render_chat_payload(browser, "### 病情摘要\n\n**核心病机**\n\n- 口干")
|
||||
assert "病情摘要" in browser.toPlainText()
|
||||
assert "核心病机" in browser.toPlainText()
|
||||
assert "###" not in browser.toPlainText()
|
||||
assert "<h3" in browser.toHtml().lower()
|
||||
|
||||
render_chat_payload(browser, "<p>空腹血糖 <strong>6.8</strong></p>")
|
||||
assert "空腹血糖" in browser.toPlainText()
|
||||
assert "6.8" in browser.toPlainText()
|
||||
|
||||
render_chat_payload(browser, '{"diagnosis":"肝郁脾虚证","risk":["血糖波动"]}')
|
||||
assert "肝郁脾虚证" in browser.toPlainText()
|
||||
browser.deleteLater()
|
||||
|
||||
|
||||
def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.show()
|
||||
dialog._stream_bubble = dialog._append_bubble("ai", "")
|
||||
bubble = dialog._stream_bubble
|
||||
generation = dialog._generation
|
||||
stream_generation = dialog._stream_generation
|
||||
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": "第一段"},
|
||||
)
|
||||
dialog._flush_timer.stop()
|
||||
dialog._flush_stream_chunks()
|
||||
application.processEvents()
|
||||
assert bubble is not None and bubble.body is not None
|
||||
assert bubble.body.toPlainText() == "第一段"
|
||||
ai_bubble_count = len(
|
||||
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
|
||||
)
|
||||
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": "第二段"},
|
||||
)
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "done", "model_label": "千问"},
|
||||
)
|
||||
application.processEvents()
|
||||
assert bubble.body.toPlainText() == "第一段第二段"
|
||||
assert len(
|
||||
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
|
||||
) == ai_bubble_count
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stream_error_and_cancelled_late_chunk_reuse_or_leave_current_bubble(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.show()
|
||||
dialog._stream_bubble = dialog._append_bubble("ai", "")
|
||||
bubble = dialog._stream_bubble
|
||||
generation = dialog._generation
|
||||
stream_generation = dialog._stream_generation
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": "已生成"},
|
||||
)
|
||||
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||||
application.processEvents()
|
||||
assert bubble is not None and bubble.body is not None
|
||||
assert "已生成" in bubble.body.toPlainText()
|
||||
assert "模型繁忙" in bubble.body.toPlainText()
|
||||
|
||||
before_cancel = bubble.body.toPlainText()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": "迟到内容"},
|
||||
)
|
||||
application.processEvents()
|
||||
assert bubble.body.toPlainText() == before_cancel
|
||||
|
||||
|
||||
def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_it(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.diagnosis_id = 501
|
||||
dialog.show()
|
||||
for index in range(28):
|
||||
dialog._append_bubble("ai", f"历史消息 {index}:" + "辨证内容" * 16)
|
||||
application.processEvents()
|
||||
bar = dialog.chat_scroll.verticalScrollBar()
|
||||
bar.setValue(bar.maximum())
|
||||
application.processEvents()
|
||||
assert dialog._follow_chat
|
||||
|
||||
bar.setValue(max(0, bar.maximum() // 3))
|
||||
application.processEvents()
|
||||
reading_position = bar.value()
|
||||
assert not dialog._follow_chat
|
||||
dialog._append_bubble("ai", "新的流式内容" * 20)
|
||||
application.processEvents()
|
||||
assert bar.value() == reading_position
|
||||
|
||||
dialog._ask("请继续分析")
|
||||
application.processEvents()
|
||||
assert dialog._follow_chat
|
||||
assert bar.value() == bar.maximum()
|
||||
dialog.close()
|
||||
@@ -0,0 +1,866 @@
|
||||
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
|
||||
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_filters_foreign_attachments_and_blocks_file_urls(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
opened: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
ai_consult_module,
|
||||
"open_safe_http_url",
|
||||
lambda target: opened.append(target) or True,
|
||||
)
|
||||
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") == "blocked"
|
||||
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
||||
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
||||
unsafe = next(button for button in buttons if "本地危险附件" in button.text())
|
||||
assert not unsafe.isEnabled()
|
||||
for button in buttons:
|
||||
button.click()
|
||||
assert len(opened) == 2
|
||||
assert all(target.startswith(("http://", "https://")) for target in opened)
|
||||
assert all(not target.startswith("file:") for target in opened)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_tongue_thumbnail_auto_get_requires_configured_https_origin(
|
||||
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,
|
||||
)
|
||||
|
||||
untrusted = _open_dialog(application, WorkspaceRepository())
|
||||
assert requested == []
|
||||
untrusted.close()
|
||||
|
||||
trusted_repository = WorkspaceRepository()
|
||||
trusted_repository.trusted_media_domains = ["media.example.invalid"]
|
||||
assert not ai_consult_module._trusted_thumbnail_url(
|
||||
trusted_repository,
|
||||
"http://media.example.invalid/甲/tongue.jpg",
|
||||
)
|
||||
assert not ai_consult_module._trusted_thumbnail_url(
|
||||
trusted_repository,
|
||||
"https://sub.media.example.invalid/甲/tongue.jpg",
|
||||
)
|
||||
trusted = _open_dialog(application, trusted_repository)
|
||||
assert requested == ["https://media.example.invalid/甲/tongue.jpg"]
|
||||
trusted.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_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()
|
||||
@@ -74,6 +74,52 @@ def test_post_uses_json_and_never_retries_timeout() -> None:
|
||||
assert caught.value.data["attempts"] == 1
|
||||
|
||||
|
||||
def test_post_event_stream_sends_exact_contract_and_preserves_event_order() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
content = (
|
||||
'event: start\ndata: {"model_key":"qwen"}\n\n'
|
||||
'event: delta\ndata: {"content":"辨"}\n\n'
|
||||
'event: delta\ndata: {"content":"证"}\n\n'
|
||||
'event: done\ndata: {"model_label":"千问"}\n\n'
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream; charset=utf-8"},
|
||||
text=content,
|
||||
)
|
||||
|
||||
client = ApiClient(
|
||||
"https://example.test",
|
||||
token="stream-token",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
events = list(
|
||||
client.post_event_stream(
|
||||
"tcm.diagnosis/aiAssistantStream",
|
||||
{"id": 501, "task": "custom", "prompt": "如何辨证?"},
|
||||
)
|
||||
)
|
||||
client.close()
|
||||
|
||||
assert [event["event"] for event in events] == ["start", "delta", "delta", "done"]
|
||||
assert [event["data"] for event in events[1:3]] == [
|
||||
{"content": "辨"},
|
||||
{"content": "证"},
|
||||
]
|
||||
request = requests[0]
|
||||
assert request.headers["accept"] == "text/event-stream"
|
||||
assert request.headers["token"] == "stream-token"
|
||||
assert str(request.url).endswith("/adminapi/tcm.diagnosis/aiAssistantStream")
|
||||
assert json.loads(request.content) == {
|
||||
"id": 501,
|
||||
"task": "custom",
|
||||
"prompt": "如何辨证?",
|
||||
}
|
||||
|
||||
|
||||
def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QDialog, QDialogButtonBox, QLabel
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError
|
||||
from doctor_workstation.core.models import Appointment, PageResult
|
||||
@@ -163,6 +164,8 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
|
||||
)
|
||||
assert _diagnosis_id(row) == 501
|
||||
assert _video_patient_id(row) == 301
|
||||
assert _video_patient_id({"diagnosis_id": 501, "patient_id": 301}) == 0
|
||||
assert _video_patient_id({"diagnosis_id": 501, "patient_id": 501}) == 0
|
||||
assert prescription_action_label(row) == "开方"
|
||||
|
||||
approved = Appointment.from_dict(
|
||||
@@ -354,7 +357,8 @@ def test_appointment_multiline_cells_receive_enough_row_height(
|
||||
assert appointment_text.count("\n") == 2
|
||||
assert "2026-08-11 14:30" in appointment_text
|
||||
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
|
||||
assert page.table.rowHeight(0) >= required
|
||||
assert 60 <= page.table.rowHeight(0) <= 66
|
||||
assert page.table.rowHeight(0) >= min(required, 66)
|
||||
assert page.table.item(0, 4).toolTip() == appointment_text
|
||||
page.close()
|
||||
|
||||
@@ -513,6 +517,7 @@ def test_appointments_reference_split_layout_and_video_list(
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
page.resize(1460, 820)
|
||||
page._apply_responsive_layout()
|
||||
page._loaded(
|
||||
{
|
||||
"lists": [
|
||||
@@ -538,8 +543,133 @@ def test_appointments_reference_split_layout_and_video_list(
|
||||
|
||||
assert page.video_list.count() == 1
|
||||
assert "赵俊霞" in page.video_list.item(0).text()
|
||||
assert page.video_list.parentWidget().width() == 420
|
||||
assert 300 <= page.video_panel.width() <= 420
|
||||
assert page.table.objectName() == "AppointmentTable"
|
||||
assert (
|
||||
page.table.verticalScrollMode()
|
||||
== QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
)
|
||||
assert (
|
||||
page.video_list.verticalScrollMode()
|
||||
== QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
)
|
||||
assert page.date_buttons["today"].isChecked()
|
||||
|
||||
page.resize(1024, 640)
|
||||
page._apply_responsive_layout()
|
||||
assert page.video_panel.isHidden()
|
||||
assert not page.video_panel_button.isHidden()
|
||||
assert not page.date_overflow_button.isHidden()
|
||||
assert page.date_buttons["yesterday"].isHidden()
|
||||
assert not page.date_buttons["today"].isHidden()
|
||||
page.video_panel_button.click()
|
||||
assert not page.video_panel.isHidden()
|
||||
assert 250 <= page.video_panel.width() <= 300
|
||||
page.video_panel_button.click()
|
||||
assert page.video_panel.isHidden()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_identical_appointment_poll_keeps_existing_cell_widgets(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = AppointmentsPage(
|
||||
DemoDoctorRepository(),
|
||||
permissions=PermissionSet(["doctor.appointment/lists"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
result = {
|
||||
"lists": [
|
||||
{
|
||||
"id": 101,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 301,
|
||||
"patient_name": "赵俊霞",
|
||||
"gender": 2,
|
||||
"age": 53,
|
||||
"assistant_name": "周医助",
|
||||
"appointment_date": "2026-08-17",
|
||||
"appointment_time": "09:50",
|
||||
"status": 1,
|
||||
"status_desc": "已挂号",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"extend": {"status_count": {"1": 1}},
|
||||
}
|
||||
page._loaded(result, page._generation, True)
|
||||
selector = page.table.cellWidget(0, 0)
|
||||
appointment_info = page.table.cellWidget(0, 4)
|
||||
video_card = page.video_list.itemWidget(page.video_list.item(0))
|
||||
|
||||
page._loaded(deepcopy(result), page._generation, True)
|
||||
|
||||
assert page.table.cellWidget(0, 0) is selector
|
||||
assert page.table.cellWidget(0, 4) is appointment_info
|
||||
assert page.video_list.itemWidget(page.video_list.item(0)) is video_card
|
||||
|
||||
changed = deepcopy(result)
|
||||
changed["lists"][0]["assistant_name"] = "新医助"
|
||||
page._loaded(changed, page._generation, True)
|
||||
assert page.table.cellWidget(0, 0) is not selector
|
||||
assert page.table.cellWidget(0, 4) is not appointment_info
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(appointments_module, "run_async", lambda *_args, **_kwargs: object())
|
||||
page = AppointmentsPage(
|
||||
DemoDoctorRepository(),
|
||||
permissions=PermissionSet(["*"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
# 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter,
|
||||
# and 62 px top bar leaves a 1161x680 page viewport.
|
||||
page.resize(1161, 680)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
rows = [
|
||||
{
|
||||
"id": 100 + index,
|
||||
"diagnosis_id": 500 + index,
|
||||
"patient_id": 300 + index,
|
||||
"patient_name": f"患者{index}",
|
||||
"gender": 2,
|
||||
"age": 40 + index,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "周医助",
|
||||
"appointment_date": "2026-08-17",
|
||||
"appointment_time": f"{8 + index:02d}:00",
|
||||
"status": 1,
|
||||
"status_desc": "已挂号",
|
||||
"diagnosis_confirmed": 0,
|
||||
"has_prescription": 0,
|
||||
}
|
||||
for index in range(8)
|
||||
]
|
||||
page._loaded(
|
||||
{"lists": rows, "count": len(rows), "extend": {"status_count": {"1": 8}}},
|
||||
page._generation,
|
||||
False,
|
||||
)
|
||||
application.processEvents()
|
||||
|
||||
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
|
||||
assert page.header.height() == 26
|
||||
assert page.filter_panel.height() <= 84
|
||||
assert all(60 <= height <= 66 for height in heights)
|
||||
assert page.table.viewport().height() // max(heights) >= 4
|
||||
assert page.pager.isVisibleTo(page)
|
||||
assert 300 <= page.video_panel.width() < 420
|
||||
|
||||
page.resize(1024, 640)
|
||||
application.processEvents()
|
||||
assert page.video_panel.isHidden()
|
||||
assert not page.video_panel_button.isHidden()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -298,7 +298,7 @@ def test_action_visibility_requires_exact_canonical_permissions(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_refresh_generation_ignores_late_results(
|
||||
def test_refresh_generation_ignores_late_results(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -318,11 +318,81 @@ def test_refresh_generation_ignores_late_results(
|
||||
application.processEvents()
|
||||
|
||||
assert page.table.rowCount() == 1
|
||||
assert page.table.item(0, 0).text().startswith("902")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_current_appointment_is_the_only_prescription_authority(
|
||||
assert page.table.item(0, 0).text().startswith("902")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_identical_silent_refresh_has_zero_model_reset_and_fixed_widget_budget(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class Repository:
|
||||
calls = 0
|
||||
|
||||
def list_consultations(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
self.calls += 1
|
||||
return {"lists": [_row()], "count": 1}
|
||||
|
||||
repository = Repository()
|
||||
page = ConsultationsPage(repository, permissions=PermissionSet(["*"]))
|
||||
page.refresh(silent=True)
|
||||
page.table.selectRow(0)
|
||||
model = page.table_host.model
|
||||
action_widget = page.table_host.fixed.indexWidget(model.index(0, 11))
|
||||
video_widget = page.table_host.fixed.indexWidget(model.index(0, 10))
|
||||
resets: list[str] = []
|
||||
model.modelAboutToBeReset.connect(lambda: resets.append("begin"))
|
||||
model.modelReset.connect(lambda: resets.append("end"))
|
||||
install_calls: list[None] = []
|
||||
original_install = page.table_host._install_fixed_widgets
|
||||
|
||||
def count_install() -> None:
|
||||
install_calls.append(None)
|
||||
original_install()
|
||||
|
||||
monkeypatch.setattr(page.table_host, "_install_fixed_widgets", count_install)
|
||||
page.refresh(silent=True)
|
||||
|
||||
assert repository.calls == 2
|
||||
assert resets == []
|
||||
assert install_calls == []
|
||||
assert page.table_host.fixed.indexWidget(model.index(0, 11)) is action_widget
|
||||
assert page.table_host.fixed.indexWidget(model.index(0, 10)) is video_widget
|
||||
assert page.table.currentIndex().row() == 0
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_timer_poll_has_one_request_budget_while_refresh_is_in_flight(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
jobs: list[dict[str, Any]] = []
|
||||
|
||||
def queue_async(_function: Any, **options: Any) -> object:
|
||||
jobs.append(options)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(consultations_module, "run_async", queue_async)
|
||||
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
|
||||
monkeypatch.setattr(page, "isVisible", lambda: True)
|
||||
monkeypatch.setattr(page, "_refresh_counts", lambda: None)
|
||||
|
||||
page._poll_refresh()
|
||||
page._poll_refresh()
|
||||
page._poll_refresh()
|
||||
|
||||
assert len(jobs) == 1
|
||||
assert page._loading
|
||||
jobs[0]["on_success"]({"lists": [_row()], "count": 1})
|
||||
jobs[0]["on_finished"]()
|
||||
assert not page._loading
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_current_appointment_is_the_only_prescription_authority(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
calls: list[tuple[str, int]] = []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -276,13 +277,15 @@ class VisualRepository:
|
||||
) -> dict[str, Any]:
|
||||
assert diagnosis_id == 501
|
||||
self.tracking_calls.append((start_date, end_date))
|
||||
newest_date = end_date or date.today().isoformat()
|
||||
previous_date = (date.fromisoformat(newest_date) - timedelta(days=1)).isoformat()
|
||||
return {
|
||||
"blood_records": [
|
||||
{
|
||||
"id": 6201,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-10",
|
||||
"record_date": newest_date,
|
||||
"fasting_blood_sugar": 8.2,
|
||||
"systolic_pressure": 146,
|
||||
"source": 1,
|
||||
@@ -291,7 +294,7 @@ class VisualRepository:
|
||||
"id": 6202,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-10",
|
||||
"record_date": newest_date,
|
||||
"postprandial_blood_sugar": 12.4,
|
||||
"diastolic_pressure": 92,
|
||||
"western_medicine": "二甲双胍",
|
||||
@@ -300,7 +303,7 @@ class VisualRepository:
|
||||
"id": 6203,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-09",
|
||||
"record_date": previous_date,
|
||||
"fasting_blood_sugar": 7.6,
|
||||
"postprandial_blood_sugar": 10.8,
|
||||
},
|
||||
@@ -310,7 +313,7 @@ class VisualRepository:
|
||||
"id": 6301,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-10",
|
||||
"record_date": newest_date,
|
||||
"breakfast_foods": "燕麦、鸡蛋",
|
||||
"lunch_foods": "杂粮饭",
|
||||
}
|
||||
@@ -320,7 +323,7 @@ class VisualRepository:
|
||||
"id": 6401,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-09",
|
||||
"record_date": previous_date,
|
||||
"exercise_type": "散步",
|
||||
"duration": 35,
|
||||
"intensity": 2,
|
||||
@@ -331,7 +334,7 @@ class VisualRepository:
|
||||
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
assert diagnosis_id == 501
|
||||
self.tracking_note_calls += 1
|
||||
return [{"note_date": "2026-08-10", "content": "饭后散步,继续观察。"}]
|
||||
return [{"note_date": date.today().isoformat(), "content": "饭后散步,继续观察。"}]
|
||||
|
||||
def list_diagnosis_todos(
|
||||
self,
|
||||
@@ -871,10 +874,11 @@ def test_tabs_lazy_load_real_repository_data_and_daily_matrix_structure(
|
||||
panel = dialog._daily_panels[1]
|
||||
assert panel.matrix.rowCount() == 11
|
||||
assert panel.matrix.columnCount() == 8
|
||||
newest_header = repository.tracking_calls[-1][1][5:]
|
||||
blood_column = next(
|
||||
column
|
||||
for column in range(1, panel.matrix.columnCount())
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == "08-10"
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == newest_header
|
||||
)
|
||||
assert panel.matrix.item(0, blood_column).text() == "↑ 8.2 · 自录"
|
||||
assert panel.matrix.item(3, blood_column).text() == "↑ 146/92 · 自录"
|
||||
@@ -1270,15 +1274,18 @@ def test_existing_daily_cells_edit_real_records_and_reject_wrong_owner(
|
||||
return payload
|
||||
|
||||
monkeypatch.setattr(diagnosis_module, "DailyRecordEditorDialog", AcceptedEditor)
|
||||
newest_date = date.fromisoformat(repository.tracking_calls[-1][1])
|
||||
newest_header = newest_date.strftime("%m-%d")
|
||||
previous_header = (newest_date - timedelta(days=1)).strftime("%m-%d")
|
||||
blood_column = next(
|
||||
column
|
||||
for column in range(1, panel.matrix.columnCount())
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == "08-10"
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == newest_header
|
||||
)
|
||||
exercise_column = next(
|
||||
column
|
||||
for column in range(1, panel.matrix.columnCount())
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == "08-09"
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == previous_header
|
||||
)
|
||||
blood_role = panel.matrix.item(0, blood_column).data(Qt.ItemDataRole.UserRole)
|
||||
diet_role = panel.matrix.item(6, blood_column).data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
@@ -8,9 +8,16 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QAbstractTableModel, QRect, Qt, Signal
|
||||
from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter
|
||||
from PySide6.QtWidgets import QApplication, QFrame, QToolButton, QWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QFrame,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||
@@ -158,14 +165,15 @@ def test_visual_hierarchy_and_filter_contract(
|
||||
page = _page()
|
||||
content_layout = page.page_scroll.widget().layout()
|
||||
margins = content_layout.contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (20, 18, 29, 16)
|
||||
assert content_layout.spacing() == 12
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (18, 10, 18, 10)
|
||||
assert content_layout.spacing() == 8
|
||||
status_card = page.findChild(QFrame, "DiagnosisStatusCard")
|
||||
assert status_card is not None
|
||||
assert status_card.height() == 62
|
||||
assert page.page_header.height() == 62
|
||||
assert status_card.height() == 50
|
||||
assert page.findChild(QFrame, "DiagnosisFilterCard") is not None
|
||||
assert page.findChild(QFrame, "DiagnosisListCard") is not None
|
||||
assert page.filters_card.height() == 108
|
||||
assert page.filters_card.height() == 90
|
||||
assert page.keyword_edit.maximumWidth() == 380
|
||||
assert list(page.status_buttons) == ["1", "", "4", "2", "3"]
|
||||
assert page.status_buttons["1"].isChecked()
|
||||
@@ -234,12 +242,17 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
assert isinstance(page.table.model(), QAbstractTableModel)
|
||||
assert isinstance(page.table.model(), DiagnosisTableModel)
|
||||
assert page.table_host.LEFT_WIDTHS == (48, 70, 60, 100, 175, 88, 120, 100, 72, 110)
|
||||
assert page.table_host.FIXED_WIDTHS == (120, 340)
|
||||
assert page.table_host.fixed.width() == 462
|
||||
assert page.table_host.FIXED_WIDTHS == (120, 410)
|
||||
assert page.table_host.fixed.width() == 532
|
||||
assert page.table.isColumnHidden(10)
|
||||
assert page.table_host.fixed.isColumnHidden(9)
|
||||
assert not page.table_host.fixed.isColumnHidden(10)
|
||||
assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table_host.fixed.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table_host.fixed.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
assert page.table_host.minimumHeight() == 0
|
||||
assert page.table_host.sizePolicy().verticalPolicy() == QSizePolicy.Policy.Expanding
|
||||
|
||||
rows = [_row(501), _row(502, has_appointment=0, appointments=[])]
|
||||
page.table_host.set_rows(rows)
|
||||
@@ -303,6 +316,10 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
page.loading_overlay.stop()
|
||||
|
||||
page.pager.update_state(3, 97)
|
||||
assert 40 <= page.pager.height() <= 44
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
assert pager_margins.top() >= 4
|
||||
assert pager_margins.bottom() >= 4
|
||||
assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40]
|
||||
assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5
|
||||
assert page.pager.jumper.maximum() == 7
|
||||
@@ -472,6 +489,7 @@ def test_full_more_menu_requires_each_real_repository_capability(
|
||||
"view": True,
|
||||
"edit": True,
|
||||
"prescription": True,
|
||||
"ai_consult": True,
|
||||
"appointment": True,
|
||||
"assign": True,
|
||||
"delete": True,
|
||||
@@ -612,30 +630,106 @@ def test_error_state_is_persistent_until_rows_replace_it(application: QApplicati
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||
def test_two_desktop_sizes_scroll_vertically_without_horizontal_page_clipping(
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 4), ((1710, 920), 7)],
|
||||
)
|
||||
def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
|
||||
application: QApplication,
|
||||
size: tuple[int, int],
|
||||
minimum_visible_rows: int,
|
||||
) -> None:
|
||||
page = _page()
|
||||
rows = [_row(600 + index, patient_name=f"患者{index:02d}") for index in range(15)]
|
||||
rows = [
|
||||
_row(
|
||||
600 + index,
|
||||
patient_name=f"患者{index:02d}",
|
||||
latest_appointment_channel_text="健康顾问转介",
|
||||
)
|
||||
for index in range(40)
|
||||
]
|
||||
page.table_host.set_rows(rows)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
viewport = page.table.viewport()
|
||||
visible_rows = sum(
|
||||
1
|
||||
for row in range(page.table_host.model.rowCount())
|
||||
if (
|
||||
(rect := page.table.visualRect(page.table_host.model.index(row, 0))).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
pager_top = page.pager.mapTo(page.page_scroll.viewport(), QPoint()).y()
|
||||
assert page.page_scroll.horizontalScrollBar().maximum() == 0
|
||||
assert page.page_scroll.verticalScrollBar().maximum() > 0
|
||||
assert page.page_scroll.verticalScrollBar().maximum() == 0
|
||||
assert pager_top >= 0
|
||||
assert pager_top + page.pager.height() <= page.page_scroll.viewport().height()
|
||||
assert visible_rows >= minimum_visible_rows
|
||||
assert page.table.verticalScrollBar().maximum() > 0
|
||||
assert page.table_host.fixed.geometry().right() <= page.table_host.rect().right()
|
||||
assert page.search_button.geometry().right() <= page.search_button.parentWidget().rect().right()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
page = _page()
|
||||
rows = [
|
||||
_row(
|
||||
800 + index,
|
||||
latest_appointment_channel_text="健康顾问转介",
|
||||
)
|
||||
for index in range(40)
|
||||
]
|
||||
page.table_host.set_rows(rows[:15])
|
||||
page.resize(1366, 768)
|
||||
page.show()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
host_height = page.table_host.height()
|
||||
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page._change_page_size(40)
|
||||
page.table_host.set_rows(rows)
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
main_scroll = page.table.verticalScrollBar()
|
||||
fixed_scroll = page.table_host.fixed.verticalScrollBar()
|
||||
assert page.table_host.height() == host_height
|
||||
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||
application.processEvents()
|
||||
assert fixed_scroll.value() == main_scroll.value()
|
||||
fixed_scroll.setValue(fixed_scroll.maximum() // 3)
|
||||
application.processEvents()
|
||||
assert main_scroll.value() == fixed_scroll.value()
|
||||
|
||||
center_index = page.table.indexAt(page.table.viewport().rect().center())
|
||||
assert center_index.isValid()
|
||||
main_top = page.table.visualRect(page.table_host.model.index(center_index.row(), 0)).top()
|
||||
fixed_top = page.table_host.fixed.visualRect(
|
||||
page.table_host.model.index(center_index.row(), 10)
|
||||
).top()
|
||||
assert main_top == fixed_top
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_required_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1024x640.png": (1024, 640),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1440x900.png": (1440, 900),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1366x768.png": (1366, 768),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1710x920.png": (1710, 920),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_loading_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
|
||||
@@ -7,7 +7,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import pytest
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, QSize, Signal
|
||||
from PySide6.QtGui import QColor, QImage
|
||||
from PySide6.QtNetwork import QNetworkReply
|
||||
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from doctor_workstation.ui.diagnosis_drawer import (
|
||||
@@ -34,6 +34,7 @@ def _png_bytes(width: int, height: int, color: str = "#0F766E") -> bytes:
|
||||
|
||||
class _FakeReply(QObject):
|
||||
finished = Signal()
|
||||
downloadProgress = Signal(int, int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -45,6 +46,7 @@ class _FakeReply(QObject):
|
||||
self.payload = payload
|
||||
self.network_error = error
|
||||
self.aborted = False
|
||||
self.read_all_calls = 0
|
||||
|
||||
def abort(self) -> None:
|
||||
self.aborted = True
|
||||
@@ -53,6 +55,7 @@ class _FakeReply(QObject):
|
||||
return self.network_error
|
||||
|
||||
def readAll(self) -> QByteArray: # noqa: N802 - mirrors QNetworkReply
|
||||
self.read_all_calls += 1
|
||||
return QByteArray(self.payload)
|
||||
|
||||
|
||||
@@ -61,6 +64,7 @@ class _FakeManager(QObject):
|
||||
super().__init__(parent)
|
||||
self.responses: list[tuple[bytes, QNetworkReply.NetworkError]] = []
|
||||
self.requests: list[str] = []
|
||||
self.request_objects: list[object] = []
|
||||
self.replies: list[_FakeReply] = []
|
||||
|
||||
def queue(
|
||||
@@ -73,6 +77,7 @@ class _FakeManager(QObject):
|
||||
def get(self, request: object) -> _FakeReply:
|
||||
payload, error = self.responses.pop(0)
|
||||
self.requests.append(request.url().toString())
|
||||
self.request_objects.append(request)
|
||||
reply = _FakeReply(payload, error, self)
|
||||
self.replies.append(reply)
|
||||
return reply
|
||||
@@ -174,6 +179,45 @@ def test_remote_image_uses_text_only_after_request_or_decode_failure(
|
||||
assert application.thread() == button.thread()
|
||||
|
||||
|
||||
def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_download(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
owner = _RenderOwner(5)
|
||||
button = _RemoteImageButton(
|
||||
"",
|
||||
render_owner=owner,
|
||||
owner_generation=5,
|
||||
maximum_size=QSize(64, 64),
|
||||
fallback_text="image unavailable",
|
||||
cover=True,
|
||||
object_name="DiagnosisTongueThumb",
|
||||
parent=owner,
|
||||
)
|
||||
button._manager.deleteLater()
|
||||
manager = _FakeManager(button)
|
||||
button._manager = manager
|
||||
manager.queue(b"must-not-be-read")
|
||||
|
||||
button.load_url("https://media.example.invalid/oversize.png")
|
||||
request = manager.request_objects[-1]
|
||||
assert request.attribute(QNetworkRequest.Attribute.RedirectPolicyAttribute) == (
|
||||
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy
|
||||
)
|
||||
|
||||
reply = manager.replies[-1]
|
||||
reply.downloadProgress.emit(button._MAX_IMAGE_BYTES, -1)
|
||||
assert reply.aborted is False
|
||||
reply.downloadProgress.emit(button._MAX_IMAGE_BYTES + 1, -1)
|
||||
assert reply.aborted is True
|
||||
assert reply.property("diagnosisImageOversize") is True
|
||||
|
||||
reply.finished.emit()
|
||||
assert reply.read_all_calls == 0
|
||||
assert button.property("loadState") == "failed"
|
||||
assert button.text() == "image unavailable"
|
||||
assert application.thread() == button.thread()
|
||||
|
||||
|
||||
def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QScrollArea
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
@@ -18,7 +19,9 @@ 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,
|
||||
)
|
||||
@@ -596,3 +599,187 @@ def test_patient_ai_disclaimer_remains_the_unified_text() -> None:
|
||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -621,17 +621,36 @@ def test_patient_list_reference_geometry_and_row_actions(
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
||||
page.resize(1460, 820)
|
||||
# 1366x768 shell minus its 170 px patient rail, 26 px outer gutter,
|
||||
# and 62 px top bar leaves a 1170x680 page viewport.
|
||||
page.resize(1170, 680)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.patient_workspace.refresh()
|
||||
application.processEvents()
|
||||
|
||||
workspace = page.patient_workspace
|
||||
assert page.header.height() == 62
|
||||
assert workspace.filter_card.height() <= 92
|
||||
assert all(
|
||||
button.minimumHeight() == 56 and button.maximumHeight() == 56
|
||||
button.minimumHeight() == 44 and button.maximumHeight() == 44
|
||||
for button in workspace.summary_buttons.values()
|
||||
)
|
||||
assert all(
|
||||
widget.minimumWidth() == 0 and widget.maximumWidth() > 1000
|
||||
for widget in (
|
||||
workspace.keyword_edit,
|
||||
workspace.status_host,
|
||||
workspace.quick_host,
|
||||
workspace.date_host,
|
||||
)
|
||||
)
|
||||
assert page.tabs.minimumHeight() == 0
|
||||
assert workspace.content_stack.minimumHeight() == 0
|
||||
assert workspace.table.minimumHeight() == 0
|
||||
assert workspace.bottom_actions.isHidden()
|
||||
assert workspace.table.viewport().height() // 40 >= 6
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
assert workspace.table.objectName() == "PatientTable"
|
||||
assert workspace.table.columnCount() == 10
|
||||
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QComboBox, QFrame, QWidget
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
from doctor_workstation.ui.widgets import BusinessPager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
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,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
|
||||
monkeypatch.setattr(library_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _issued_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 1000 + index,
|
||||
"sn": f"CF-202608-{1000 + index}",
|
||||
"prescription_type": "汤剂",
|
||||
"is_system_auto": index % 2,
|
||||
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
|
||||
"gender": 2 if index % 2 else 1,
|
||||
"age": 29 + index,
|
||||
"audit_status": index % 3,
|
||||
"void_status": 0,
|
||||
"has_prescription_order": index % 2,
|
||||
"creator_id": 7,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "赵医助",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
|
||||
|
||||
def _library_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 2000 + index,
|
||||
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
|
||||
"formula_type": "主方" if index % 3 else "辅方",
|
||||
"herbs": [
|
||||
{"name": "黄芪", "dosage": 15},
|
||||
{"name": "党参", "dosage": 12},
|
||||
],
|
||||
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
|
||||
"is_public": index % 2,
|
||||
"disable_edit": 0,
|
||||
"creator_id": 7,
|
||||
"creator_name": "陈医生",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
|
||||
}
|
||||
|
||||
|
||||
class DensityRepository:
|
||||
def __init__(self) -> None:
|
||||
self.issued_rows = [_issued_row(index) for index in range(15)]
|
||||
self.library_rows = [_library_row(index) for index in range(15)]
|
||||
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
|
||||
|
||||
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.issued_rows, "count": 44}
|
||||
|
||||
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.library_rows, "count": 41}
|
||||
|
||||
|
||||
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
|
||||
repository = DensityRepository()
|
||||
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
|
||||
permissions = {"*"}
|
||||
if kind == "issued":
|
||||
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
|
||||
repository, permissions, user
|
||||
)
|
||||
else:
|
||||
page = PrescriptionLibraryPage(repository, permissions, user)
|
||||
page.refresh()
|
||||
return page
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
|
||||
viewport = page.table.viewport()
|
||||
return sum(
|
||||
1
|
||||
for row in range(page.table.rowCount())
|
||||
if (
|
||||
(item := page.table.item(row, 0)) is not None
|
||||
and (rect := page.table.visualItemRect(item)).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
pager = BusinessPager(15)
|
||||
pager.update_state(2, 44)
|
||||
pager.show()
|
||||
_settle(application)
|
||||
|
||||
assert prescriptions_module.BusinessPager is BusinessPager
|
||||
assert 40 <= pager.height() <= 44
|
||||
assert pager.minimumHeight() == pager.maximumHeight() == 42
|
||||
assert pager.findChildren(QComboBox) == []
|
||||
assert pager.page_size_label.text() == "15 条/页"
|
||||
margins = pager.layout().contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4)
|
||||
assert pager.page_label is not None and pager.page_label.text() == "2"
|
||||
pager.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 6), ((1710, 920), 9)],
|
||||
)
|
||||
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
||||
application: QApplication,
|
||||
kind: str,
|
||||
size: tuple[int, int],
|
||||
minimum_visible_rows: int,
|
||||
) -> None:
|
||||
page = _new_page(kind)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
_settle(application)
|
||||
|
||||
header = page.findChild(QWidget, "PageHeader")
|
||||
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
|
||||
toolbar = page.findChild(QFrame, toolbar_name)
|
||||
assert header is not None and 60 <= header.height() <= 64
|
||||
assert toolbar is not None and 44 <= toolbar.height() <= 48
|
||||
assert 40 <= page.pager.height() <= 44
|
||||
assert page.pager.minimumHeight() == page.pager.maximumHeight()
|
||||
assert page.table.minimumHeight() == 0
|
||||
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert _fully_visible_rows(page) >= minimum_visible_rows
|
||||
|
||||
pager_position = page.pager.mapTo(page, QPoint())
|
||||
assert pager_position.x() >= 0
|
||||
assert pager_position.x() + page.pager.width() <= page.width()
|
||||
assert pager_position.y() >= 0
|
||||
assert pager_position.y() + page.pager.height() <= page.height()
|
||||
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + (
|
||||
page.pager.page_size_label.width()
|
||||
)
|
||||
assert page_size_right <= page.width()
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
toolbar_margins = toolbar.layout().contentsMargins()
|
||||
assert pager_margins.left() == toolbar_margins.left() == 16
|
||||
assert pager_margins.right() == toolbar_margins.right() == 16
|
||||
|
||||
if kind == "issued":
|
||||
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
||||
assert filters is not None and 84 <= filters.height() <= 92
|
||||
else:
|
||||
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
|
||||
assert filters is not None
|
||||
assert page.name_filter.minimumWidth() < 500
|
||||
filter_right = filters.contentsRect().right()
|
||||
for control in (
|
||||
page.name_filter,
|
||||
page.formula_filter,
|
||||
page.visibility_filter,
|
||||
page.effect_filter,
|
||||
page.query_button,
|
||||
page.reset_button,
|
||||
):
|
||||
right = control.mapTo(filters, QPoint()).x() + control.width()
|
||||
assert right <= filter_right
|
||||
|
||||
page.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_density_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
|
||||
1366,
|
||||
768,
|
||||
),
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
|
||||
1710,
|
||||
920,
|
||||
),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1366x768.png": (1366, 768),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1710x920.png": (1710, 920),
|
||||
}
|
||||
for path, dimensions in expected.items():
|
||||
image = QImage(str(path))
|
||||
assert not image.isNull(), path
|
||||
assert (image.width(), image.height()) == dimensions
|
||||
@@ -16,6 +16,7 @@ from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
from doctor_workstation.ui.dialogs import prescription as dialog_module
|
||||
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
|
||||
from doctor_workstation.ui.dialogs.prescription import (
|
||||
DiagnosisDetailDialog,
|
||||
PrescriptionEditorDialog,
|
||||
PrescriptionOrderDialog,
|
||||
PrescriptionTemplateDialog,
|
||||
@@ -287,6 +288,125 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_order_detail_lookup_is_queued_before_repository_call(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
queued: list[tuple[Any, dict[str, Any]]] = []
|
||||
requested: list[int] = []
|
||||
shown: list[tuple[int, str]] = []
|
||||
|
||||
class Repository:
|
||||
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
|
||||
requested.append(order_id)
|
||||
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
|
||||
|
||||
def queue_async(function: Any, **options: Any) -> object:
|
||||
queued.append((function, options))
|
||||
return object()
|
||||
|
||||
def present_order_detail(
|
||||
_host: Any,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
order_id: int,
|
||||
permissions: Any,
|
||||
exec_: bool,
|
||||
) -> None:
|
||||
del permissions, exec_
|
||||
shown.append((order_id, order["order_no"]))
|
||||
|
||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
||||
dialog = DiagnosisDetailDialog(
|
||||
{"orders": [{"id": 17, "order_no": "ROW-17"}]},
|
||||
repository=Repository(),
|
||||
)
|
||||
table = dialog._order_detail_table
|
||||
button = dialog._order_detail_button
|
||||
assert table is not None
|
||||
assert button is not None
|
||||
table.setCurrentCell(0, 0)
|
||||
|
||||
button.click()
|
||||
|
||||
assert len(queued) == 1
|
||||
assert requested == []
|
||||
assert shown == []
|
||||
assert not table.isEnabled()
|
||||
assert not button.isEnabled()
|
||||
|
||||
function, options = queued[0]
|
||||
options["on_success"](function())
|
||||
options["on_finished"]()
|
||||
assert requested == [17]
|
||||
assert shown == [(17, "DETAIL-17")]
|
||||
assert table.isEnabled()
|
||||
assert button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
queued: list[dict[str, Any]] = []
|
||||
shown: list[tuple[int, str]] = []
|
||||
|
||||
def queue_async(_function: Any, **options: Any) -> object:
|
||||
queued.append(options)
|
||||
return object()
|
||||
|
||||
def present_order_detail(
|
||||
_host: Any,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
order_id: int,
|
||||
permissions: Any,
|
||||
exec_: bool,
|
||||
) -> None:
|
||||
del permissions, exec_
|
||||
shown.append((order_id, order["order_no"]))
|
||||
|
||||
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
|
||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
||||
dialog = DiagnosisDetailDialog(
|
||||
{
|
||||
"orders": [
|
||||
{"id": 21, "order_no": "ROW-21"},
|
||||
{"id": 22, "order_no": "ROW-22"},
|
||||
]
|
||||
},
|
||||
repository=repository,
|
||||
)
|
||||
table = dialog._order_detail_table
|
||||
button = dialog._order_detail_button
|
||||
assert table is not None
|
||||
assert button is not None
|
||||
|
||||
table.setCurrentCell(0, 0)
|
||||
dialog._open_selected_order()
|
||||
table.setCurrentCell(1, 0)
|
||||
dialog._open_selected_order()
|
||||
assert len(queued) == 2
|
||||
|
||||
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
|
||||
queued[0]["on_finished"]()
|
||||
assert shown == []
|
||||
assert not table.isEnabled()
|
||||
assert not button.isEnabled()
|
||||
|
||||
queued[1]["on_error"](RuntimeError("detail unavailable"))
|
||||
queued[1]["on_finished"]()
|
||||
assert shown == [(22, "ROW-22")]
|
||||
assert table.isEnabled()
|
||||
assert button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _finish_queued(callback: dict[str, Any], result: Any) -> None:
|
||||
callback["on_success"](result)
|
||||
if callback.get("on_finished"):
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -12,7 +12,7 @@ import httpx
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
@@ -283,9 +283,10 @@ def test_queue_uses_admin_same_day_contract(
|
||||
"status": 1,
|
||||
"start_date": date.today().isoformat(),
|
||||
"end_date": date.today().isoformat(),
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"patient_name": "王小明",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"patient_name": "王小明",
|
||||
"include_status_counts": 1,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -296,6 +297,167 @@ def test_queue_uses_admin_same_day_contract(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_reception_daily_records_use_backend_matrix_contract(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
today = date.today().isoformat()
|
||||
tracking_calls: list[tuple[int, str, str]] = []
|
||||
note_calls: list[int] = []
|
||||
|
||||
class Repository:
|
||||
fail_tracking = False
|
||||
|
||||
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": 71,
|
||||
"diagnosis_id": 271,
|
||||
# Production appointment.patient_id is the diagnosis id.
|
||||
"patient_id": 271,
|
||||
"patient_name": "日常记录患者",
|
||||
"status": 1,
|
||||
"appointment_time": "09:30:00",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
assert appointment_id == 71
|
||||
return {
|
||||
"appointment": {
|
||||
"id": 71,
|
||||
"patient_id": 271,
|
||||
"patient_name": "日常记录患者",
|
||||
"status": 1,
|
||||
},
|
||||
"diagnosis": {
|
||||
"id": 271,
|
||||
"patient_id": 971,
|
||||
"patient_name": "日常记录患者",
|
||||
"age": 56,
|
||||
},
|
||||
"tracking_notes": [
|
||||
{"note_date": today, "content": "内嵌备注降级数据"}
|
||||
],
|
||||
}
|
||||
|
||||
def get_tracking_window(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> dict[str, Any]:
|
||||
tracking_calls.append((diagnosis_id, start_date, end_date))
|
||||
if self.fail_tracking:
|
||||
raise RuntimeError("tracking unavailable")
|
||||
return {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"blood_records": [
|
||||
{
|
||||
"record_date": today,
|
||||
"record_time": "08:30:00",
|
||||
"fasting_blood_sugar": 9.6,
|
||||
"postprandial_blood_sugar": 11.4,
|
||||
"other_blood_sugar": 8.5,
|
||||
"systolic_pressure": 141,
|
||||
"diastolic_pressure": 90,
|
||||
"western_medicine": "二甲双胍",
|
||||
"insulin": "睡前 8U",
|
||||
"source": 1,
|
||||
}
|
||||
],
|
||||
"diet_records": [
|
||||
{
|
||||
"record_date": today,
|
||||
"breakfast_foods": ["小米粥"],
|
||||
"lunch_foods": ["杂粮饭"],
|
||||
"dinner_foods": ["青菜"],
|
||||
}
|
||||
],
|
||||
"exercise_records": [
|
||||
{
|
||||
"record_date": today,
|
||||
"exercise_type": "快走",
|
||||
"duration": 45,
|
||||
"intensity_text": "中等",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
note_calls.append(diagnosis_id)
|
||||
return [{"note_date": today, "content": "睡眠改善,继续随访"}]
|
||||
|
||||
repository = Repository()
|
||||
page = ReceptionPage(repository, PermissionSet([]))
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
|
||||
assert [
|
||||
page.detail_tabs.tabText(index) for index in range(page.detail_tabs.count())
|
||||
] == ["问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"]
|
||||
assert tracking_calls == [
|
||||
(271, (date.today() - timedelta(days=6)).isoformat(), today)
|
||||
]
|
||||
assert note_calls == [271]
|
||||
assert page._selection_context()[-1] == 971
|
||||
|
||||
matrix = page.daily_panel.matrix
|
||||
assert matrix.objectName() == "ReceptionDailyRecordsTable"
|
||||
assert matrix.rowCount() == 11
|
||||
assert matrix.columnCount() == 8
|
||||
assert matrix.horizontalHeaderItem(0).text() == "指标"
|
||||
assert matrix.horizontalHeaderItem(1).text() == today[5:]
|
||||
assert [matrix.item(row, 0).text() for row in range(11)] == [
|
||||
"空腹血糖",
|
||||
"餐后2h血糖",
|
||||
"其他血糖",
|
||||
"血压",
|
||||
"西药",
|
||||
"胰岛素",
|
||||
"早餐",
|
||||
"午餐",
|
||||
"晚餐",
|
||||
"运动",
|
||||
"跟踪备注",
|
||||
]
|
||||
assert matrix.item(0, 1).text() == "9.6 · 自录 ↑"
|
||||
assert matrix.item(0, 1).data(Qt.ItemDataRole.UserRole)["high"] is True
|
||||
assert matrix.item(1, 1).text() == "11.4 · 自录 ↑"
|
||||
assert matrix.item(2, 1).text() == "8.5 · 自录"
|
||||
assert matrix.item(2, 1).data(Qt.ItemDataRole.UserRole)["high"] is False
|
||||
assert matrix.item(3, 1).text() == "141/90 · 自录 ↑"
|
||||
assert matrix.item(4, 1).text() == "二甲双胍"
|
||||
assert matrix.item(5, 1).text() == "睡前 8U"
|
||||
assert matrix.item(6, 1).text() == "已记录"
|
||||
assert matrix.item(9, 1).text() == "45min"
|
||||
assert matrix.item(10, 1).text() == "睡眠改善,继续随访"
|
||||
assert "睡眠改善" in page.followup_text.text()
|
||||
|
||||
page.daily_panel.range_buttons["30"].click()
|
||||
assert tracking_calls[-1] == (
|
||||
271,
|
||||
(date.today() - timedelta(days=29)).isoformat(),
|
||||
today,
|
||||
)
|
||||
assert page.daily_panel.matrix.columnCount() == 31
|
||||
|
||||
preserved = page.daily_panel.matrix.item(0, 1).text()
|
||||
repository.fail_tracking = True
|
||||
page.daily_panel.refresh_button.click()
|
||||
assert page.daily_panel.matrix.item(0, 1).text() == preserved
|
||||
assert "已保留上次数据" in page.daily_panel.state.label.text()
|
||||
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_queue_date_picker_filters_the_selected_day(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
@@ -394,6 +556,69 @@ def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_silent_poll_replaces_only_the_queue_row_with_visible_changes(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
original_queue_row = reception_module.QueueRow
|
||||
constructed: list[int] = []
|
||||
|
||||
class CountingQueueRow(original_queue_row):
|
||||
def __init__(self, record: Any, parent: QWidget | None = None) -> None:
|
||||
constructed.append(int(record["id"]))
|
||||
super().__init__(record, parent)
|
||||
|
||||
monkeypatch.setattr(reception_module, "QueueRow", CountingQueueRow)
|
||||
|
||||
class Repository:
|
||||
calls = 0
|
||||
|
||||
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
self.calls += 1
|
||||
rows = [
|
||||
{
|
||||
"id": index,
|
||||
"diagnosis_id": 200 + index,
|
||||
"patient_id": 100 + index,
|
||||
"patient_name": f"患者{index}",
|
||||
"clinical_diagnosis": "消渴",
|
||||
"status": 1,
|
||||
}
|
||||
for index in range(1, 4)
|
||||
]
|
||||
if self.calls > 1:
|
||||
rows[1]["clinical_diagnosis"] = "消渴 · 气阴两虚"
|
||||
return {"lists": rows, "count": 3}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
"appointment": {"id": appointment_id, "patient_id": 100 + appointment_id},
|
||||
"diagnosis": {
|
||||
"id": 200 + appointment_id,
|
||||
"patient_id": 100 + appointment_id,
|
||||
},
|
||||
}
|
||||
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.refresh()
|
||||
items = [page.queue_list.item(index) for index in range(3)]
|
||||
widgets = [page.queue_list.itemWidget(item) for item in items]
|
||||
assert constructed == [1, 2, 3]
|
||||
|
||||
page.refresh(silent=True)
|
||||
|
||||
assert all(page.queue_list.item(index) is items[index] for index in range(3))
|
||||
assert page.queue_list.itemWidget(items[0]) is widgets[0]
|
||||
assert page.queue_list.itemWidget(items[1]) is not widgets[1]
|
||||
assert page.queue_list.itemWidget(items[2]) is widgets[2]
|
||||
assert constructed == [1, 2, 3, 2]
|
||||
changed_row = page.queue_list.itemWidget(items[1])
|
||||
assert changed_row.findChild(QLabel, "ReceptionQueueSubline").text() == "消渴 · 气阴两虚"
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_timer_poll_does_not_supersede_an_in_flight_queue_request(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -905,9 +1130,10 @@ def test_queue_worker_uses_frozen_widget_snapshot(
|
||||
"status": 1,
|
||||
"start_date": date.today().isoformat(),
|
||||
"end_date": date.today().isoformat(),
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"patient_name": "甲患者",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"patient_name": "甲患者",
|
||||
"include_status_counts": 1,
|
||||
}
|
||||
]
|
||||
page.close()
|
||||
@@ -1094,7 +1320,10 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
||||
|
||||
left = page.ai_analysis_card.geometry()
|
||||
right = page.ai_assistant_card.geometry()
|
||||
assert 470 <= left.height() <= 520
|
||||
assert left.height() < 470
|
||||
assert page.ai_analysis_card.minimumHeight() == 0
|
||||
assert page.ai_analysis_card.maximumHeight() > 520
|
||||
assert page.ai_analysis_card.findChildren(QScrollArea) == []
|
||||
assert left.height() == right.height()
|
||||
assert 0 <= right.left() - left.right() - 1 <= 2
|
||||
assert abs(left.width() * 5 - right.width() * 4) <= 10
|
||||
@@ -1464,7 +1693,6 @@ def test_ai_analysis_dialog_switches_complete_cached_payloads_without_requests(
|
||||
"qwen 风险项目 1",
|
||||
"qwen 风险项目 2",
|
||||
"qwen 风险项目 3",
|
||||
"qwen 风险项目 4",
|
||||
]
|
||||
calls_before_dialog = list(repository.analysis_calls)
|
||||
page.ai_analysis_expand_button.click()
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError
|
||||
from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, 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 (
|
||||
@@ -271,6 +271,51 @@ def test_remote_reception_is_forcibly_scoped_to_today() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_remote_reception_daily_records_use_admin_endpoints_exactly() -> None:
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.list_appointments(
|
||||
status=1,
|
||||
start_date="2026-08-11",
|
||||
end_date="2026-08-17",
|
||||
include_status_counts=1,
|
||||
page_no=1,
|
||||
page_size=15,
|
||||
)
|
||||
repository.get_reception(71)
|
||||
repository.get_tracking_window(
|
||||
271,
|
||||
start_date="2026-08-11",
|
||||
end_date="2026-08-17",
|
||||
)
|
||||
repository.list_tracking_notes(271)
|
||||
|
||||
assert client.get_calls[-4:] == [
|
||||
(
|
||||
"doctor.appointment/lists",
|
||||
{
|
||||
"status": 1,
|
||||
"start_date": "2026-08-11",
|
||||
"end_date": "2026-08-17",
|
||||
"include_status_counts": 1,
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
},
|
||||
),
|
||||
("doctor.appointment/reception", {"id": 71}),
|
||||
(
|
||||
"tcm.diagnosis/trackingWindow",
|
||||
{
|
||||
"id": 271,
|
||||
"start_date": "2026-08-11",
|
||||
"end_date": "2026-08-17",
|
||||
},
|
||||
),
|
||||
("tcm.diagnosis/trackingNotes", {"diagnosis_id": 271}),
|
||||
]
|
||||
|
||||
|
||||
def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
||||
"""Prescription, patient and diagnosis methods remain thin endpoint adapters."""
|
||||
|
||||
@@ -600,6 +645,56 @@ def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
|
||||
assert client.timeouts == [105.0]
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
|
||||
class StreamingClient(RecordingClient):
|
||||
def post_event_stream(self, endpoint: str, payload: dict[str, Any], **kwargs: Any):
|
||||
assert endpoint == "tcm.diagnosis/aiAssistantStream"
|
||||
assert payload == {"id": 501, "prompt": "请辨证", "task": "tcm_pattern"}
|
||||
assert kwargs["timeout"] == 105.0
|
||||
yield {"event": "start", "data": {"model_key": "qwen"}}
|
||||
yield {"event": "delta", "data": {"content": "肝郁"}}
|
||||
yield {"event": "delta", "data": {"delta": "脾虚"}}
|
||||
yield {"event": "done", "data": {"model_label": "千问"}}
|
||||
|
||||
client = StreamingClient()
|
||||
events = list(
|
||||
RemoteDoctorRepository(client).stream_diagnosis_ai(
|
||||
501,
|
||||
"请辨证",
|
||||
task="tcm_pattern",
|
||||
)
|
||||
)
|
||||
|
||||
assert [event["event"] for event in events] == ["start", "delta", "delta", "done"]
|
||||
assert "".join(event.get("text", "") for event in events) == "肝郁脾虚"
|
||||
assert client.post_calls == []
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() -> None:
|
||||
class MissingStreamClient(RecordingClient):
|
||||
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||
raise ApiHttpError("missing", status_code=404)
|
||||
|
||||
missing_client = MissingStreamClient()
|
||||
events = list(
|
||||
RemoteDoctorRepository(missing_client).stream_diagnosis_ai(501, "请分析")
|
||||
)
|
||||
assert [event["event"] for event in events] == ["start", "delta", "done"]
|
||||
assert events[1]["text"] == "服务端分析结果"
|
||||
assert [call[0] for call in missing_client.post_calls] == [
|
||||
"tcm.diagnosis/aiAssistant"
|
||||
]
|
||||
|
||||
class ErrorStreamClient(RecordingClient):
|
||||
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||
yield {"event": "error", "data": {"message": "模型繁忙"}}
|
||||
|
||||
error_client = ErrorStreamClient()
|
||||
with pytest.raises(ApiBusinessError, match="模型繁忙"):
|
||||
list(RemoteDoctorRepository(error_client).stream_diagnosis_ai(501, "请分析"))
|
||||
assert error_client.post_calls == []
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_analysis_uses_exact_post_contract() -> None:
|
||||
"""The legacy default is qwen, followed by an explicit OpenAI request."""
|
||||
|
||||
|
||||
@@ -26,10 +26,16 @@ class _ShellPageDouble(QWidget):
|
||||
self.permissions = permissions
|
||||
self.current_user = current_user
|
||||
self.refresh_count = 0
|
||||
self.show_count = 0
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.refresh_count += 1
|
||||
|
||||
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().showEvent(event)
|
||||
self.show_count += 1
|
||||
self.refresh()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
@@ -211,6 +217,34 @@ def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
|
||||
]
|
||||
|
||||
|
||||
def test_real_navigation_refreshes_once_and_current_page_click_is_a_noop(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
appointments = shell_window.pages["appointments"]
|
||||
reception = shell_window.pages["reception"]
|
||||
assert isinstance(appointments, _ShellPageDouble)
|
||||
assert isinstance(reception, _ShellPageDouble)
|
||||
assert appointments.refresh_count == 1
|
||||
assert appointments.show_count == 1
|
||||
assert reception.refresh_count == 0
|
||||
|
||||
shell_window.nav_buttons["reception"].click()
|
||||
application.processEvents()
|
||||
assert reception.refresh_count == 1
|
||||
assert reception.show_count == 1
|
||||
|
||||
shell_window.nav_buttons["reception"].click()
|
||||
application.processEvents()
|
||||
assert reception.refresh_count == 1
|
||||
assert reception.show_count == 1
|
||||
|
||||
shell_window.nav_buttons["appointments"].click()
|
||||
application.processEvents()
|
||||
assert appointments.refresh_count == 2
|
||||
assert appointments.show_count == 2
|
||||
|
||||
|
||||
def test_non_fixed_tabs_close_and_active_close_renavigates(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user