from __future__ import annotations import os from typing import Any 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 doctor_workstation.ui import shell as shell_module from doctor_workstation.ui.shell import NavigationItem, ShellWindow from doctor_workstation.ui.theme import apply_theme def _logical_pixel(image: Any, x: int, y: int): device_scale = image.devicePixelRatio() return image.pixelColor(round(x * device_scale), round(y * device_scale)) class _ShellPageDouble(QWidget): def __init__( self, _repository: Any, *, permissions: Any, current_user: Any, parent: QWidget | None = None, ) -> None: super().__init__(parent) self.permissions = permissions self.current_user = current_user self.refresh_count = 0 self.show_count = 0 self.ai_context_available = False self.ai_open_count = 0 def refresh(self) -> None: self.refresh_count += 1 def open_selected_ai_consult(self) -> bool: self.ai_open_count += 1 return self.ai_context_available def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual super().showEvent(event) self.show_count += 1 self.refresh() @pytest.fixture(scope="module") def application() -> QApplication: app = QApplication.instance() or QApplication([]) apply_theme(app) return app @pytest.mark.parametrize( ("available_size", "expected_size"), [ (QSize(1920, 1080), QSize(1710, 920)), (QSize(1486, 1000), QSize(1486, 920)), (QSize(1600, 800), QSize(1600, 800)), (QSize(800, 600), QSize(1024, 640)), (None, QSize(1710, 920)), ], ) def test_shell_initial_size_is_bounded_by_logical_available_geometry( available_size: QSize | None, expected_size: QSize, ) -> None: assert shell_module._bounded_initial_window_size(available_size) == expected_size def test_patients_navigation_keeps_the_product_menu_title() -> None: resolved = shell_module._resolve_navigation( [ { "name": "我的患者", "component": "first_visit/my_patients", "perms": "firstvisit.myPatient/lists", } ], {"firstvisit.myPatient/lists"}, demo_mode=False, ) assert [(item.key, title) for item, title in resolved] == [("patients", "我的患者")] def test_appointments_navigation_is_named_reception_and_always_first() -> None: resolved = shell_module._resolve_navigation( [ { "name": "我的患者", "component": "first_visit/my_patients", "perms": "firstvisit.myPatient/lists", "sort": 99, }, { "name": "挂号列表", "component": "tcm/appointment/list", "perms": "doctor.appointment/lists", "sort": 1, }, ], {"doctor.appointment/lists", "firstvisit.myPatient/lists"}, demo_mode=False, ) # 挂号与诊单是两条队列,侧边栏此前两项同名。现在与服务端菜单的“挂号列表”一致。 assert [(item.key, title) for item, title in resolved] == [ ("appointments", "挂号列表"), ("patients", "我的患者"), ] @pytest.fixture def shell_window( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> ShellWindow: navigation = [ NavigationItem(key, title, glyph, _ShellPageDouble, (permission,)) for key, title, glyph, permission in ( ("appointments", "挂号列表", "号", "doctor.appointment/lists"), ("reception", "接诊台", "◎", "doctor.appointment/lists"), ( "prescription_library", "我的处方库", "方", "tcm.prescriptionLibrary/lists", ), ("prescriptions", "已开处方", "笺", "tcm.prescription/lists"), ("patients", "我的患者", "患", "firstvisit.myPatient/lists"), ("consultations", "问诊列表", "询", "tcm.diagnosis/lists"), ) ] monkeypatch.setattr( shell_module, "_resolve_navigation", lambda _menu, _permissions, *, demo_mode: [ (item, item.title) for item in navigation ], ) window = ShellWindow( object(), { "user": {"name": "陈医生", "department_name": "中医门诊", "role_ids": [1]}, "demo_mode": True, }, permissions={item.permissions[0] for item in navigation} | {"tcm.diagnosis/aiAssistant"}, ) window.show() application.processEvents() yield window window.close() application.processEvents() def test_shell_matches_reference_geometry_at_both_acceptance_sizes( application: QApplication, shell_window: ShellWindow, ) -> None: for width, height in ((1024, 640), (1440, 900)): shell_window.resize(width, height) application.processEvents() assert shell_window.sidebar.width() == 179 assert shell_window.topbar.height() == 62 assert shell_window.tabs_host.height() == 0 assert shell_window.workspace.width() == width - 26 - 179 assert shell_window.stack.width() == width - 26 - 179 assert shell_window.stack.height() == height - 26 - 62 assert shell_window.stack.geometry().right() < shell_window.workspace.width() assert shell_window.stack.geometry().bottom() < shell_window.workspace.height() image = shell_window.grab().toImage() assert _logical_pixel(image, 20, 300).name().lower() in { "#f2f5fd", "#f3f6fd", "#f2f6fe", "#f3f6fe", } assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff" assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe" def test_topbar_search_actions_and_navigation_controls_stay_aligned( application: QApplication, shell_window: ShellWindow, ) -> None: for width, height in ((1024, 640), (1366, 768)): shell_window.resize(width, height) application.processEvents() search_host = shell_window.topbar.findChild(QFrame, "ShellGlobalSearch") shortcut_hint = search_host.findChild(QWidget, "ShellShortcutHint") assert search_host.size().toTuple() == (265, 36) assert shell_window.fold_button.size().toTuple() == (38, 38) assert shortcut_hint.size() == shortcut_hint.sizeHint() assert ( abs(search_host.geometry().center().y() - shell_window.fold_button.geometry().center().y()) <= 1 ) assert ( abs( shortcut_hint.mapTo(search_host, shortcut_hint.rect().center()).y() - search_host.rect().center().y() ) <= 1 ) shell_window.global_search.setText("患者") application.processEvents() action_buttons = shell_window.global_search.findChildren(QToolButton) assert len(action_buttons) == 2 for button in action_buttons: assert button.size().toTuple() == (22, 18) assert shell_window.global_search.rect().contains(button.geometry()) assert ( abs( button.geometry().center().y() - shell_window.global_search.rect().center().y() ) <= 1 ) clear_button = max(action_buttons, key=lambda button: button.x()) clear_right = clear_button.mapTo(search_host, clear_button.rect().topRight()).x() assert clear_right < shortcut_hint.x() shell_window.global_search.clear() def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -> None: for page in shell_window.pages.values(): assert page.parentWidget() is shell_window.stack assert not page.isWindow() def test_reference_shell_has_integrated_search_ai_card_and_window_controls( shell_window: ShellWindow, ) -> None: assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint assert ( shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号" ) assert shell_window.assistant_card.isVisible() assert shell_window.assistant_button.text() == "开始对话" assert shell_window.upload_settings_button.text().strip().startswith("设置") assert ( shell_window.upload_settings_button.accessibleName() == "本机录音上传设置" ) assert shell_window.model_label is shell_window.upload_settings_button assert shell_window.minimize_button.text() == "" assert shell_window.close_button.text() == "" shell_window.set_connection_state(False) assert "离线" in shell_window.assistant_status.text() shell_window.set_connection_state(True) assert "在线" in shell_window.assistant_status.text() def test_shell_ai_entry_always_opens_patient_picker_even_with_current_selection( application: QApplication, shell_window: ShellWindow, monkeypatch: pytest.MonkeyPatch, ) -> None: current = shell_window.pages["appointments"] assert isinstance(current, _ShellPageDouble) current.ai_context_available = True opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] monkeypatch.setattr( shell_module, "select_and_present_ai_consult", lambda *args, **kwargs: opened.append((args, kwargs)) or False, ) shell_window.assistant_button.click() application.processEvents() assert current.ai_open_count == 0 assert len(opened) == 1 assert opened[0][0][0] is shell_window.repository assert opened[0][0][2] is shell_window assert shell_window.stack.currentWidget() is current def test_shell_global_diagnosis_entry_reuses_dialog_and_obeys_permissions( shell_window: ShellWindow, monkeypatch: pytest.MonkeyPatch, ) -> None: opened: list[tuple[str, int]] = [] refresh_callbacks: list[Any] = [] created: list[Any] = [] class _SavedSignal: def connect(self, callback: Any) -> None: refresh_callbacks.append(callback) class _DiagnosisDialogDouble: def __init__( self, repository: Any, parent: Any, *, permissions: Any, ) -> None: self.repository = repository self.parent = parent self.permissions = permissions self.saved = _SavedSignal() self.raise_count = 0 self.activate_count = 0 created.append(self) def refresh_permissions(self, permissions: Any) -> None: self.permissions = permissions def open_for( self, diagnosis_id: int, *, editable: bool, modeless: bool, ) -> None: assert editable is True assert modeless is True opened.append(("edit", diagnosis_id)) def open_view_only(self, diagnosis_id: int, *, modeless: bool) -> None: assert modeless is True opened.append(("view", diagnosis_id)) def raise_(self) -> None: self.raise_count += 1 def activateWindow(self) -> None: # noqa: N802 - Qt-compatible test double self.activate_count += 1 monkeypatch.setattr(shell_module, "DiagnosisDialog", _DiagnosisDialogDouble) shell_window._global_diagnosis_dialog = None shell_window.permissions = {"tcm.diagnosis/edit"} assert shell_window.open_diagnosis_by_id(501, modeless=True) is created[0] shell_window.permissions = {"tcm.diagnosis/readonlyDetail"} assert shell_window.open_diagnosis_by_id("502", modeless=True) is created[0] shell_window.permissions = {"tcm.diagnosis/*"} assert shell_window.open_diagnosis_by_id(503, modeless=True) is created[0] assert opened == [("edit", 501), ("view", 502), ("edit", 503)] assert len(created) == 1 assert created[0].parent is shell_window assert len(refresh_callbacks) == 1 assert created[0].raise_count == 3 assert created[0].activate_count == 3 shell_window.permissions = set() assert shell_window.open_diagnosis_by_id(504, modeless=True) is None assert shell_window.open_diagnosis_by_id(0, modeless=True) is None assert shell_window.open_diagnosis_by_id("invalid", modeless=True) is None assert opened == [("edit", 501), ("view", 502), ("edit", 503)] def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker( application: QApplication, shell_window: ShellWindow, monkeypatch: pytest.MonkeyPatch, ) -> None: appointments = shell_window.pages["appointments"] reception = shell_window.pages["reception"] assert isinstance(appointments, _ShellPageDouble) assert isinstance(reception, _ShellPageDouble) opened: list[dict[str, Any]] = [] def open_picker(repository: Any, permissions: Any, parent: Any, **kwargs: Any) -> bool: opened.append( { "repository": repository, "permissions": permissions, "parent": parent, **kwargs, } ) return False monkeypatch.setattr(shell_module, "select_and_present_ai_consult", open_picker) shell_window.global_search.setText("张医生") shell_window.ai_top_button.click() application.processEvents() assert appointments.ai_open_count == 0 assert reception.ai_open_count == 0 assert shell_window.stack.currentWidget() is appointments assert opened == [ { "repository": shell_window.repository, "permissions": shell_window.permissions, "parent": shell_window, "initial_query": "张医生", } ] def test_shell_ai_entry_on_reception_still_opens_global_patient_picker( application: QApplication, shell_window: ShellWindow, monkeypatch: pytest.MonkeyPatch, ) -> None: reception = shell_window.pages["reception"] assert isinstance(reception, _ShellPageDouble) assert shell_window.navigate("reception") reception.ai_context_available = True opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] monkeypatch.setattr( shell_module, "select_and_present_ai_consult", lambda *args, **kwargs: opened.append((args, kwargs)) or False, ) shell_window.ai_top_button.click() application.processEvents() assert reception.ai_open_count == 0 assert len(opened) == 1 assert shell_window.stack.currentWidget() is reception def test_shell_hides_global_ai_entries_without_ai_permission( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: navigation = [ NavigationItem( "appointments", "问诊列表", "号", _ShellPageDouble, ("doctor.appointment/lists",), ) ] monkeypatch.setattr( shell_module, "_resolve_navigation", lambda _menu, _permissions, *, demo_mode: [ (item, item.title) for item in navigation ], ) window = ShellWindow( object(), {"user": {"name": "无 AI 权限医生"}, "demo_mode": True}, permissions={"doctor.appointment/lists"}, ) window.show() application.processEvents() assert window.assistant_card.isHidden() assert window.ai_top_button.isHidden() window.close() application.processEvents() def test_shell_ai_entry_does_not_require_reception_page( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: navigation = [ NavigationItem( "appointments", "问诊列表", "号", _ShellPageDouble, ("doctor.appointment/lists",), ) ] monkeypatch.setattr( shell_module, "_resolve_navigation", lambda _menu, _permissions, *, demo_mode: [ (item, item.title) for item in navigation ], ) opened: list[Any] = [] monkeypatch.setattr( shell_module, "select_and_present_ai_consult", lambda *args, **kwargs: opened.append((args, kwargs)) or False, ) window = ShellWindow( object(), {"user": {"name": "无接诊台医生"}, "demo_mode": True}, permissions={ "doctor.appointment/lists", "tcm.diagnosis/aiAssistant", }, ) window.show() application.processEvents() window.assistant_button.click() application.processEvents() assert len(opened) == 1 assert opened[0][0][0] is window.repository assert opened[0][0][2] is window assert window.stack.currentWidget() is window.pages["appointments"] window.close() application.processEvents() def test_shell_settings_opens_global_local_audio_upload_manager( application: QApplication, shell_window: ShellWindow, monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, Any] = {} def dialog_factory( repository: Any, diagnosis_id: int | None, parent: QWidget, ) -> QDialog: dialog = QDialog(parent) dialog.setObjectName("LocalAudioQueueDialog") captured.update( repository=repository, diagnosis_id=diagnosis_id, parent=parent, dialog=dialog, ) return dialog monkeypatch.setattr(shell_module, "LocalAudioQueueDialog", dialog_factory) shell_window.upload_settings_button.click() application.processEvents() assert captured["repository"] is shell_window.repository assert captured["diagnosis_id"] is None assert captured["parent"] is shell_window assert captured["dialog"].isVisible() captured["dialog"].reject() application.processEvents() assert shell_window._local_audio_settings_dialog is None def test_every_visible_page_navigates_and_visited_tabs_track_active_page( shell_window: ShellWindow, ) -> None: changed: list[str] = [] shell_window.page_changed.connect(changed.append) for key in ( "reception", "appointments", "prescription_library", "prescriptions", "patients", "consultations", ): assert shell_window.navigate(key) assert shell_window.stack.currentWidget() is shell_window.pages[key] assert shell_window.nav_buttons[key].isChecked() assert shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == key assert shell_window.visited_tab_keys() == ( "appointments", "reception", "prescription_library", "prescriptions", "patients", "consultations", ) assert changed[-6:] == [ "reception", "appointments", "prescription_library", "prescriptions", "patients", "consultations", ] def test_real_navigation_refreshes_once_and_current_page_click_is_a_noop( application: QApplication, shell_window: ShellWindow, ) -> None: appointments = shell_window.pages["appointments"] reception = shell_window.pages["reception"] assert isinstance(appointments, _ShellPageDouble) assert isinstance(reception, _ShellPageDouble) assert appointments.refresh_count == 1 assert appointments.show_count == 1 assert reception.refresh_count == 0 shell_window.nav_buttons["reception"].click() application.processEvents() assert reception.refresh_count == 1 assert reception.show_count == 1 shell_window.nav_buttons["reception"].click() application.processEvents() assert reception.refresh_count == 1 assert reception.show_count == 1 shell_window.nav_buttons["appointments"].click() application.processEvents() assert appointments.refresh_count == 2 assert appointments.show_count == 2 def test_non_fixed_tabs_close_and_active_close_renavigates( shell_window: ShellWindow, ) -> None: for key in ("patients", "consultations"): assert shell_window.navigate(key) assert not shell_window.close_tab("appointments") assert shell_window.close_tab("patients") assert "patients" not in shell_window.visited_tab_keys() assert ( shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "consultations" ) assert shell_window.close_current_tab() assert ( shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "appointments" ) assert shell_window.stack.currentWidget() is shell_window.pages["appointments"] assert shell_window.nav_buttons["appointments"].isChecked() shell_window.close_all_tabs() assert shell_window.visited_tab_keys() == ("appointments",) assert shell_window.stack.currentWidget() is shell_window.pages["appointments"] def test_sidebar_collapse_preserves_active_navigation( shell_window: ShellWindow, ) -> None: assert shell_window.navigate("consultations") shell_window.toggle_sidebar() assert shell_window.sidebar.width() == 68 assert shell_window.nav_buttons["consultations"].text() == "" assert shell_window.nav_buttons["consultations"].isChecked() shell_window.toggle_sidebar() assert shell_window.sidebar.width() == 195 assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表") assert shell_window.nav_buttons["consultations"].isChecked() def test_shell_directional_controls_have_no_unicode_arrow_text( shell_window: ShellWindow, ) -> None: assert shell_window.fold_button.text() == "" assert shell_window.refresh_button.text() == "" assert shell_window.fullscreen_button.text() == "" assert shell_window.tabs_menu_button.text() == "" assert all( arrow not in button.text() for button in shell_window.findChildren(QWidget) if hasattr(button, "text") and callable(button.text) for arrow in ("←", "→", "↑", "↓", "▲", "▼", "▴", "▾") ) def test_chat_notification_takes_the_doctor_to_the_matching_workspace( application: QApplication, shell_window: ShellWindow, monkeypatch: pytest.MonkeyPatch, ) -> None: opened: list[Any] = [] monkeypatch.setattr( ShellWindow, "open_diagnosis_by_id", lambda self, diagnosis_id, *, modeless=False: opened.append(diagnosis_id), ) center = shell_window.chat_notifications assert shell_window.navigate("consultations") center.add_notifications( [ { "id": "n1", "type": "patient_opened_chat", "patient_name": "甘先生", "created_at": 1787882294, } ] ) application.processEvents() assert [item.id for item in center.pending] == ["n1"] # 患者进入会话 → 直接落到接诊台。 next(iter(center._cards.values())).open_button.click() application.processEvents() assert shell_window._active_page_key == "reception" assert center.pending == [] # 面诊结束 → 打开对应诊单。 center.add_notifications( [ { "id": "n2", "type": "consultation_complete", "patient_name": "甘先生", "doctor_name": "陈医生", "diagnosis_id": 8169, "created_at": 1787882294, } ] ) next(iter(center._cards.values())).open_button.click() application.processEvents() assert opened == [8169] assert center.pending == []