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() @pytest.mark.parametrize("mode", [None, "text"]) def test_video_payload_keeps_three_identifiers_distinct( application: QApplication, immediate_async: None, mode: str | None, ) -> None: detail = _detail(41, name="视频患者") detail["appointment"]["appointment_type"] = mode 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, "appointment_type": mode, "patient_id": 141, "diagnosis_id": 241, "patient_name": "视频患者", "mode": "im", "record": detail["appointment"], } ] assert page.video_button.text() == ("图文沟通" if mode == "text" else "IM 问诊") 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()