Files
zyt/app/tests/test_reception_parity_ui.py
T
2026-09-07 10:07:47 +08:00

2776 lines
94 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import os
from datetime import date, timedelta
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import (
QDate,
QEvent,
QEventLoop,
QObject,
QPoint,
Qt,
QThreadPool,
QTimer,
)
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import (
QApplication,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import api_client as api_client_module
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui import widgets as widgets_module
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import (
NOTE_LIMIT,
QueueRow,
ReceptionPage,
_is_image_attachment,
)
from doctor_workstation.ui.widgets import StatusBadge
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("width", [1114, 1320, 1494])
def test_visible_history_updates_grow_clinical_card_without_clipping(
application: QApplication,
queued_async: list[dict[str, Any]],
width: int,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(["*"]))
page.resize(width, 824)
page.show()
try:
application.processEvents()
page.detail_stack.setCurrentIndex(1)
for _ in range(8):
application.processEvents()
label = page.case_labels["present"]
clinical = page.clinical_info_group
initial_height = clinical.height()
initial_scroll_maximum = page.detail_scroll.verticalScrollBar().maximum()
history = (
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
)
label.setText(history)
application.processEvents()
assert label.text() == history
assert label.height() >= label.heightForWidth(label.width())
label.setText("\n".join([history] * 5))
application.processEvents()
assert label.height() >= label.heightForWidth(label.width())
assert clinical.height() > initial_height
assert page.detail_scroll.verticalScrollBar().maximum() > initial_scroll_maximum
assert label.maximumHeight() > label.height()
expanded_height = clinical.height()
label.setText("无特殊不适。")
application.processEvents()
assert label.height() >= label.heightForWidth(label.width())
assert clinical.height() < expanded_height
finally:
page.close()
page.deleteLater()
application.processEvents()
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(reception_module, "run_async", run_immediately)
@pytest.fixture
def queued_async(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Capture asynchronous work so tests can advance each model independently."""
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append(
{
"function": function,
"args": args,
**options,
}
)
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
return jobs
def test_api_client_reuses_a_bounded_transport_across_qt_runnables(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[Any] = []
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
return httpx.Response(200, json={"code": 1, "data": url})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_parallel_requests=2)
pool = QThreadPool()
pool.setMaxThreadCount(1)
pool.setExpiryTimeout(-1)
remaining = 40
results: list[str] = []
loop = QEventLoop()
def finished() -> None:
nonlocal remaining
remaining -= 1
if remaining == 0:
loop.quit()
for index in range(remaining):
widgets_module.run_async(
lambda index=index: client.get(f"patient/{index}"),
on_success=results.append,
on_finished=finished,
pool=pool,
)
QTimer.singleShot(5_000, loop.quit)
loop.exec()
assert pool.waitForDone(2_000)
client.close()
assert remaining == 0
assert len(results) == 40
assert len(created) == 1
assert created[0].closed
application.processEvents()
def test_clearing_ai_layout_does_not_promote_children_to_windows(
application: QApplication,
) -> None:
host = QWidget()
layout = QVBoxLayout(host)
dynamic_label = QLabel("正在加载患者 AI 分析", host)
layout.addWidget(dynamic_label)
host.show()
application.processEvents()
destroyed: list[bool] = []
dynamic_label.destroyed.connect(lambda: destroyed.append(True))
reception_module._clear_ai_layout(layout)
assert layout.count() == 0
assert destroyed == [True]
assert dynamic_label not in application.topLevelWidgets()
host.close()
host.deleteLater()
def test_queue_row_never_shows_loading_fields_as_parentless_windows(
application: QApplication,
) -> None:
tracked_names = {
"StatusBadge",
"ReceptionQueueTime",
"ReceptionQueueMetric",
}
class OrphanShowRecorder(QObject):
def __init__(self) -> None:
super().__init__()
self.object_names: list[str] = []
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
if (
event.type() == QEvent.Type.Show
and isinstance(watched, QWidget)
and watched.objectName() in tracked_names
and watched.parentWidget() is None
and watched.isWindow()
):
self.object_names.append(watched.objectName())
return False
recorder = OrphanShowRecorder()
application.installEventFilter(recorder)
try:
active_row = QueueRow(
{
"patient_name": "问诊患者",
"status": 2,
"status_desc": "问诊中",
"fasting_blood_sugar": 8.6,
}
)
waiting_row = QueueRow(
{
"patient_name": "待接诊患者",
"status": 1,
"appointment_time": "14:30",
}
)
finally:
application.removeEventFilter(recorder)
assert recorder.object_names == []
for row in (active_row, waiting_row):
for object_name in tracked_names:
widget = row.findChild(QWidget, object_name)
assert widget is not None
assert widget.parentWidget() is row
assert not widget.isWindow()
row.deleteLater()
@pytest.mark.parametrize(
("path", "expected"),
[
("https://cdn.test/tongue.JPG?token=1", True),
("https://cdn.test/report.webp", True),
("https://cdn.test/report.pdf", False),
],
)
def test_note_attachment_preview_type_is_extension_aware(path: str, expected: bool) -> None:
assert _is_image_attachment(path) is expected
def test_note_attachments_expand_images_and_keep_file_open(
application: QApplication,
immediate_async: None,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
page._render_notes(
[
{
"id": 1,
"note_date": "2026-08-12",
"tongue_images": ["https://cdn.test/tongue.jpg"],
"report_files": [
"https://cdn.test/check.png",
"https://cdn.test/check.pdf",
],
}
]
)
previews = page.notes_container.findChildren(
QPushButton, "NoteAttachmentPreview"
)
assert len(previews) == 2
assert all(button.text() == "" for button in previews)
assert all(not button.icon().isNull() for button in previews)
labels = [button.text() for button in page.notes_container.findChildren(QPushButton)]
assert labels.count("打开") == 1
page.close()
application.processEvents()
def test_queue_status_badge_is_not_clipped_in_narrow_panel(
application: QApplication,
) -> None:
row = QueueRow(
{
"patient_name": "张蒙",
"status": 1,
"status_desc": "问诊中",
"appointment_time": "12:45:00",
"gender": 1,
"age": 36,
"assistant_name": "苏亚梅",
}
)
row.setFixedWidth(280)
row.show()
application.processEvents()
badge = row.findChild(StatusBadge)
assert badge is not None
assert badge.height() >= 24
assert badge.width() >= 54
assert badge.geometry().right() < row.width()
row.close()
application.processEvents()
def test_queue_condition_summary_never_exposes_raw_dict(
application: QApplication,
) -> None:
row = QueueRow(
{
"patient_name": "结构患者",
"status": 1,
"gender": 1,
"age": 52,
"diagnosis": {
"clinical_diagnosis": "2 型糖尿病",
"chief_complaint": "口渴多饮",
"disease_course_text": "病程 10 年",
},
}
)
summary = row.findChild(QLabel, "ReceptionQueueSubline")
assert summary is not None
assert summary.text() == "2 型糖尿病 · 病程 10 年"
assert "{" not in summary.text()
fallback = QueueRow(
{
"patient_name": "待完善患者",
"status": 1,
"gender": 2,
"age": 40,
"diagnosis": {"unknown": {"raw": "不能泄露"}},
}
)
fallback_summary = fallback.findChild(QLabel, "ReceptionQueueSubline")
assert fallback_summary is not None
assert fallback_summary.text() == "暂无病情摘要"
assert "{" not in fallback_summary.text()
row.close()
fallback.close()
application.processEvents()
def _detail(
appointment_id: int,
*,
name: str,
status: int = 1,
phone: str = "13800138000",
) -> dict[str, Any]:
patient_id = appointment_id + 100
diagnosis_id = appointment_id + 200
return {
"appointment": {
"id": appointment_id,
"patient_id": patient_id,
"patient_name": name,
"status": status,
"appointment_date": date.today().isoformat(),
"appointment_time": "09:30",
"doctor_name": "张医生",
"assistant_name": "李医助",
"appointment_type_text": "复诊",
"channel_text": "线上",
"remark": "准时到诊",
},
"patient": {
"id": patient_id,
"phone": phone,
"gender": 2,
"age": 42,
"height": 165,
"weight": 55,
"region_text": "浙江省杭州市",
},
"diagnosis": {
"id": diagnosis_id,
"patient_id": patient_id,
"patient_name": name,
"phone": phone,
"chief_complaint": "反复口渴",
"present_illness": "持续两周",
"clinical_diagnosis": "消渴",
},
}
def _analysis_payload(
label: str = "高血糖风险",
*,
model: str = "qwen",
) -> dict[str, Any]:
is_openai = model == "openai"
return {
"diagnosis_advice": (
"OpenAI:建议结合客观检查复核当前糖尿病控制情况"
if is_openai
else "2 型糖尿病,血糖控制不佳"
),
"risk_assessment": [
{"label": label, "level": "high"},
{"label": "心血管风险", "level": "medium"},
{"label": "肾脏风险", "level": "low"},
],
"treatment_advice": (
"OpenAI:复核近期检查趋势并评估联合用药安全性。"
if is_openai
else "建议调整降糖方案,考虑联合用药,加强生活方式干预。"
),
"model_key": model,
"model_label": "OpenAI" if is_openai else "千问",
"model_name": "gpt-5.2" if is_openai else "qwen3.6-35b",
"generated_at": "2026-08-14 10:31:00" if is_openai else "2026-08-14 10:30:00",
}
def test_queue_uses_admin_same_day_contract(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ReceptionPage(Repository(), PermissionSet([]))
page.search_edit.setText(" 王小明 ")
page.refresh()
assert calls == [
{
"status": 1,
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 1,
"page_size": 15,
"patient_name": "王小明",
"include_status_counts": 1,
}
]
page.queue_tabs.setCurrentIndex(1)
assert calls[-1]["status"] == 4
assert calls[-1]["start_date"] == calls[-1]["end_date"]
page.close()
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,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ReceptionPage(Repository(), PermissionSet([]))
page.show()
application.processEvents()
page.filter_disclosure.button.click()
page.queue_date_button.click()
calendar = page.queue_date_button.calendarWidget()
assert calendar.isVisible()
page.queue_date_button._pick_date(QDate(2026, 8, 1))
assert not calendar.isVisible()
assert page.queue_date_button.text() == "2026-08-01"
assert page._queue_date == "2026-08-01"
assert calls[-1]["start_date"] == "2026-08-01"
assert calls[-1]["end_date"] == "2026-08-01"
page.close()
application.processEvents()
def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(10, name="轮询患者")
class Repository:
queue_calls = 0
detail_calls = 0
analysis_calls: list[tuple[int, str]]
def __init__(self) -> None:
self.analysis_calls = []
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
self.queue_calls += 1
return {
"lists": [
{
**detail["appointment"],
"diagnosis_id": detail["diagnosis"]["id"],
"clinical_diagnosis": "消渴",
"server_revision": self.queue_calls,
}
],
"count": 20 + self.queue_calls,
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 10
self.detail_calls += 1
return detail
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
assert diagnosis_id == 210
self.analysis_calls.append((diagnosis_id, model))
return _analysis_payload(model=model)
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page.refresh()
first_item = page.queue_list.item(0)
first_row = page.queue_list.itemWidget(first_item)
for _index in range(3):
page.poll_timer.timeout.emit()
assert repository.queue_calls == 4
assert repository.detail_calls == 1
assert repository.analysis_calls == [(210, "qwen"), (210, "openai")]
assert page.queue_list.item(0) is first_item
assert page.queue_list.itemWidget(first_item) is first_row
assert first_item.data(Qt.ItemDataRole.UserRole)["server_revision"] == 4
assert page.queue_summary.text() == "已加载 1 / 共 24 位患者"
page.refresh()
assert repository.detail_calls == 2
assert repository.analysis_calls == [(210, "qwen"), (210, "openai")]
assert page.queue_list.item(0) is first_item
page.close()
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,
) -> None:
jobs: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
jobs.append(options)
return object()
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(object(), PermissionSet([]))
page.refresh()
generation = page._queue_generation
for _index in range(3):
page.poll_timer.timeout.emit()
assert len(jobs) == 1
assert page._queue_generation == generation
jobs[0]["on_success"]({"lists": [], "count": 0})
jobs[0]["on_finished"]()
assert not page._queue_loading
page.close()
application.processEvents()
def test_silent_queue_error_does_not_replace_the_visible_banner(
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(reception_module, "run_async", queue_async)
page = ReceptionPage(object(), PermissionSet([]))
page.refresh(silent=True)
jobs[0]["on_error"](RuntimeError("background unavailable"))
assert page.queue_banner.label.text() == ""
jobs[0]["on_finished"]()
page.refresh()
jobs[1]["on_error"](RuntimeError("foreground unavailable"))
assert page.queue_banner.label.text()
assert page.queue_banner.property("kind") == "danger"
page.close()
application.processEvents()
def test_fast_patient_switch_rejects_late_detail(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(object(), PermissionSet(["*"]))
first = {"id": 11, "patient_id": 111, "diagnosis_id": 211, "patient_name": "甲患者"}
second = {"id": 22, "patient_id": 122, "diagnosis_id": 222, "patient_name": "乙患者"}
page._select_record(first)
page.note_edit.setPlainText("甲患者的未保存草稿")
page._pending_report_files = [r"C:\records\first.pdf"]
page._select_record(second)
assert len(callbacks) == 2
assert page.patient_name_label.text() == "乙患者"
assert page.note_edit.toPlainText() == ""
assert page._pending_report_files == []
callbacks[0]["on_success"]({"detail": _detail(11, name="甲患者")})
callbacks[0]["on_finished"]()
assert page._selected_appointment_id == 22
assert page.patient_name_label.text() == "乙患者"
assert page._selected_detail is None
callbacks[1]["on_success"]({"detail": _detail(22, name="乙患者")})
callbacks[1]["on_finished"]()
assert page._selected_detail == _detail(22, name="乙患者")
assert page.patient_name_label.text() == "乙患者"
assert "反复口渴" in page.diagnosis_text.text()
page.close()
application.processEvents()
def test_rapid_aba_switch_prioritizes_current_detail_and_skips_stale_bundles(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(73, name="甲患者")
second = _detail(74, name="乙患者")
class Repository:
def __init__(self) -> None:
self.calls: list[int] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
self.calls.append(appointment_id)
return first if appointment_id == 73 else second
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
page._select_record(second["appointment"])
page._select_record(first["appointment"])
assert [job["priority"] for job in queued_async] == [1, 2, 3]
for stale_job in queued_async[:2]:
assert stale_job["function"]() == {"cancelled": True}
stale_job["on_finished"]()
assert repository.calls == []
current_job = queued_async[2]
current_job["on_success"](current_job["function"]())
current_job["on_finished"]()
assert repository.calls == [73]
assert page._selected_appointment_id == 73
assert page.patient_name_label.text() == "甲患者"
assert not page._detail_loading
page.close()
application.processEvents()
def test_switching_patient_resets_stale_daily_panel_loading(
application: QApplication,
) -> None:
page = ReceptionPage(object(), PermissionSet([]))
page.daily_panel.set_loading(True)
assert page.daily_panel._loading
assert not page.daily_panel.refresh_button.isEnabled()
page._reset_detail_content(
seed={"id": 75, "patient_name": "新患者", "diagnosis_id": 275}
)
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert all(button.isEnabled() for button in page.daily_panel.range_buttons.values())
assert page.daily_panel.start_date.isEnabled()
assert page.daily_panel.end_date.isEnabled()
page.close()
application.processEvents()
def test_stale_daily_worker_skips_repository_after_patient_switch(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(76, name="日常甲患者")
second = _detail(77, name="日常乙患者")
class Repository:
def __init__(self) -> None:
self.tracking_calls: list[int] = []
def get_tracking_window(self, diagnosis_id: int, **_options: Any) -> dict[str, Any]:
self.tracking_calls.append(diagnosis_id)
return {}
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
queued_async[0]["on_success"]({"detail": first, "warnings": []})
page._request_daily_range("2026-08-13", "2026-08-19")
stale_daily_job = queued_async[1]
page._select_record(second["appointment"])
assert stale_daily_job["function"]() == {"cancelled": True}
assert repository.tracking_calls == []
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summary(
application: QApplication,
) -> None:
page = ReceptionPage(object(), PermissionSet([]))
detail = _detail(31, name="层级患者")
diagnosis = dict(detail["diagnosis"])
diagnosis.update(
{
"diagnosis_date": 0,
"diagnosis_type_text": "复诊",
"systolic": 168,
"diastolic": 102,
"blood_pressure_status": "偏高",
"fasting_blood_sugar": "空腹6.8",
"fasting_blood_sugar_status": "偏高",
"current_medications": ["二甲双胍缓释片早晚分服", "格列吡嗪餐前服用"],
"allergy_history_text": "青霉素过敏",
"symptoms": "口干口苦、多饮多汗、腰膝酸软。" * 14,
"tongue": "舌红、苔黄腻",
"pulse": "脉弦滑",
"past_history_text": "高血压、高脂血症",
"remark": "重点复核用药剂量与低血糖风险。【完整病例末尾】",
}
)
page.resize(1280, 800)
page.show()
application.processEvents()
page.detail_stack.setCurrentIndex(1)
medication_tab = next(
index for index in range(page.detail_tabs.count())
if page.detail_tabs.tabText(index) == "用药记录"
)
page.detail_tabs.setCurrentIndex(medication_tab)
application.processEvents()
page._render_case(
{**detail["appointment"], "has_prescription": True},
detail["patient"],
diagnosis,
)
application.processEvents()
assert page.diagnosis_card.objectName() == "ReceptionMedicationCaseCard"
assert page.diagnosis_card.accessibleName() == "完整病例"
assert page.medication_scroll.objectName() == "ReceptionMedicationScroll"
assert page.medication_scroll.accessibleName() == "用药记录与完整病例滚动区域"
assert page.medication_scroll.horizontalScrollBar().maximum() == 0
assert page.medication_scroll.verticalScrollBar().maximum() > 0
assert page.diagnosis_text.isHidden()
assert page.diagnosis_text.textFormat() == Qt.TextFormat.PlainText
assert page._case_summary_text == page.diagnosis_text.text()
assert "<" not in page._case_summary_text
assert "【完整病例末尾】" in page._case_summary_text
assert page.case_metric_labels["diagnosis_date"].text() == "未记录"
assert page.case_metric_labels["fasting_glucose"].text() == "6.8 mmol/L"
assert page.case_metric_labels["fasting_glucose"].property("alert") is True
assert page.case_metric_labels["blood_pressure"].text() == "168/102 mmHg"
medication = page.case_section_frames["medication"]
assert medication.isVisible()
medication_captions = [
label.text()
for label in medication.findChildren(QLabel, "ReceptionCaseFieldCaption")
]
medication_values = [
label
for label in medication.findChildren(QLabel, "ReceptionCaseFieldValue")
]
assert medication_captions == ["当前用药", "过敏史"]
assert medication_values[0].text().startswith("二甲双胍缓释片")
assert medication_values[0].property("important") is True
assert medication_values[1].property("risk") is True
title = page.findChild(QLabel, "ReceptionMedicationCaseTitle")
caption = medication.findChild(QLabel, "ReceptionCaseFieldCaption")
value = medication.findChild(QLabel, "ReceptionCaseFieldValue")
assert title is not None and title.font().pixelSize() == 18
assert caption is not None and caption.font().pixelSize() == 13
assert value is not None and value.font().pixelSize() == 14
assert title.font().weight() == 600
assert caption.font().weight() == 400
assert value.font().weight() == 600
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#5d6b80"
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
assert value.wordWrap()
assert value.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByMouse
for _ in range(5):
page.medication_scroll.verticalScrollBar().setValue(
page.medication_scroll.verticalScrollBar().maximum()
)
application.processEvents()
assert (
page.medication_scroll.verticalScrollBar().value()
== page.medication_scroll.verticalScrollBar().maximum()
)
page.close()
application.processEvents()
def test_current_detail_finishes_without_waiting_for_stale_patient_request(
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(reception_module, "run_async", queue_async)
page = ReceptionPage(object(), PermissionSet(["*"]))
first = {"id": 71, "patient_id": 171, "diagnosis_id": 271, "patient_name": "慢患者"}
second = {"id": 72, "patient_id": 172, "diagnosis_id": 272, "patient_name": "当前患者"}
page._select_record(first)
page._select_record(second)
jobs[1]["on_success"]({"detail": _detail(72, name="当前患者")})
jobs[1]["on_finished"]()
assert not page._detail_loading
assert page.ai_button.isEnabled()
jobs[0]["on_finished"]()
assert not page._detail_loading
assert page._selected_appointment_id == 72
assert page.ai_button.isEnabled()
page.close()
application.processEvents()
def test_queue_load_more_accumulates_to_total_boundary(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 23)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
assert page.queue_list.count() == 15
assert page._queue_has_more()
assert not hasattr(page, "load_more_button")
assert not hasattr(page, "add_patient_button")
page._load_more()
assert [call["page_no"] for call in calls] == [1, 2]
assert all(call["page_size"] == 15 for call in calls)
assert page.queue_list.count() == 22
assert page.queue_summary.text() == "共 22 位患者"
assert not page._queue_has_more()
page.close()
application.processEvents()
def test_silent_poll_keeps_already_loaded_queue_pages(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 28)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(dict(kwargs))
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
page._load_more()
assert page.queue_list.count() == 27
assert page._queue_page == 2
calls.clear()
page.refresh(silent=True)
assert page.queue_list.count() == 27
assert page._queue_page == 2
assert calls[-1]["page_no"] == 1
assert calls[-1]["page_size"] >= 27
page.close()
application.processEvents()
def test_queue_shows_loading_indicator_while_page_request_is_open(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
class Repository:
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
return {
"lists": [
{"id": 1, "patient_name": "加载患者", "status": 1},
],
"count": 16,
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": 1001,
"status": 1,
},
"diagnosis": {"id": 2001, "patient_id": 1001},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
assert page.queue_loading_indicator.isVisibleTo(page)
assert page.queue_loading_indicator.label.text() == "正在加载…"
assert page.queue_loading_indicator.spinner._timer.isActive()
queued_async[0]["on_success"](
{
"lists": [{"id": 1, "patient_name": "加载患者", "status": 1}],
"count": 16,
}
)
queued_async[0]["on_finished"]()
assert not page.queue_loading_indicator.isVisibleTo(page)
assert not page.queue_loading_indicator.spinner._timer.isActive()
page._load_more()
assert page.queue_loading_indicator.isVisibleTo(page)
load_more_job = queued_async[-1]
load_more_job["on_success"](
{
"lists": [{"id": 2, "patient_name": "第二页患者", "status": 1}],
"count": 16,
}
)
load_more_job["on_finished"]()
assert not page.queue_loading_indicator.isVisibleTo(page)
page.close()
application.processEvents()
def test_queue_scrolls_to_bottom_loads_next_page(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 23)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.resize(1280, 640)
page.show()
application.processEvents()
assert page.queue_list.count() == 15
scrollbar = page.queue_list.verticalScrollBar()
assert scrollbar.maximum() > 0
scrollbar.setValue(scrollbar.maximum())
application.processEvents()
assert [call["page_no"] for call in calls] == [1, 2]
assert page.queue_list.count() == 22
page.close()
application.processEvents()
def test_search_and_tab_changes_reset_accumulated_pages(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
if kwargs["status"] == 4:
rows = [{"id": 401, "patient_name": "过号患者", "status": 4}]
elif kwargs["patient_name"]:
rows = [{"id": 201, "patient_name": "搜索患者", "status": 1}]
else:
rows = [
{"id": index, "patient_name": f"患者{index}", "status": 1}
for index in range(1, 19)
]
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {"lists": rows[start : start + kwargs["page_size"]], "count": len(rows)}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": appointment_id + 1000,
"status": 1,
},
"diagnosis": {
"id": appointment_id + 2000,
"patient_id": appointment_id + 1000,
},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
page._load_more()
assert page.queue_list.count() == 18
page.search_edit.setText("搜索")
page.refresh()
assert page.queue_list.count() == 1
assert page._queue_page == 1
assert calls[-1]["patient_name"] == "搜索"
page.queue_tabs.setCurrentIndex(1)
assert page.queue_list.count() == 1
assert page._queue_page == 1
assert calls[-1]["status"] == 4
page.close()
application.processEvents()
def test_queue_worker_uses_frozen_widget_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs: list[Any] = []
calls: list[dict[str, Any]] = []
def queue_async(function: Any, **_options: Any) -> object:
jobs.append(function)
return object()
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(Repository(), PermissionSet([]))
page.search_edit.setText("甲患者")
page.refresh()
page.search_edit.blockSignals(True)
page.search_edit.setText("乙患者")
page.search_edit.blockSignals(False)
page.queue_tabs.blockSignals(True)
page.queue_tabs.setCurrentIndex(1)
page.queue_tabs.blockSignals(False)
jobs[0]()
assert calls == [
{
"status": 1,
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 1,
"page_size": 15,
"patient_name": "甲患者",
"include_status_counts": 1,
}
]
page.close()
application.processEvents()
def test_phone_permission_and_ungated_notify_video_actions(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(31, name="脱敏患者")
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 31
return detail
masked_page = ReceptionPage(Repository(), PermissionSet([]))
masked_page._select_record(detail["appointment"])
assert masked_page.patient_labels["phone"].text() == "138****8000"
assert not masked_page.notify_button.isHidden()
assert not masked_page.video_button.isHidden()
plain_page = ReceptionPage(Repository(), PermissionSet(["tcm.diagnosis/phonePlain"]))
plain_page._select_record(detail["appointment"])
assert plain_page.patient_labels["phone"].text() == "13800138000"
assert plain_page.appointment_labels["doctor"].text() == "张医生"
assert plain_page.appointment_labels["assistant"].text() == "李医助"
assert plain_page.patient_labels["region"].text() == "浙江省杭州市"
masked_page.close()
plain_page.close()
application.processEvents()
def test_im_consult_is_visible_immediately_after_history(
application: QApplication,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
page.resize(1280, 760)
page.detail_stack.setCurrentIndex(1)
page.show()
application.processEvents()
assert page.video_button.text() == "IM 问诊"
assert page.video_button.objectName() == "ReceptionImButton"
assert not page.video_button.isHidden()
assert page.history_button.geometry().right() < page.video_button.geometry().left()
assert page.video_button.geometry().right() < page.more_button.geometry().left()
assert "IM 问诊" not in [
action.text() for action in page.more_button.menu().actions()
]
page.close()
application.processEvents()
@pytest.mark.parametrize("width", [1280, 1494])
@pytest.mark.parametrize("permissions", [[], ["*"]])
def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
application: QApplication,
queued_async: list[dict[str, Any]],
width: int,
permissions: list[str],
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(permissions))
page.resize(width, 760)
page.show()
try:
application.processEvents()
# The first show initiates a queue reload and clears the selection.
# Present a synthetic selected patient after that initial reset.
page.patient_name_label.setText("测试患者")
page.patient_meta_label.setText("女 · 42岁 · 138****8000 | 就诊号:31")
page.detail_stack.setCurrentIndex(1)
for _ in range(4):
application.processEvents()
hero = page.notify_button.parentWidget()
assert hero.objectName() == "ReceptionHero"
assert page.notify_button.isVisibleTo(page)
assert page.notify_button.text() == "通知医助"
assert page.notify_button.width() >= page.notify_button.sizeHint().width()
assert page.notify_button.height() > 0
assert "通知医助" not in [action.text() for action in page.more_button.menu().actions()]
buttons = [
button
for button in (
page.notify_button,
page.history_button,
page.video_button,
page.more_button,
page.complete_button,
)
if button.isVisibleTo(page)
]
for button in buttons:
assert hero.rect().contains(button.geometry())
assert button.width() >= button.sizeHint().width()
right = button.mapTo(page.detail_scroll.viewport(), QPoint(button.width(), 0)).x()
assert right <= page.detail_scroll.viewport().width()
for previous, following in zip(buttons, buttons[1:], strict=False):
assert previous.geometry().right() < following.geometry().left()
finally:
page.close()
application.processEvents()
def test_notify_header_button_keeps_appointment_context_and_pending_state(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
sent: list[int] = []
class Repository:
def notify_assistant(self, appointment_id: int) -> dict[str, bool]:
sent.append(appointment_id)
return {"success": True}
page = ReceptionPage(Repository(), PermissionSet([]))
try:
page._update_action_state({}, {})
assert not page.notify_button.isEnabled()
page._selected_appointment_id = 31
page._selected_record = {"id": 31, "status": 1}
page._update_action_state(page._selected_record, {})
assert page.notify_button.isEnabled()
queued_async.clear()
page.notify_button.click()
assert not page.notify_button.isEnabled()
page.notify_button.click()
assert len(queued_async) == 1
notification = queued_async.pop()
notification["function"]()
assert sent == [31]
notification["on_finished"]()
assert page.notify_button.isEnabled()
page.notify_button.click()
notification = queued_async.pop()
page._selected_appointment_id = 32
page._selected_record = {"id": 32, "status": 1}
page._detail_generation += 1
# An old request still targets its original appointment and cannot
# re-enable a pending notification for a newly selected patient.
notification["function"]()
notification["on_finished"]()
assert sent == [31, 31]
assert not page.notify_button.isEnabled()
finally:
page.close()
application.processEvents()
def test_reception_ai_report_button_follows_permission(
application: QApplication,
immediate_async: None,
) -> None:
hidden = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
assert hidden.ai_button.isHidden()
hidden.close()
page = ReceptionPage(
DemoDoctorRepository(),
PermissionSet(["doctor.appointment/reception"]),
)
assert not page.ai_button.isHidden()
assert not page.ai_button.isEnabled()
assert not page.ai_question_edit.isEnabled()
assert not page.ai_send_button.isEnabled()
page.close()
application.processEvents()
def test_reception_assistant_maps_prompt_to_report_model(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(32, name="AI 患者")
opened: list[dict[str, Any]] = []
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 32
return detail
monkeypatch.setattr(
reception_module,
"present_diagnosis_ai_assistant",
lambda repository, parent, **options: opened.append(options),
)
page = ReceptionPage(
Repository(),
PermissionSet(["doctor.appointment/reception", "tcm.diagnosis/aiAssistant"]),
)
page._select_record(detail["appointment"])
assert page.ai_question_edit.isEnabled()
assert page.ai_question_edit.maxLength() == 500
assert all(button.isEnabled() for button in page.ai_prompt_buttons)
page._open_ai_report_for_prompt("并发症筛查")
page.ai_question_edit.setText("请给出中药用药调整建议")
page._submit_ai_question()
assert [item["diagnosis_id"] for item in opened] == [232, 232]
assert [item["task"] for item in opened] == [
"complication_risk",
"medication_review",
]
assert opened[1]["prompt"] == "请给出中药用药调整建议"
page.close()
application.processEvents()
def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geometry(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(33, name="智能分析患者")
analysis_calls: list[tuple[int, str]] = []
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 33
return detail
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
analysis_calls.append((diagnosis_id, model))
return _analysis_payload(model=model)
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/aiAnalysis",
"tcm.diagnosis/aiAssistant",
]
),
)
page.resize(1494, 832)
page.show()
page._select_record(detail["appointment"])
application.processEvents()
assert analysis_calls == [(233, "qwen"), (233, "openai")]
assert [
(
page.ai_analysis_model_selector.itemText(index),
page.ai_analysis_model_selector.itemData(index),
)
for index in range(page.ai_analysis_model_selector.count())
] == [("千问", "qwen"), ("OpenAI", "openai")]
assert page.ai_analysis_model_selector.currentData() == "qwen"
assert page._ai_analysis_state == "success"
assert page.ai_analysis_stack.currentWidget() is page.ai_analysis_content_page
assert page.ai_summary_label.isVisible()
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert page.ai_treatment_label.text().startswith("建议调整降糖方案")
assert page.ai_summary_label.textFormat() == Qt.TextFormat.PlainText
assert page.ai_treatment_label.textFormat() == Qt.TextFormat.PlainText
chips = [
label
for label in page.ai_risk_chip_host.findChildren(QLabel)
if label.property("receptionRiskChip")
]
assert [label.text() for label in chips] == ["高血糖风险", "心血管风险", "肾脏风险"]
assert all(label.textFormat() == Qt.TextFormat.PlainText for label in chips)
assert [label.property("riskLevel") for label in chips] == ["high", "medium", "low"]
assert "千问" in page.ai_analysis_title.toolTip()
assert [button.text() for button in page.ai_prompt_buttons] == [
"基于当前病史,下一步检查建议?",
"该患者的用药调整建议?",
"糖尿病教育要点有哪些?",
"并发症筛查建议?",
]
assert page.ai_send_button.text() == ""
assert page.ai_send_button.accessibleName() == "发送 AI 问诊问题"
expand_button = page.ai_analysis_expand_button
assert expand_button.objectName() == "ReceptionAiAnalysisExpandButton"
assert expand_button.property("iconKind") == "expand-corners"
assert expand_button.text() == ""
assert expand_button.accessibleName() == "查看完整 AI 智能分析"
assert expand_button.toolTip() == "放大查看 AI 智能分析"
assert expand_button.isEnabled()
assert expand_button.focusPolicy() != Qt.FocusPolicy.NoFocus
assert expand_button.size().width() == expand_button.size().height() == 28
title_right = page.ai_analysis_title.mapTo(
page.ai_analysis_card,
QPoint(page.ai_analysis_title.width(), 0),
).x()
button_left = expand_button.mapTo(page.ai_analysis_card, QPoint(0, 0)).x()
button_right = expand_button.mapTo(
page.ai_analysis_card,
QPoint(expand_button.width(), 0),
).x()
assert button_left > title_right
assert button_right <= page.ai_analysis_card.contentsRect().right() + 1
left = page.ai_analysis_card.geometry()
right = page.ai_assistant_card.geometry()
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 right.left() - left.right() - 1 == 10
assert abs(left.width() / (left.width() + right.width()) - 0.425) < 0.01
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
assert page.ai_summary_label.minimumSizeHint().width() <= 48
calls_before_switch = list(analysis_calls)
page.ai_analysis_model_selector.setCurrentIndex(
page.ai_analysis_model_selector.findData("openai")
)
assert page.ai_analysis_model_selector.currentData() == "openai"
assert page.ai_summary_label.text().startswith("OpenAI")
page.ai_analysis_model_selector.setCurrentIndex(
page.ai_analysis_model_selector.findData("qwen")
)
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert analysis_calls == calls_before_switch
assert page.detail_scroll.objectName() == "ReceptionDetailScroll"
assert (
page.detail_scroll.verticalScrollBarPolicy()
== Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
page.resize(1494, 620)
application.processEvents()
assert page.detail_scroll.verticalScrollBar().maximum() > 0
page.close()
application.processEvents()
def test_reception_dual_ai_queues_openai_only_after_qwen_is_visible(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
detail = _detail(83, name="双模型排队患者")
calls: list[tuple[int, str]] = []
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
calls.append((diagnosis_id, model))
return _analysis_payload(f"{model} 排队风险", model=model)
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page._select_record(detail["appointment"])
assert len(queued_async) == 1
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
queued_async[0]["on_finished"]()
assert len(queued_async) == 2
assert calls == []
assert page.ai_analysis_model_selector.currentData() == "qwen"
assert page._ai_analysis_model_states == {"qwen": "loading", "openai": "waiting"}
qwen_result = queued_async[1]["function"](*queued_async[1]["args"])
assert calls == [(283, "qwen")]
queued_async[1]["on_success"](qwen_result)
assert len(queued_async) == 3
assert calls == [(283, "qwen")]
assert page.ai_analysis_model_selector.currentData() == "qwen"
assert page._ai_analysis_state == "success"
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert page._ai_analysis_payloads["qwen"] == qwen_result
assert page._ai_analysis_model_states["openai"] == "loading"
assert page.ai_analysis_secondary_status.property("secondaryState") == "loading"
assert "OpenAI 正在生成" in page.ai_analysis_secondary_status.text()
assert page.ai_analysis_expand_button.isEnabled()
openai_result = queued_async[2]["function"](*queued_async[2]["args"])
assert calls == [(283, "qwen"), (283, "openai")]
queued_async[2]["on_success"](openai_result)
assert page.ai_analysis_model_selector.currentData() == "qwen"
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
jobs_before_switch = len(queued_async)
calls_before_switch = list(calls)
page.ai_analysis_model_selector.setCurrentIndex(
page.ai_analysis_model_selector.findData("openai")
)
assert page.ai_summary_label.text().startswith("OpenAI")
assert "openai 排队风险" in page.ai_risk_label.text()
page.ai_analysis_model_selector.setCurrentIndex(
page.ai_analysis_model_selector.findData("qwen")
)
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert len(queued_async) == jobs_before_switch
assert calls == calls_before_switch
page.close()
application.processEvents()
def test_saturated_fallback_ai_slots_do_not_leave_current_analysis_loading(
application: QApplication,
queued_async: list[dict[str, Any]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(86, name="后台分析繁忙患者")
calls: list[tuple[int, str]] = []
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
calls.append((diagnosis_id, model))
raise AssertionError("busy automatic work must not enter repository")
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
automatic_qwen_job = queued_async[1]
deferred = automatic_qwen_job["function"](*automatic_qwen_job["args"])
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_reception_openai_failure_keeps_qwen_success_visible(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
detail = _detail(84, name="第二模型失败患者")
calls: list[tuple[int, str]] = []
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
calls.append((diagnosis_id, model))
if model == "openai":
raise RuntimeError("openai upstream unavailable")
return _analysis_payload("保留的千问风险", model=model)
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
qwen_result = queued_async[1]["function"](*queued_async[1]["args"])
queued_async[1]["on_success"](qwen_result)
assert len(queued_async) == 3
with pytest.raises(RuntimeError) as failure:
queued_async[2]["function"](*queued_async[2]["args"])
queued_async[2]["on_error"](failure.value)
assert calls == [(284, "qwen"), (284, "openai")]
assert page.ai_analysis_model_selector.currentData() == "qwen"
assert page._ai_analysis_state == "success"
assert page._ai_analysis_payload == qwen_result
assert page._ai_analysis_payloads == {"qwen": qwen_result}
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert "保留的千问风险" in page.ai_risk_label.text()
assert page.ai_analysis_expand_button.isEnabled()
assert page.ai_analysis_secondary_status.property("secondaryState") == "error"
assert "千问结果已保留" in page.ai_analysis_secondary_status.text()
assert "OpenAI分析加载失败" in page.ai_analysis_secondary_status.text()
page.close()
application.processEvents()
def test_reception_ai_analysis_permission_error_retry_states(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(34, name="状态患者")
class Repository:
def __init__(self) -> None:
self.fail = True
self.calls: list[tuple[int, str]] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return detail
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
self.calls.append((diagnosis_id, model))
if self.fail:
raise RuntimeError("analysis unavailable")
return _analysis_payload("重试后风险", model=model)
denied_repository = Repository()
denied = ReceptionPage(denied_repository, PermissionSet([]))
denied._select_record(detail["appointment"])
assert denied_repository.calls == []
assert denied._ai_analysis_state == "permission"
assert denied._ai_analysis_payload is None
assert denied._ai_analysis_payloads == {}
assert not denied.ai_analysis_model_selector.isEnabled()
assert not denied.ai_analysis_expand_button.isEnabled()
assert "没有查看" in denied.ai_analysis_state_label.text()
assert denied.ai_analysis_retry_button.isHidden()
denied.close()
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page.show()
page._select_record(detail["appointment"])
application.processEvents()
assert repository.calls == [(234, "qwen")]
assert page._ai_analysis_state == "error"
assert page._ai_analysis_payload is None
assert not page.ai_analysis_expand_button.isEnabled()
assert "加载失败" in page.ai_analysis_state_label.text()
assert not page.ai_analysis_retry_button.isHidden()
page._load_detail(detail["appointment"], silent=True, clear=False)
assert repository.calls == [(234, "qwen")]
assert page._ai_analysis_state == "error"
repository.fail = False
page.ai_analysis_retry_button.click()
application.processEvents()
assert repository.calls == [
(234, "qwen"),
(234, "qwen"),
(234, "openai"),
]
assert page._ai_analysis_state == "success"
assert page.ai_analysis_expand_button.isEnabled()
assert "重试后风险" in page.ai_risk_label.text()
page.close()
application.processEvents()
def test_saved_diagnosis_forces_a_fresh_structured_ai_analysis(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(38, name="病历更新患者")
class Repository:
def __init__(self) -> None:
self.analysis_calls: list[tuple[int, str]] = []
self.model_counts = {"qwen": 0, "openai": 0}
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
return {
"lists": [
{
**detail["appointment"],
"diagnosis_id": detail["diagnosis"]["id"],
}
],
"count": 1,
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 38
return detail
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
assert diagnosis_id == 238
self.analysis_calls.append((diagnosis_id, model))
self.model_counts[model] += 1
return _analysis_payload(
f"第 {self.model_counts[model]}{model}分析",
model=model,
)
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page.refresh()
assert repository.analysis_calls == [(238, "qwen"), (238, "openai")]
page._diagnosis_saved()
assert repository.analysis_calls == [
(238, "qwen"),
(238, "openai"),
(238, "qwen"),
(238, "openai"),
]
assert "第 2 次qwen分析" in page.ai_risk_label.text()
page.close()
application.processEvents()
def test_ai_analysis_dialog_switches_complete_cached_payloads_without_requests(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(39, name="完整分析患者")
diagnosis_texts = {
"qwen": ("千问诊断建议需要完整展示,不能在卡片中被截断。" * 80)
+ "[千问诊断末尾]",
"openai": ("OpenAI 诊断建议需要独立完整展示并支持模型对照。" * 80)
+ "[OpenAI诊断末尾]",
}
treatment_texts = {
"qwen": ("千问治疗建议应支持长文本滚动阅读,并允许复制。" * 80)
+ "[千问治疗末尾]",
"openai": ("OpenAI 治疗建议应保留全部上下文与检查复核事项。" * 80)
+ "[OpenAI治疗末尾]",
}
payloads = {
model: {
"diagnosis_advice": diagnosis_texts[model],
"risk_assessment": [
{
"label": f"{model} 风险项目 {index}",
"level": ("high", "medium", "low")[(index - 1) % 3],
}
for index in range(1, 7)
],
"treatment_advice": treatment_texts[model],
"model_key": model,
"model_label": "千问" if model == "qwen" else "OpenAI",
"model_name": "qwen3.6-35b" if model == "qwen" else "gpt-5.2",
"generated_at": (
"2026-08-14 11:05:14"
if model == "qwen"
else "2026-08-14 11:06:20"
),
"raw_marker": {
"model": model,
"tail": f"{model}-payload-complete",
},
}
for model in ("qwen", "openai")
}
class Repository:
def __init__(self) -> None:
self.analysis_calls: list[tuple[int, str]] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 39
return detail
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
assert diagnosis_id == 239
self.analysis_calls.append((diagnosis_id, model))
return payloads[model]
dialogs: list[Any] = []
def capture_dialog(dialog: Any) -> int:
dialogs.append(dialog)
dialog.show()
application.processEvents()
return 0
monkeypatch.setattr(reception_module._ReceptionAiAnalysisDialog, "exec", capture_dialog)
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page.resize(1494, 832)
page.show()
page._select_record(detail["appointment"])
application.processEvents()
card_risks = [
label
for label in page.ai_risk_chip_host.findChildren(QLabel)
if label.property("receptionRiskChip")
]
assert [label.text() for label in card_risks] == [
"qwen 风险项目 1",
"qwen 风险项目 2",
"qwen 风险项目 3",
]
calls_before_dialog = list(repository.analysis_calls)
page.ai_analysis_expand_button.click()
assert calls_before_dialog == [(239, "qwen"), (239, "openai")]
assert repository.analysis_calls == calls_before_dialog
assert len(dialogs) == 1
dialog = dialogs[0]
assert dialog.objectName() == "ReceptionAiAnalysisDialog"
assert dialog.parent() is page
assert dialog.isModal()
assert dialog.property("businessDialog") is True
scroll = dialog.findChild(QScrollArea, "ReceptionAiAnalysisDialogScroll")
assert scroll is not None
assert scroll.widgetResizable()
assert (
scroll.horizontalScrollBarPolicy()
== Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
diagnosis_label = dialog.findChild(
QLabel,
"ReceptionAiAnalysisDialogDiagnosisText",
)
treatment_label = dialog.findChild(
QLabel,
"ReceptionAiAnalysisDialogTreatmentText",
)
assert diagnosis_label is not None
assert treatment_label is not None
assert [
(dialog.model_selector.itemText(index), dialog.model_selector.itemData(index))
for index in range(dialog.model_selector.count())
] == [("千问", "qwen"), ("OpenAI", "openai")]
assert dialog.model_selector.currentData() == "qwen"
assert diagnosis_label.text() == diagnosis_texts["qwen"]
assert treatment_label.text() == treatment_texts["qwen"]
assert diagnosis_label.text().endswith("[千问诊断末尾]")
assert treatment_label.text().endswith("[千问治疗末尾]")
assert diagnosis_label.textFormat() == Qt.TextFormat.PlainText
assert treatment_label.textFormat() == Qt.TextFormat.PlainText
dialog_risks = [
label
for label in dialog.findChildren(QLabel)
if label.property("dialogAiRisk")
]
assert [label.text() for label in dialog_risks] == [
f"qwen 风险项目 {index}" for index in range(1, 7)
]
assert [label.property("riskLevel") for label in dialog_risks] == [
"high",
"medium",
"low",
"high",
"medium",
"low",
]
assert dialog.payloads == payloads
assert dialog.payloads["qwen"]["raw_marker"]["tail"] == "qwen-payload-complete"
dialog.model_selector.setCurrentIndex(dialog.model_selector.findData("openai"))
application.processEvents()
assert dialog.active_model == "openai"
assert diagnosis_label.text() == diagnosis_texts["openai"]
assert treatment_label.text() == treatment_texts["openai"]
assert diagnosis_label.text().endswith("[OpenAI诊断末尾]")
assert treatment_label.text().endswith("[OpenAI治疗末尾]")
assert "OpenAI" in dialog.meta_label.text()
dialog_risks = [
label
for label in dialog.findChildren(QLabel)
if label.property("dialogAiRisk")
]
assert [label.text() for label in dialog_risks] == [
f"openai 风险项目 {index}" for index in range(1, 7)
]
assert [label.property("riskLevel") for label in dialog_risks] == [
"high",
"medium",
"low",
"high",
"medium",
"low",
]
assert dialog.payloads["openai"]["raw_marker"]["tail"] == (
"openai-payload-complete"
)
assert repository.analysis_calls == calls_before_dialog
dialog.resize(640, 420)
application.processEvents()
assert scroll.verticalScrollBar().maximum() > 0
assert dialog.close_button.accessibleName() == "关闭 AI 智能分析详情"
dialog.close_button.click()
assert not dialog.isVisible()
page.close()
application.processEvents()
def test_legacy_single_model_repository_survives_same_patient_detail_refresh(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs: list[dict[str, Any]] = []
calls: list[int] = []
def queue_async(function: Any, **options: Any) -> object:
jobs.append({"function": function, **options})
return object()
class Repository:
def get_diagnosis_ai_analysis(self, diagnosis_id: int) -> dict[str, Any]:
calls.append(diagnosis_id)
result = _analysis_payload("刷新期间返回")
result["diagnosis_advice"] = "详情刷新后仍接收"
return result
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
detail = _detail(37, name="同一患者")
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
assert len(jobs) == 2
assert page._ai_analysis_state == "loading"
page._load_detail(detail["appointment"], silent=True, clear=False)
assert len(jobs) == 3
jobs[2]["on_success"]({"detail": detail, "warnings": []})
assert len(jobs) == 3
result = jobs[1]["function"]()
jobs[1]["on_success"](result)
assert calls == [237]
assert len(jobs) == 3
assert page._ai_analysis_state == "success"
assert page.ai_summary_label.text() == "详情刷新后仍接收"
assert "刷新期间返回" in page.ai_risk_label.text()
assert page._ai_analysis_model_states["openai"] == "unsupported"
assert page.ai_analysis_secondary_status.property("secondaryState") == "unsupported"
assert "仅支持千问" in page.ai_analysis_secondary_status.text()
page.close()
application.processEvents()
def test_detail_failure_stops_ai_loading_and_keeps_retry_available(
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(reception_module, "run_async", queue_async)
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
raise AssertionError(
"queued async test must not execute AI worker inline: "
f"{diagnosis_id}/{model}"
)
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
record = {
"id": 81,
"patient_id": 181,
"diagnosis_id": 281,
"patient_name": "失败患者",
}
page._select_record(record)
assert page._ai_analysis_state == "loading"
jobs[0]["on_error"](RuntimeError("detail unavailable"))
assert page._ai_analysis_state == "error"
assert not page._ai_analysis_loading
assert "病历加载失败" in page.ai_analysis_state_label.text()
assert not page.ai_analysis_retry_button.isHidden()
jobs[0]["on_finished"]()
assert not page._detail_loading
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_finished_only_detail_request_cannot_leave_loading_placeholders(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
page = ReceptionPage(object(), PermissionSet(["*"]))
record = {
"id": 86,
"patient_id": 186,
"diagnosis_id": 286,
"patient_name": "无结果患者",
}
page._select_record(record)
queued_async[0]["on_finished"]()
assert not page._detail_loading
assert page._selected_detail is None
assert page.detail_banner.property("kind") == "danger"
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert page._ai_analysis_state == "error"
page.close()
application.processEvents()
def test_finished_only_patient_ai_query_becomes_retryable(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
detail = _detail(87, name="AI 无结果患者")
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
assert len(queued_async) == 2
assert page._ai_analysis_state == "loading"
queued_async[1]["on_finished"]()
assert page._ai_analysis_state == "error"
assert not page._ai_analysis_loading
assert "请重试" in page.ai_analysis_state_label.text()
assert not page.ai_analysis_retry_button.isHidden()
page.ai_analysis_retry_button.click()
assert len(queued_async) == 3
assert page._ai_analysis_state == "loading"
page.close()
application.processEvents()
def test_ai_success_clears_loading_before_worker_finished(
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(reception_module, "run_async", queue_async)
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
raise AssertionError(
"queued async test must not execute AI worker inline: "
f"{diagnosis_id}/{model}"
)
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
detail = _detail(82, name="成功患者")
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
assert len(jobs) == 2
assert page._ai_analysis_loading
jobs[1]["on_success"](_analysis_payload())
assert len(jobs) == 3
assert page._ai_analysis_state == "success"
assert not page._ai_analysis_loading
assert page._ai_analysis_model_states["openai"] == "loading"
page.close()
application.processEvents()
def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
raise AssertionError("queued async test must not execute worker inline")
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
first = _detail(35, name="第一位患者")
second = _detail(36, name="第二位患者")
third = _detail(37, name="第三位患者")
page._select_record(first["appointment"])
queued_async[0]["on_success"]({"detail": first, "warnings": []})
assert len(queued_async) == 2
page._select_record(second["appointment"])
assert queued_async[1]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[2]["on_success"]({"detail": second, "warnings": []})
assert len(queued_async) == 4
second_qwen = _analysis_payload("第二位千问风险")
second_qwen["diagnosis_advice"] = "第二位患者千问分析"
queued_async[3]["on_success"](second_qwen)
assert len(queued_async) == 5
page._select_record(third["appointment"])
assert queued_async[4]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[5]["on_success"]({"detail": third, "warnings": []})
assert len(queued_async) == 7
third_qwen = _analysis_payload("第三位千问风险")
third_qwen["diagnosis_advice"] = "第三位患者千问分析"
queued_async[6]["on_success"](third_qwen)
assert len(queued_async) == 8
third_openai = _analysis_payload("第三位 OpenAI 风险", model="openai")
third_openai["diagnosis_advice"] = "第三位患者 OpenAI 分析"
queued_async[7]["on_success"](third_openai)
assert page._selected_appointment_id == 37
assert page._ai_analysis_payloads == {
"qwen": third_qwen,
"openai": third_openai,
}
assert page.ai_summary_label.text() == "第三位患者千问分析"
jobs_before_stale_results = len(queued_async)
stale_qwen = _analysis_payload("第一位迟到千问风险")
stale_qwen["diagnosis_advice"] = "第一位患者迟到千问结果"
queued_async[1]["on_success"](stale_qwen)
stale_openai = _analysis_payload("第二位迟到 OpenAI 风险", model="openai")
stale_openai["diagnosis_advice"] = "第二位患者迟到 OpenAI 结果"
queued_async[4]["on_success"](stale_openai)
assert len(queued_async) == jobs_before_stale_results
assert page._selected_appointment_id == 37
assert page._ai_analysis_payloads == {
"qwen": third_qwen,
"openai": third_openai,
}
assert page.ai_summary_label.text() == "第三位患者千问分析"
assert "第三位千问风险" in page.ai_risk_label.text()
page.ai_analysis_model_selector.setCurrentIndex(
page.ai_analysis_model_selector.findData("openai")
)
assert page.ai_summary_label.text() == "第三位患者 OpenAI 分析"
assert "第三位 OpenAI 风险" in page.ai_risk_label.text()
page.close()
application.processEvents()
def test_video_payload_keeps_three_identifiers_distinct(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(41, name="视频患者")
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 41
return detail
page = ReceptionPage(Repository(), PermissionSet([]))
page._select_record(detail["appointment"])
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page._request_video()
assert emitted == [
{
"source": "reception",
"appointment_id": 41,
"patient_id": 141,
"diagnosis_id": 241,
"patient_name": "视频患者",
"mode": "im",
"record": detail["appointment"],
}
]
page.close()
application.processEvents()
def test_completion_revalidates_server_status_before_write(
application: QApplication,
) -> None:
completed: list[int] = []
class Repository:
status = 3
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": 151,
"status": self.status,
}
}
def complete_appointment(self, appointment_id: int) -> dict[str, bool]:
completed.append(appointment_id)
return {"ok": True}
repository = Repository()
page = ReceptionPage(repository, PermissionSet(["doctor.appointment/complete"]))
with pytest.raises(ValueError, match="状态已变化"):
page._complete_after_revalidation(51)
assert completed == []
repository.status = 4
assert page._complete_after_revalidation(51) == ""
assert completed == [51]
page.close()
application.processEvents()
def test_note_limit_and_attachment_payload_contract(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs: list[tuple[Any, dict[str, Any]]] = []
received: list[dict[str, Any]] = []
uploads: list[dict[str, Any]] = []
def queue_async(function: Any, **options: Any) -> object:
jobs.append((function, options))
return object()
class Repository:
def upload_material(self, **kwargs: Any) -> str:
uploads.append(kwargs)
suffix = "tongue.jpg" if kwargs["material_type"] == "image" else "report.pdf"
return f"/uploads/{kwargs['material_type']}/{suffix}"
def add_doctor_note(self, diagnosis_id: int, content: str, **kwargs: Any) -> None:
received.append({"diagnosis_id": diagnosis_id, "content": content, **kwargs})
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(Repository(), PermissionSet(["doctor.appointment/addDoctorNote"]))
detail = _detail(61, name="备注患者")
page._selected_record = detail["appointment"]
page._selected_appointment_id = 61
page._selected_detail = detail
page._detail_generation = 7
page.note_edit.setPlainText("字" * (NOTE_LIMIT + 20))
page._pending_tongue_images = [r"C:\records\tongue.jpg"]
page._pending_report_files = [r"C:\records\report.pdf"]
assert len(page.note_edit.toPlainText()) == NOTE_LIMIT
assert page.note_counter.text() == f"{NOTE_LIMIT} / {NOTE_LIMIT}"
page._save_note()
assert len(jobs) == 1
jobs[0][0]()
assert uploads == [
{"path": r"C:\records\tongue.jpg", "material_type": "image", "cid": 0},
{"path": r"C:\records\report.pdf", "material_type": "file", "cid": 0},
]
assert received == [
{
"diagnosis_id": 261,
"content": "字" * NOTE_LIMIT,
"tongue_images": ["/uploads/image/tongue.jpg"],
"report_files": ["/uploads/file/report.pdf"],
}
]
jobs[0][1]["on_finished"]()
assert not page._note_busy
page.close()
application.processEvents()
def test_remote_note_uses_multipart_then_server_urls_only(
application: QApplication,
tmp_path: Path,
) -> None:
requests: list[httpx.Request] = []
tongue = tmp_path / "tongue.jpg"
report = tmp_path / "report.pdf"
tongue.write_bytes(b"tongue-image")
report.write_bytes(b"report-file")
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path.endswith("/upload/image"):
return httpx.Response(
200,
json={"code": 1, "data": {"uri": "/materials/tongue.jpg"}},
)
if request.url.path.endswith("/upload/file"):
return httpx.Response(
200,
json={"code": 1, "data": {"url": "https://cdn.test/report.pdf"}},
)
return httpx.Response(200, json={"code": 1, "data": {"id": 9}})
with ApiClient(
"https://example.test",
transport=httpx.MockTransport(handler),
) as client:
repository = RemoteDoctorRepository(client)
page = ReceptionPage(repository, PermissionSet([]))
result = page._upload_and_add_note(
501,
"两阶段备注",
[str(tongue)],
[str(report)],
)
assert result == {"id": 9}
assert [request.url.path.rsplit("/", 2)[-2:] for request in requests] == [
["upload", "image"],
["upload", "file"],
["doctor.appointment", "addDoctorNote"],
]
for upload_request in requests[:2]:
assert upload_request.headers["content-type"].startswith("multipart/form-data; boundary=")
assert b'name="cid"' in upload_request.content
assert b"\r\n0\r\n" in upload_request.content
note_payload = json.loads(requests[-1].content)
assert note_payload == {
"diagnosis_id": 501,
"content": "两阶段备注",
"tongue_images": ["/materials/tongue.jpg"],
"report_files": ["https://cdn.test/report.pdf"],
}
assert str(tmp_path) not in requests[-1].content.decode("utf-8")
page.close()
application.processEvents()
def test_partial_upload_failure_never_submits_note(
application: QApplication,
) -> None:
submitted: list[dict[str, Any]] = []
class Repository:
def upload_material(self, path: str, material_type: str, cid: int = 0) -> str:
del material_type, cid
if path.endswith("bad.pdf"):
raise OSError("磁盘读取失败")
return "/materials/good.jpg"
def add_doctor_note(self, **kwargs: Any) -> None:
submitted.append(kwargs)
page = ReceptionPage(Repository(), PermissionSet([]))
with pytest.raises(RuntimeError, match="bad.pdf.*上传失败"):
page._upload_and_add_note(
501,
"不会提交",
[r"C:\records\good.jpg"],
[r"C:\records\bad.pdf"],
)
assert submitted == []
page.close()
application.processEvents()