From 0c43d54c087ed2f4ba7bc718ffecb5257e1429eb Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Nov 2026 13:35:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0:=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/pages/consultations.py | 12 +- .../doctor_workstation/ui/pages/patients.py | 29 ++- .../doctor_workstation/ui/pages/reception.py | 53 ++++-- app/src/doctor_workstation/ui/shell.py | 79 ++++---- app/tests/test_consultation_global_search.py | 109 +++++++++++ .../test_global_patient_search_patients.py | 172 +++++++++++++++++ app/tests/test_reception_history_dates.py | 176 ++++++++++++++++++ app/tests/test_shell_contract.py | 72 ++++++- 8 files changed, 633 insertions(+), 69 deletions(-) create mode 100644 app/tests/test_consultation_global_search.py create mode 100644 app/tests/test_global_patient_search_patients.py create mode 100644 app/tests/test_reception_history_dates.py diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py index e63621d7c..24978f8d1 100644 --- a/app/src/doctor_workstation/ui/pages/consultations.py +++ b/app/src/doctor_workstation/ui/pages/consultations.py @@ -1858,8 +1858,13 @@ class ConsultationsPage(QWidget): self.latest_appointment_end_date.setDate(_OPTIONAL_DATE_MINIMUM) self.channel_combo.setCurrentIndex(0) - def _reset(self) -> None: - self.keyword_edit.clear() + def search_patient(self, query: str) -> None: + """Search authorized diagnoses independently of the current queue filters.""" + self.pager.invalidate() + self._reset(keyword=query.strip()) + + def _reset(self, *, keyword: str = "") -> None: + self.keyword_edit.setText(keyword) self.patient_name_edit.clear() self.doctor_edit.clear() for combo in ( @@ -1872,7 +1877,10 @@ class ConsultationsPage(QWidget): self.channel_combo, self.unserved_sort_combo, ): + blocked = combo.blockSignals(True) combo.setCurrentIndex(0) + combo.blockSignals(blocked) + self.table_host.set_sort_direction("") for editor in ( self.latest_appointment_start_date, self.latest_appointment_end_date, diff --git a/app/src/doctor_workstation/ui/pages/patients.py b/app/src/doctor_workstation/ui/pages/patients.py index d56f34338..f7244e28a 100644 --- a/app/src/doctor_workstation/ui/pages/patients.py +++ b/app/src/doctor_workstation/ui/pages/patients.py @@ -1996,13 +1996,15 @@ class PatientListWorkspace(QWidget): self.custom_date_button.setChecked(True) self.search() - def reset_filters(self) -> None: - self.keyword_edit.clear() + def reset_filters(self, *, keyword: str = "", refresh: bool = True) -> None: + self.keyword_edit.setText(keyword.strip()) self.status_combo.setCurrentIndex(0) for key, button in self.status_buttons.items(): button.setChecked(key == "") self.set_date_mode("all", refresh=False) - self.search() + self._page = 1 + if refresh: + self.refresh() def search(self) -> None: if self._date_mode != "all" and self.start_date.date() > self.end_date.date(): @@ -3256,7 +3258,7 @@ class PatientsPage(QWidget): if current is self.sender() or getattr(current, "scope", None) == text: self.scope_badge.set_status(text, "neutral") - def _workspace_changed(self, index: int) -> None: + def _workspace_changed(self, index: int, *, refresh: bool = True) -> None: for workspace_index, disclosure in enumerate(self.filter_disclosures): disclosure.button.setVisible(workspace_index == index) self.scope_badge.setVisible(False) @@ -3264,9 +3266,9 @@ class PatientsPage(QWidget): workspace = self.tabs.widget(index) scope = getattr(workspace, "scope", "按权限加载") self.scope_badge.set_status(str(scope), "neutral") - refresh = getattr(workspace, "refresh", None) - if callable(refresh) and self.isVisible(): - refresh() + reload_workspace = getattr(workspace, "refresh", None) + if refresh and callable(reload_workspace) and self.isVisible(): + reload_workspace() @property def filter_disclosure(self) -> FilterDisclosure: @@ -3283,6 +3285,19 @@ class PatientsPage(QWidget): if expanded: splitter.setSizes(self._progress_splitter_sizes) + def search_patient(self, query: str) -> None: + """Search the patient workspace using its normal permission-scoped endpoint.""" + blocked = self.tabs.blockSignals(True) + try: + self.tabs.setCurrentWidget(self.patient_workspace) + finally: + self.tabs.blockSignals(blocked) + self._workspace_changed(0, refresh=False) + # A global search starts a fresh result list, even for the same keyword. + self.patient_workspace.pager.invalidate() + self.patient_workspace.reset_filters(keyword=query, refresh=False) + self.refresh() + def refresh(self) -> None: workspace = self.tabs.currentWidget() refresh = getattr(workspace, "refresh", None) diff --git a/app/src/doctor_workstation/ui/pages/reception.py b/app/src/doctor_workstation/ui/pages/reception.py index ea58bc6c2..5c8228455 100644 --- a/app/src/doctor_workstation/ui/pages/reception.py +++ b/app/src/doctor_workstation/ui/pages/reception.py @@ -1966,7 +1966,7 @@ def _queue_shows_status_badge(status_number: int, status_text: str) -> bool: text = str(status_text or "") if "问诊中" in text or "已完成" in text or "已过号" in text: return True - return status_number in {3, 4} + return status_number in {2, 3, 4} def _queue_badge_kind(status_number: int, status_text: str) -> str: @@ -3013,8 +3013,8 @@ class _ReceptionDateButton(QPushButton): ) self._calendar.setFixedSize(292, 280) self._calendar.hide() - self._calendar.clicked.connect(self._pick_date) - self._calendar.activated.connect(self._pick_date) + self._calendar.clicked.connect(self._pick_calendar_date) + self._calendar.activated.connect(self._pick_calendar_date) self.clicked.connect(self._toggle_calendar) def iso_date(self) -> str: @@ -3040,13 +3040,16 @@ class _ReceptionDateButton(QPushButton): self._calendar.show() self._calendar.raise_() + def _pick_calendar_date(self, value: QDate) -> None: + # clicked and activated may describe the same selection; consume one popup. + if self._calendar.isVisible(): + self._pick_date(value) + def _pick_date(self, value: QDate) -> None: self._calendar.hide() if not value.isValid(): return iso_date = value.toString(Qt.DateFormat.ISODate) - if iso_date == self._iso_date: - return self.set_iso_date(iso_date) self.date_selected.emit(iso_date) @@ -4264,9 +4267,9 @@ class ReceptionPage(QWidget): header_layout = QHBoxLayout(header) header_layout.setContentsMargins(2, 0, 0, 0) header_layout.setSpacing(8) - title = QLabel("今日接诊", header) - title.setObjectName("ReceptionQueueTitle") - header_layout.addWidget(title) + self.queue_title = QLabel("今日接诊", header) + self.queue_title.setObjectName("ReceptionQueueTitle") + header_layout.addWidget(self.queue_title) header_layout.addStretch(1) self.refresh_button = QPushButton("", header) self.refresh_button.setIcon(icons.icon("refresh", PALETTE["muted"], 18)) @@ -4311,7 +4314,7 @@ class ReceptionPage(QWidget): filter_row = QGridLayout() filter_row.setSpacing(6) - for status, label in ((None, "全部"), (1, "待接诊"), (2, "问诊中"), (3, "已完成")): + for status, label in ((None, "全部"), (1, "待接诊"), (2, "已取消"), (3, "已完成")): button = QPushButton(label, self.queue_filter_panel) button.setFixedHeight(32) button.setCheckable(True) @@ -4350,7 +4353,12 @@ class ReceptionPage(QWidget): self.queue_list.currentItemChanged.connect(self._on_queue_selection) self.queue_list.verticalScrollBar().valueChanged.connect(self._on_queue_scroll) self.queue_stack.addWidget(self.queue_list) - self.queue_empty = EmptyState("队列为空", "当前筛选下没有待处理患者。", "重新加载") + self.queue_empty = EmptyState("暂无挂号记录", "当前筛选下没有挂号记录。", "重新加载") + self.queue_empty_description = next( + label for label in self.queue_empty.findChildren(QLabel) + if label.property("role") == "muted" + ) + self._sync_queue_filter_summary() self.queue_empty.action_requested.connect(self._refresh_workspace) self.queue_stack.addWidget(self.queue_empty) layout.addWidget(self.queue_stack, 1) @@ -6941,13 +6949,21 @@ class ReceptionPage(QWidget): self.refresh() def _sync_queue_filter_summary(self) -> None: - status = { - None: "全部状态", 1: "待接诊", 2: "问诊中", 3: "已完成", 4: "已过号", - }.get(self._queue_filter_status, "全部状态") + status = STATUS_TEXT.get(self._queue_filter_status, "全部状态") + today = date.today().isoformat() + self.queue_title.setText( + "历史接诊" if self._queue_date < today else + "今日接诊" if self._queue_date == today else "预约接诊" + ) summary = f"{self._queue_date} · {status}" keyword = self.search_edit.text().strip() self.queue_filter_summary.setText(summary + (" · 姓名已筛选" if keyword else "")) self.queue_filter_summary.setToolTip(summary + (f" · 姓名:{keyword}" if keyword else "")) + if hasattr(self, "queue_empty_description"): + conditions = f"{status}、当前姓名" if keyword else status + self.queue_empty_description.setText( + f"{self._queue_date}\n当前账号在“{conditions}”筛选下没有挂号记录。" + ) def _search_changed(self, value: str) -> None: if not value and self.isVisible(): @@ -6964,6 +6980,15 @@ class ReceptionPage(QWidget): self.refresh() def _queue_date_changed(self, iso_date: str) -> None: + # Selecting a day opens that doctor's complete daily appointment list. + self._queue_filter_status = None + for value, button in self._queue_filter_buttons.items(): + button.setChecked(value is None) + blocked = self.search_edit.blockSignals(True) + try: + self.search_edit.clear() + finally: + self.search_edit.blockSignals(blocked) self._queue_date = iso_date self._clear_selection() self._reset_queue_state() @@ -7401,7 +7426,7 @@ class ReceptionPage(QWidget): labels = { None: f"全部 {total}", 1: f"待接诊 {counts.get(1, 0)}", - 2: f"问诊中 {counts.get(2, 0)}", + 2: f"已取消 {counts.get(2, 0)}", 3: f"已完成 {counts.get(3, 0)}", } for status, button in self._queue_filter_buttons.items(): diff --git a/app/src/doctor_workstation/ui/shell.py b/app/src/doctor_workstation/ui/shell.py index 628f6573e..79da58b11 100644 --- a/app/src/doctor_workstation/ui/shell.py +++ b/app/src/doctor_workstation/ui/shell.py @@ -1319,9 +1319,15 @@ class ShellWindow(QMainWindow): search_layout = QHBoxLayout(search_host) search_layout.setContentsMargins(4, 0, 7, 0) search_layout.setSpacing(3) - self.search_mark = QLabel(search_host) + self.search_mark = QToolButton(search_host) self.search_mark.setObjectName("ShellSearchMark") self.search_mark.setFixedSize(20, 20) + self.search_mark.setAutoRaise(True) + self.search_mark.setCursor(Qt.CursorShape.PointingHandCursor) + self.search_mark.setAccessibleName("搜索患者") + self.search_mark.setToolTip("搜索患者(回车)") + self.search_mark.setStyleSheet("background: transparent; border: 0; padding: 0;") + self.search_mark.clicked.connect(self._run_global_search) self.search_mark.hide() search_layout.addWidget(self.search_mark) self.global_search = QLineEdit(search_host) @@ -1329,12 +1335,15 @@ class ShellWindow(QMainWindow): self.global_search.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - self.global_search.setPlaceholderText("搜索患者姓名、手机号、病历号") + self.global_search.setPlaceholderText("搜索患者姓名、手机号...") + self.global_search.setToolTip("输入患者姓名或手机号,按回车或点击放大镜搜索") self.global_search.setClearButtonEnabled(True) self._global_search_icon_action = self.global_search.addAction( _painted_shell_icon("search", 16), QLineEdit.ActionPosition.LeadingPosition, ) + self._global_search_icon_action.setToolTip("搜索患者(回车)") + self._global_search_icon_action.triggered.connect(self._run_global_search) self.global_search.returnPressed.connect(self._run_global_search) search_layout.addWidget(self.global_search, 1) shortcut_hint = QLabel("Ctrl K", search_host) @@ -1614,7 +1623,7 @@ class ShellWindow(QMainWindow): self.search_host.layout().setSpacing(6 if active else 3) self.search_mark.setVisible(active) self._global_search_icon_action.setVisible(not active) - self.global_search.setPlaceholderText("搜索患者姓名、手机号..." if active else "搜索患者姓名、手机号、病历号") + self.global_search.setPlaceholderText("搜索患者姓名、手机号...") self.search_shortcut_hint.setText("Ctrl K") self.ai_top_button.setVisible(self._can_ai_assistant and not active) self.menu_sidebar_action.setVisible(active) @@ -1789,7 +1798,8 @@ class ShellWindow(QMainWindow): self._global_search_icon_action.setIcon( _painted_shell_icon("search", 20 if active else 16, reception=active) ) - self.search_mark.setPixmap(_painted_shell_icon("search", 20, reception=True).pixmap(20, 20)) + self.search_mark.setIcon(_painted_shell_icon("search", 20, reception=True)) + self.search_mark.setIconSize(QSize(20, 20)) for key, button in self.nav_buttons.items(): size = 20 if active else 18 button.setProperty("receptionTechBlue", active) @@ -1819,46 +1829,20 @@ class ShellWindow(QMainWindow): def _run_global_search(self) -> None: query = self.global_search.text().strip() - if not query: - self._focus_global_search() + keys = ("patients", "consultations") if self._active_page_key == "patients" else ( + "consultations", "patients", + ) + for key in keys: + # Only use patient pages already registered by permission-aware navigation. + page = self.pages.get(key) + search = getattr(page, "search_patient", None) + if page is None or key not in self.nav_buttons or not callable(search): + continue + self._navigate( + self.stack.indexOf(page), key, on_activate=lambda search=search: search(query), + ) return - page = self.stack.currentWidget() - if page is None: - return - target: QLineEdit | None = None - for attribute in ( - "patient_input", - "keyword_edit", - "search_edit", - "patient_filter", - "name_filter", - "sn_filter", - ): - candidate = getattr(page, attribute, None) - if isinstance(candidate, QLineEdit) and not candidate.isHidden(): - target = candidate - break - if target is None: - for candidate in page.findChildren(QLineEdit): - placeholder = candidate.placeholderText() - if not candidate.isHidden() and any( - marker in placeholder - for marker in ("患者", "姓名", "手机号", "处方") - ): - target = candidate - break - if target is None: - show_toast(self, "当前页面没有可用的快捷搜索条件。", "warning") - return - target.setText(query) - search = getattr(page, "_search", None) - if callable(search): - search() - else: - refresh = getattr(page, "refresh", None) - if callable(refresh): - refresh() - show_toast(self, f"正在当前页面搜索“{query}”。", "info") + show_toast(self, "当前账号没有可用的患者搜索页面。", "warning") def _open_ai_assistant(self) -> None: if not self._can_ai_assistant: @@ -2109,18 +2093,25 @@ class ShellWindow(QMainWindow): self._ensure_activation_refresh(page, generation) self._activation_page = None - def _navigate(self, index: int, key: str) -> None: + def _navigate( + self, index: int, key: str, *, on_activate: Callable[[], None] | None = None, + ) -> None: if index < 0 or index >= self.stack.count(): return page = self.stack.widget(index) if page is None: return if self._active_page_key == key and self.stack.currentWidget() is page: + if on_activate is not None: + self._activation_refreshed = False + on_activate() return self._activation_generation += 1 activation_generation = self._activation_generation self._activation_page = page self._activation_refreshed = False + if on_activate is not None: + on_activate() # A stack swap is otherwise a hard cut between two frames: this is the # transition a doctor sees on every sidebar click all day. motion.switch_stack(self.stack, index) diff --git a/app/tests/test_consultation_global_search.py b/app/tests/test_consultation_global_search.py new file mode 100644 index 000000000..93b18e009 --- /dev/null +++ b/app/tests/test_consultation_global_search.py @@ -0,0 +1,109 @@ +"""Global patient search must not inherit a narrow queue or stale pagination.""" + +from __future__ import annotations + +import os +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QDate, Signal +from PySide6.QtWidgets import QApplication, QWidget + +from doctor_workstation.services.repository import RemoteDoctorRepository +from doctor_workstation.ui.pages import consultations as module + + +class Client: + def __init__(self) -> None: + self.calls = [] + + def get(self, endpoint: str, params: Any = None) -> Any: + params = dict(params or {}) + self.calls.append((endpoint, params)) + page = params.get("page_no", 1) + return {"lists": [{"id": page, "patient_name": "测试患者"}], "count": 2} + + +class DetailStub(QWidget): + saved = Signal() + + def __init__(self, _repository: Any, parent: QWidget | None = None) -> None: + super().__init__(parent) + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def page(application: QApplication, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(module, "DiagnosisDialog", DetailStub) + + def immediate(function: Any, **callbacks: Any) -> None: + callbacks["on_success"](function()) + if callbacks.get("on_finished"): + callbacks["on_finished"]() + + monkeypatch.setattr(module, "run_async", immediate) + client = Client() + view = module.ConsultationsPage(RemoteDoctorRepository(client)) + monkeypatch.setattr(view, "_refresh_counts", lambda: None) + yield view, client + view.poll_timer.stop() + view.pager.invalidate() + view.close() + view.deleteLater() + application.processEvents() + + +@pytest.mark.parametrize("query", [" 林患者 ", "13800138000", "138", "", " "]) +def test_global_search_submits_only_new_keyword_and_normal_scoped_list(page, query): + view, client = page + view.patient_name_edit.setText("旧患者") + view.doctor_edit.setText("旧医生") + view.keyword_edit.setText("旧手机号") + view.pending_assign_keyword.setText("旧待分配关键词") + view._pending_assign = "1" + view._pending_booking = "1" + view._completed_appointment = "1" + for combo in (view.department_combo, view.assistant_combo, view.channel_combo): + combo.addItem("旧筛选", 123) + combo.setCurrentIndex(combo.count() - 1) + view.latest_appointment_start_date.setDate(QDate(2026, 1, 3)) + view.latest_appointment_end_date.setDate(QDate(2026, 1, 1)) + view.latest_assign_start_date.setDate(QDate(2026, 1, 3)) + view.latest_assign_end_date.setDate(QDate(2026, 1, 1)) + view.unserved_sort_combo.setCurrentIndex(1) + client.calls.clear() + + view.search_patient(query) + + assert len(client.calls) == 1 + endpoint, params = client.calls[0] + assert endpoint == "tcm.diagnosis/lists" + assert params.get("keyword", "") == query.strip() + assert params["page_no"] == 1 + assert all(not value for key, value in params.items() if key not in { + "keyword", "page_no", "page_size", + }) + assert view.keyword_edit.text() == query.strip() + assert view.patient_name_edit.text() == view.pending_assign_keyword.text() == "" + assert view._appointment_date == view._appointment_status_filter == "" + assert view._pending_assign == "" + assert view.table_host.model._sort_direction == "" + assert view.table_host.model.rowCount() == 1 + + +def test_repeating_global_search_starts_at_first_page(page): + view, client = page + view.search_patient("林患者") + view.pager.load_more() + assert view.pager.page == 2 + client.calls.clear() + view.search_patient("林患者") + assert [params["page_no"] for _, params in client.calls] == [1] + assert view.pager.page == 1 + assert view.table_host.model.rowCount() == 1 diff --git a/app/tests/test_global_patient_search_patients.py b/app/tests/test_global_patient_search_patients.py new file mode 100644 index 000000000..9525d997b --- /dev/null +++ b/app/tests/test_global_patient_search_patients.py @@ -0,0 +1,172 @@ +"""Global patient searches enter the patient list with one scoped request.""" + +from __future__ import annotations + +import os +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication + +from doctor_workstation.core import PermissionSet +from doctor_workstation.ui.pages import patients as patients_module +from doctor_workstation.ui.pages.patients import PatientsPage + + +class Repository: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def _result(self, kind: str, query: dict[str, Any]) -> dict[str, Any]: + self.calls.append((kind, dict(query))) + start = (query["page_no"] - 1) * query["page_size"] + return { + "lists": [ + { + "id": index + 100, + "diagnosis_id": index + 100, + "patient_name": f"患者 {index}", + "appointment_status": 1, + "queue_status": "waiting", + } + for index in range(start, min(start + query["page_size"], 37)) + ], + "count": 37, + "extend": {"scope": {"label": "本人患者"}, "summary": {}}, + } + + def list_patients(self, **query: Any) -> dict[str, Any]: + return self._result("patients", query) + + def patient_orders(self, **query: Any) -> dict[str, Any]: + return self._result("orders", query) + + def patient_progress(self, **query: Any) -> dict[str, Any]: + return self._result("progress", query) + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def page(application: QApplication, monkeypatch: pytest.MonkeyPatch): + def immediate(function: Any, **callbacks: Any) -> object: + result = function() + callbacks["on_success"](result) + if callbacks.get("on_finished"): + callbacks["on_finished"]() + return object() + + monkeypatch.setattr(patients_module, "run_async", immediate) + repository = Repository() + widget = PatientsPage(repository, PermissionSet(["firstvisit.myPatient/lists"])) + widget.resize(1280, 900) + widget.show() + application.processEvents() + yield widget, repository + for timer in widget.findChildren(QTimer): + timer.stop() + widget.close() + widget.deleteLater() + application.processEvents() + + +def assert_patient_query(repository: Repository, keyword: str) -> None: + assert repository.calls == [ + ( + "patients", + { + "page_no": 1, + "page_size": 15, + "keyword": keyword, + "status": "", + "start_date": "", + "end_date": "", + }, + ) + ] + + +@pytest.mark.parametrize("tab_index", [0, 1, 2], ids=["patients", "orders", "progress"]) +@pytest.mark.parametrize("expanded", [False, True], ids=["collapsed", "expanded"]) +def test_search_enters_patient_list_and_clears_old_filters( + page, monkeypatch: pytest.MonkeyPatch, tab_index: int, expanded: bool +) -> None: + widget, repository = page + workspace = widget.patient_workspace + workspace.keyword_edit.setText("旧患者") + workspace.status_combo.setCurrentIndex(2) + workspace.set_date_mode("today", refresh=False) + widget.filter_disclosures[0].set_expanded(expanded) + widget.order_workspace.keyword_edit.setText("旧订单") + widget.tabs.setCurrentIndex(tab_index) + repository.calls.clear() + refreshes: list[bool] = [] + original_refresh = widget.refresh + + def refresh() -> None: + refreshes.append(True) + original_refresh() + + monkeypatch.setattr(widget, "refresh", refresh) + widget.search_patient(" 13800138000 ") + + assert widget.tabs.currentWidget() is workspace + assert_patient_query(repository, "13800138000") + assert refreshes == [True] + assert workspace.keyword_edit.text() == "13800138000" + assert workspace._date_mode == "all" + assert workspace.status_buttons[""].isChecked() + assert workspace._page == workspace.pager.page == 1 + assert widget.filter_disclosures[0].expanded is expanded + assert widget.order_workspace.keyword_edit.text() == "旧订单" + assert not widget.progress_workspace.timer.isActive() + + +@pytest.mark.parametrize("query", ["", " "], ids=["empty", "whitespace"]) +def test_empty_search_clears_keyword_even_when_patient_page_is_hidden(page, query: str) -> None: + widget, repository = page + widget.patient_workspace.keyword_edit.setText("旧患者") + widget.patient_workspace.status_combo.setCurrentIndex(3) + widget.tabs.setCurrentIndex(1) + widget.hide() + repository.calls.clear() + + widget.search_patient(query) + + assert widget.tabs.currentIndex() == 0 + assert widget.patient_workspace.keyword_edit.text() == "" + assert_patient_query(repository, "") + + +def test_repeated_search_discards_previously_loaded_pages(page) -> None: + widget, repository = page + widget.search_patient("患者") + workspace = widget.patient_workspace + workspace.pager.load_more() + assert workspace._page == workspace.pager.page == 2 + repository.calls.clear() + + widget.search_patient("患者") + + assert_patient_query(repository, "患者") + assert workspace._page == workspace.pager.page == 1 + assert workspace.table.rowCount() == 15 + + +def test_reset_button_still_accepts_qt_clicked_signal(page) -> None: + widget, repository = page + workspace = widget.patient_workspace + workspace.keyword_edit.setText("旧患者") + workspace.status_combo.setCurrentIndex(2) + repository.calls.clear() + + workspace.reset_button.click() + + assert workspace.keyword_edit.text() == "" + assert_patient_query(repository, "") diff --git a/app/tests/test_reception_history_dates.py b/app/tests/test_reception_history_dates.py new file mode 100644 index 000000000..f5268b4cf --- /dev/null +++ b/app/tests/test_reception_history_dates.py @@ -0,0 +1,176 @@ +"""Historical reception queries retain their day and include finished appointments.""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QDate, QTimer +from PySide6.QtWidgets import QApplication + +from doctor_workstation.core import PermissionSet +from doctor_workstation.services.repository import RemoteDoctorRepository +from doctor_workstation.ui.pages import reception as module +from doctor_workstation.ui.widgets import StatusBadge + + +class Today(date): + @classmethod + def today(cls): + return cls(2026, 11, 6) + + +class Client: + def __init__(self): + self.calls = [] + + def get(self, endpoint: str, params: Any = None): + params = dict(params or {}) + self.calls.append((endpoint, params)) + assert endpoint == "doctor.appointment/lists" + day = params["start_date"] + statuses = [2, 3, 4] if day == "2026-10-17" else [] + rows = [ + {"id": status, "patient_name": "测试患者", "status": status, + "status_desc": module.STATUS_TEXT[status], "appointment_date": day} + for status in statuses + if params.get("status") in (None, "", status) + ] + offset = (params["page_no"] - 1) * params["page_size"] + return { + "lists": rows[offset:offset + params["page_size"]], "count": len(rows), + "extend": {"status_count": {str(status): statuses.count(status) for status in range(1, 5)}}, + } + + +@pytest.fixture(scope="module") +def application(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def page(application, monkeypatch): + def immediate(function, **callbacks): + callbacks["on_success"](function()) + callbacks["on_finished"]() + + monkeypatch.setattr(module, "date", Today) + monkeypatch.setattr(module, "run_async", immediate) + client = Client() + widget = module.ReceptionPage(RemoteDoctorRepository(client), PermissionSet([])) + # Keep this test on the list boundary, without loading patient clinical details. + monkeypatch.setattr(widget, "_select_record", lambda *_args, **_kwargs: None) + monkeypatch.setattr(widget, "_maybe_load_more_queue", lambda: None) + yield widget, client + for timer in widget.findChildren(QTimer): + timer.stop() + widget.close() + widget.deleteLater() + application.processEvents() + + +def test_historical_day_opens_all_statuses_and_labels_cancelled_correctly(page): + widget, client = page + widget.queue_date_button._pick_date(QDate(2026, 10, 17)) + assert widget.queue_title.text() == "历史接诊" + assert widget._queue_filter_status is None + assert widget._queue_filter_buttons[None].isChecked() + assert [record.status for record in widget._queue_records] == [2, 3, 4] + assert widget._queue_filter_buttons[None].text() == "全部 3" + assert widget._queue_filter_buttons[2].text() == "已取消 1" + row = widget.queue_list.itemWidget(widget.queue_list.item(0)) + assert any(badge.text() == "已取消" and not badge.isHidden() for badge in row.findChildren(StatusBadge)) + assert client.calls[-1][1]["start_date"] == client.calls[-1][1]["end_date"] == "2026-10-17" + assert not client.calls[-1][1].get("status") + assert "progress_board" not in client.calls[-1][1] + assert "diag_scope_relax" not in client.calls[-1][1] + + +@pytest.mark.parametrize("status", [None, 1, 2, 3, 4]) +def test_history_status_refresh_and_poll_keep_exact_date(page, status): + widget, client = page + widget.queue_date_button._pick_date(QDate(2026, 10, 17)) + widget._set_queue_filter(status) + widget.refresh(workspace_refresh=True) + widget._poll_queue() + assert all(params["start_date"] == params["end_date"] == "2026-10-17" for _, params in client.calls) + assert client.calls[-1][1].get("status") == status + assert widget._queue_filter_buttons[None].text() == "全部 3" + if status == 2: + assert "已取消" in widget.queue_filter_summary.text() + + +def test_history_pagination_keeps_date_and_status(page): + widget, client = page + widget._queue_page_size = 2 + widget.queue_date_button._pick_date(QDate(2026, 10, 17)) + widget._load_more() + assert [params["page_no"] for _, params in client.calls] == [1, 2] + assert len(widget._queue_records) == 3 + assert client.calls[-1][1]["start_date"] == "2026-10-17" + assert not client.calls[-1][1].get("status") + + +def test_empty_day_explains_selected_date_and_explicit_today_selection_shows_all(page): + widget, _client = page + assert widget._queue_filter_status == 1 + widget.queue_date_button._pick_date(QDate(2026, 10, 16)) + assert widget.queue_stack.currentWidget() is widget.queue_empty + assert "2026-10-16" in widget.queue_empty_description.text() + assert "当前账号" in widget.queue_empty_description.text() + assert "挂号记录" in widget.queue_empty_description.text() + widget.queue_date_button._pick_date(QDate(2026, 11, 6)) + assert widget.queue_title.text() == "今日接诊" + assert widget._queue_filter_status is None + widget.queue_date_button._pick_date(QDate(2026, 11, 7)) + assert widget.queue_title.text() == "预约接诊" + + +@pytest.mark.parametrize("selected_date", ["2026-10-17", "2026-11-06", "2026-11-07"]) +def test_selecting_any_day_clears_old_name_and_status_with_one_request(page, application, selected_date): + widget, client = page + widget.show() + application.processEvents() + widget.search_edit.setText("之前查过的患者") + widget._set_queue_filter(3) + client.calls.clear() + + widget.queue_date_button._pick_date(QDate.fromString(selected_date, "yyyy-MM-dd")) + + assert len(client.calls) == 1 + endpoint, params = client.calls[0] + assert endpoint == "doctor.appointment/lists" + assert params["start_date"] == params["end_date"] == selected_date + assert params["page_no"] == 1 + assert not params.get("patient_name") + assert not params.get("status") + assert widget.search_edit.text() == "" + assert widget._queue_filter_buttons[None].isChecked() + assert not any(key in params for key in ("progress_board", "diag_scope_relax", "assistant_id")) + + +def test_calendar_reselects_same_day_and_deduplicates_click_activation(page, application): + widget, client = page + widget.show() + application.processEvents() + widget._set_queue_filter(3) + widget.search_edit.setText("旧姓名") + client.calls.clear() + button = widget.queue_date_button + calendar = button.calendarWidget() + selected = QDate(2026, 11, 6) + button.click() + assert calendar.isVisible() + calendar.clicked.emit(selected) + calendar.activated.emit(selected) + assert len(client.calls) == 1 + assert not calendar.isVisible() + assert widget.search_edit.text() == "" + assert widget._queue_filter_status is None + button.click() + calendar.activated.emit(selected) + assert len(client.calls) == 2 diff --git a/app/tests/test_shell_contract.py b/app/tests/test_shell_contract.py index a6c6a9c0f..42786a7ee 100644 --- a/app/tests/test_shell_contract.py +++ b/app/tests/test_shell_contract.py @@ -7,7 +7,8 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest from PySide6.QtCore import QSize, Qt -from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidget +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QDialog, QFrame, QLineEdit, QToolButton, QWidget from doctor_workstation.ui import shell as shell_module from doctor_workstation.ui.shell import NavigationItem, ShellWindow @@ -35,10 +36,15 @@ class _ShellPageDouble(QWidget): self.show_count = 0 self.ai_context_available = False self.ai_open_count = 0 + self.search_queries: list[str] = [] def refresh(self) -> None: self.refresh_count += 1 + def search_patient(self, query: str) -> None: + self.search_queries.append(query) + self.refresh() + def open_selected_ai_consult(self) -> bool: self.ai_open_count += 1 return self.ai_context_available @@ -243,13 +249,75 @@ def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) - assert not page.isWindow() +@pytest.mark.parametrize("origin,trigger,target", [ + ("consultations", "enter", "consultations"), + ("appointments", "button", "consultations"), + ("prescriptions", "enter", "consultations"), + ("prescription_library", "button", "consultations"), + ("patients", "enter", "patients"), + ("legacy_reference", "action", "consultations"), +]) +def test_topbar_search_routes_to_patient_page_once( + shell_window: ShellWindow, application: QApplication, origin: str, trigger: str, target: str, +) -> None: + assert shell_window.navigate(origin) + application.processEvents() + unrelated = QLineEdit(shell_window.pages[origin]) + unrelated.setPlaceholderText("患者姓名") + unrelated.setText("原页面条件") + page = shell_window.pages[target] + before = page.refresh_count + shell_window.global_search.setText(" 13800138000 ") + if trigger == "enter": + QTest.keyClick(shell_window.global_search, Qt.Key.Key_Return) + elif trigger == "button": + QTest.mouseClick(shell_window.search_mark, Qt.MouseButton.LeftButton) + else: + shell_window._global_search_icon_action.trigger() + application.processEvents() + assert shell_window.stack.currentWidget() is page + assert page.search_queries == ["13800138000"] + assert page.refresh_count == before + 1 + assert unrelated.text() == "原页面条件" + + +def test_topbar_search_uses_available_patient_page_and_empty_query_clears( + shell_window: ShellWindow, application: QApplication, +) -> None: + shell_window.pages.pop("consultations") + shell_window.global_search.setText("林患者") + QTest.keyClick(shell_window.global_search, Qt.Key.Key_Return) + application.processEvents() + patient_page = shell_window.pages["patients"] + assert shell_window.stack.currentWidget() is patient_page + shell_window.global_search.clear() + QTest.keyClick(shell_window.global_search, Qt.Key.Key_Return) + assert patient_page.search_queries == ["林患者", ""] + + +def test_topbar_search_without_patient_page_does_not_query_unrelated_pages( + shell_window: ShellWindow, monkeypatch: pytest.MonkeyPatch, +) -> None: + shell_window.pages.pop("consultations") + shell_window.pages.pop("patients") + current = shell_window.stack.currentWidget() + before = current.refresh_count + messages = [] + monkeypatch.setattr(shell_module, "show_toast", lambda *args: messages.append(args)) + shell_window.global_search.setText("林患者") + QTest.keyClick(shell_window.global_search, Qt.Key.Key_Return) + assert current.refresh_count == before + assert shell_window.stack.currentWidget() is current + assert "没有可用的患者搜索页面" in messages[0][1] + + def test_reference_shell_has_integrated_search_ai_card_and_window_controls( shell_window: ShellWindow, ) -> None: assert shell_window.navigate("legacy_reference") assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint assert ( - shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号" + shell_window.global_search.placeholderText() == "搜索患者姓名、手机号..." ) assert shell_window.assistant_card.isVisible() assert shell_window.assistant_button.text() == "开始对话"