更新bug
This commit is contained in:
@@ -10,14 +10,32 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
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, QWidget
|
||||
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,
|
||||
@@ -41,8 +59,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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:
|
||||
@@ -79,6 +100,135 @@ def queued_async(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||
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"),
|
||||
[
|
||||
@@ -714,6 +864,101 @@ def test_fast_patient_switch_rejects_late_detail(
|
||||
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:
|
||||
@@ -1169,6 +1414,28 @@ def test_phone_permission_and_ungated_notify_video_actions(
|
||||
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()
|
||||
|
||||
|
||||
def test_reception_ai_report_button_follows_permission(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
@@ -1423,6 +1690,65 @@ def test_reception_dual_ai_queues_openai_only_after_qwen_is_visible(
|
||||
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]],
|
||||
@@ -1886,6 +2212,89 @@ def test_detail_failure_stops_ai_loading_and_keeps_retry_available(
|
||||
|
||||
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()
|
||||
@@ -1960,6 +2369,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
|
||||
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("第二位千问风险")
|
||||
@@ -1968,6 +2378,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
|
||||
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("第三位千问风险")
|
||||
|
||||
Reference in New Issue
Block a user