更新bug

This commit is contained in:
Your Name
2026-08-20 17:47:14 +08:00
parent 35f91ee37a
commit 5794f60c5d
67 changed files with 9257 additions and 1287 deletions
+413 -2
View File
@@ -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("第三位千问风险")