Files
zyt/app/tests/test_shell_contract.py
T
2026-08-20 17:56:08 +08:00

505 lines
16 KiB
Python

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_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 ("←", "→", "↑", "↓", "▲", "▼", "▴", "▾")
)