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_opens_the_current_selected_diagnosis( application: QApplication, shell_window: ShellWindow, ) -> None: current = shell_window.pages["appointments"] assert isinstance(current, _ShellPageDouble) current.ai_context_available = True shell_window.assistant_button.click() application.processEvents() assert current.ai_open_count == 1 assert shell_window.stack.currentWidget() is current def test_shell_ai_entry_falls_back_to_reception_and_opens_its_selection( application: QApplication, shell_window: ShellWindow, ) -> None: appointments = shell_window.pages["appointments"] reception = shell_window.pages["reception"] assert isinstance(appointments, _ShellPageDouble) assert isinstance(reception, _ShellPageDouble) reception.ai_context_available = True shell_window.ai_top_button.click() application.processEvents() assert appointments.ai_open_count == 1 assert reception.ai_open_count == 1 assert shell_window.stack.currentWidget() is reception def test_shell_ai_entry_on_reception_opens_chat_instead_of_noop( application: QApplication, shell_window: ShellWindow, ) -> None: reception = shell_window.pages["reception"] assert isinstance(reception, _ShellPageDouble) assert shell_window.navigate("reception") reception.ai_context_available = True shell_window.ai_top_button.click() application.processEvents() assert reception.ai_open_count == 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_reports_when_reception_is_not_available( 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 ], ) messages: list[str] = [] monkeypatch.setattr( shell_module, "show_toast", lambda _parent, message, *_args, **_kwargs: messages.append(message), ) 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 any("没有可用的接诊台" in message for message in messages) assert all("已进入接诊台" not in message for message in messages) 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 ("←", "→", "↑", "↓", "▲", "▼", "▴", "▾") )