Files
zyt/app/tests/test_ui_contract.py
T
2026-08-28 18:24:37 +08:00

621 lines
22 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from PySide6.QtCore import QCoreApplication, QSettings
from PySide6.QtGui import QPageSize, QRawFont
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QMessageBox, QVBoxLayout
from doctor_workstation import app as app_module
from doctor_workstation.app import ApplicationController
from doctor_workstation.config import AppConfig
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import login as login_module
from doctor_workstation.ui import widgets as widget_module
from doctor_workstation.ui.login import LoginWindow
from doctor_workstation.ui.pages.consultations import _video_payload
from doctor_workstation.ui.shell import NAVIGATION
from doctor_workstation.ui.theme import apply_theme
from doctor_workstation.ui.widgets import (
friendly_error,
gender_text,
invoke,
set_authentication_expired_handler,
)
def test_consultation_video_payload_keeps_appointment_and_diagnosis_ids_distinct() -> None:
record = SimpleNamespace(
id=501,
appointment_id=101,
patient_id=301,
patient_name="林晓岚",
)
payload = _video_payload(record)
assert payload["appointment_id"] == 101
assert payload["diagnosis_id"] == 501
assert payload["patient_id"] == 301
def test_gender_text_maps_legacy_codes_and_preserves_labels() -> None:
assert gender_text(1) == "男"
assert gender_text("2") == "女"
assert gender_text(0) == "未知"
assert gender_text("未知标签") == "未知标签"
def test_theme_resolves_real_chinese_glyphs() -> None:
"""Guard packaged/offscreen builds against rendering every CJK glyph as tofu."""
application = QApplication.instance() or QApplication([])
apply_theme(application)
raw_font = QRawFont.fromFont(application.font())
glyphs = raw_font.glyphIndexesForString("甄养堂医生工作站")
assert glyphs
assert all(glyph > 0 for glyph in glyphs)
assert len(set(glyphs)) > 1
def test_theme_marks_dynamic_business_dialogs_and_semantic_buttons() -> None:
application = QApplication.instance() or QApplication([])
apply_theme(application)
dialog = QDialog()
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel,
parent=dialog,
)
QVBoxLayout(dialog).addWidget(buttons)
dialog.show()
application.processEvents()
assert dialog.property("businessDialog") is True
assert buttons.button(QDialogButtonBox.StandardButton.Save).property("variant") == "primary"
assert buttons.button(QDialogButtonBox.StandardButton.Cancel).property("variant") == "secondary"
dialog.close()
application.processEvents()
def test_navigation_requires_each_pages_actual_list_capability() -> None:
assert {item.key: item.permissions for item in NAVIGATION} == {
"reception": ("doctor.appointment/lists",),
"appointments": ("doctor.appointment/lists",),
"prescription_library": ("tcm.prescriptionLibrary/lists",),
"prescriptions": ("tcm.prescription/lists",),
"patients": ("firstvisit.myPatient/lists",),
"consultations": ("tcm.diagnosis/lists",),
}
def test_video_release_does_not_remove_a_newer_call() -> None:
older = object()
newer = object()
# _release_video_call also clears the preview slot, so the double needs the
# same attributes the real controller sets up in __init__.
controller = SimpleNamespace(
video_calls={"501": newer},
_video_preview_state=None,
_video_preview_generation=0,
)
ApplicationController._release_video_call(controller, "501", older)
assert controller.video_calls == {"501": newer}
ApplicationController._release_video_call(controller, "501", newer)
assert controller.video_calls == {}
def test_invoke_leaves_prescription_filters_for_repository_mapping() -> None:
"""The UI adapter only normalises pagination, not API-specific DTO fields."""
class Repository:
def list_prescriptions(self, **filters: Any) -> dict[str, Any]:
return filters
assert invoke(
Repository(),
"prescriptions",
keyword="CF-2026-8",
status=2,
page=3,
page_size=15,
) == {
"keyword": "CF-2026-8",
"status": 2,
"page_no": 3,
"page_size": 15,
}
def test_async_error_dispatch_consumes_active_session_expiry_globally() -> None:
"""A consumed authentication expiry does not also reach a stale page."""
global_errors: list[Exception] = []
local_errors: list[Exception] = []
error = AuthenticationExpiredError("expired", code=-1)
try:
set_authentication_expired_handler(lambda caught: global_errors.append(caught) is None)
widget_module._dispatch_async_error(error, local_errors.append)
finally:
set_authentication_expired_handler(None)
assert global_errors == [error]
assert local_errors == []
def test_controller_session_expiry_returns_to_login_once() -> None:
"""The composition root owns the single transition out of an active shell."""
messages: list[str] = []
controller = SimpleNamespace(
shell_window=object(),
current_repository=object(),
_authentication_expiry_in_progress=False,
_logout=lambda *, message="": messages.append(message),
)
error = AuthenticationExpiredError("expired", code=-1)
assert ApplicationController._on_authentication_expired(controller, error)
assert ApplicationController._on_authentication_expired(controller, error)
assert messages == ["登录状态已失效,请重新登录。"]
def test_persisted_session_restore_blocks_manual_submit_before_worker_start(
monkeypatch: Any,
) -> None:
"""Login controls are locked before the asynchronous restore is dispatched."""
pending_states: list[bool] = []
worker_calls: list[tuple[Any, dict[str, Any]]] = []
worker = object()
repository = SimpleNamespace(restore_session=lambda: None)
login_window = SimpleNamespace(set_session_restore_pending=pending_states.append)
controller = SimpleNamespace(
remote_repository=repository,
_shutting_down=False,
config=SimpleNamespace(demo_mode=False),
current_repository=None,
_restore_generation=0,
_restore_in_progress=False,
_restore_worker=None,
login_window=login_window,
_on_restore_success=lambda *args: None,
_on_restore_error=lambda *args: None,
_on_restore_finished=lambda *args: None,
)
def fake_run_async(function: Any, **callbacks: Any) -> object:
assert pending_states == [True]
worker_calls.append((function, callbacks))
return worker
monkeypatch.setattr(app_module, "run_async", fake_run_async)
ApplicationController._begin_session_restore(controller)
assert controller._restore_in_progress
assert controller._restore_worker is worker
assert worker_calls[0][0] == repository.restore_session
def test_login_restore_pending_uses_existing_submit_guard() -> None:
"""A manual submit is a no-op for the whole persisted-token validation window."""
class Banner:
def clear(self) -> None:
pass
class LoginDouble:
def __init__(self) -> None:
self._loading = False
self.error_banner = Banner()
self.loading_options: dict[str, Any] = {}
def _set_loading(self, loading: bool, **options: Any) -> None:
self._loading = loading
self.loading_options = options
login = LoginDouble()
LoginWindow.set_session_restore_pending(login, True) # type: ignore[arg-type]
LoginWindow.submit(login) # type: ignore[arg-type]
assert login._loading
assert login.loading_options["button_text"] == "正在恢复登录…"
def test_real_demo_login_reaches_success_without_widget_adapter(
monkeypatch: Any,
tmp_path: Any,
) -> None:
"""Exercise the actual login widgets and demo repository as one contract."""
application = QApplication.instance() or QApplication([])
repository = DemoDoctorRepository()
settings = QSettings(str(tmp_path / "login.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="https://127.0.0.1:9",
request_timeout=30,
demo_mode=True,
debug_mode=True,
remembered_account="",
)
payloads: list[dict[str, Any]] = []
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
callbacks["on_success"](function())
except Exception as error: # pragma: no cover - assertion output is more useful
callbacks["on_error"](error)
finally:
callbacks["on_finished"]()
return object()
monkeypatch.setattr(login_module, "run_async", run_immediately)
window = LoginWindow(
object(),
config=config,
demo_repository=repository,
settings=settings,
)
window.login_succeeded.connect(payloads.append)
window.submit()
assert len(payloads) == 1
assert payloads[0]["repository"] is repository
assert payloads[0]["demo_mode"] is True
assert window.busy_overlay.label.text() == "正在验证账号…"
assert not window._loading
window.close()
application.processEvents()
def test_remembered_password_survives_a_new_login_window(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings_path = tmp_path / "remember-password.ini"
secrets: dict[tuple[str, str], str] = {}
def load_password(*, account: str, scope: str) -> str | None:
return secrets.get((account, scope.rstrip("/")))
def save_password(password: str, *, account: str, scope: str) -> bool:
secrets[(account, scope.rstrip("/"))] = password
return True
def clear_password(*, account: str, scope: str) -> None:
secrets.pop((account, scope.rstrip("/")), None)
credentials = SimpleNamespace(
load_password=load_password,
save_password=save_password,
clear_password=clear_password,
)
config = SimpleNamespace(
api_base_url="https://example.test/adminapi",
request_timeout=30,
verify_ssl=True,
demo_mode=False,
remembered_account="",
)
first = LoginWindow(
object(),
config=config,
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
credential_store=credentials,
)
first._on_login_success({}, "admin", True, "secret-value")
first.close()
application.processEvents()
restored = LoginWindow(
object(),
config=config,
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
credential_store=credentials,
)
assert restored.account_edit.text() == "admin"
assert restored.password_edit.text() == "secret-value"
assert restored.remember_check.text() == "记住密码"
assert restored.remember_check.isChecked()
restored._on_login_success({}, "admin", False, "secret-value")
restored.close()
application.processEvents()
forgotten = LoginWindow(
object(),
config=config,
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
credential_store=credentials,
)
assert forgotten.password_edit.text() == ""
assert not forgotten.remember_check.isChecked()
forgotten.close()
application.processEvents()
def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
tmp_path: Any,
) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "server-panel.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="",
request_timeout=30,
demo_mode=False,
debug_mode=True,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
window.resize(860, 590)
window.show()
window.server_toggle.setChecked(True)
window._toggle_server_panel(True)
application.processEvents()
assert window.server_panel.isVisible()
assert window.server_panel.height() >= window.server_panel.minimumSizeHint().height()
assert window.server_url_label.geometry().bottom() < window.server_url_edit.geometry().top()
assert window.server_url_edit.geometry().bottom() < window.timeout_label.geometry().top()
assert window.timeout_spin.geometry().right() < window.save_server_button.geometry().left()
assert (
window.timeout_label.geometry().bottom() < window.allow_self_signed_check.geometry().top()
)
assert window.allow_self_signed_check.geometry().bottom() < window.server_hint.geometry().top()
window.allow_self_signed_check.setChecked(True)
application.processEvents()
assert window.ssl_warning.isVisible()
assert window.allow_self_signed_check.geometry().bottom() < window.ssl_warning.geometry().top()
assert window.ssl_warning.geometry().bottom() < window.server_hint.geometry().top()
window.close()
application.processEvents()
def test_production_login_hides_and_blocks_debug_controls(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "production.ini"), QSettings.Format.IniFormat)
settings.setValue("server/base_url", "https://stale.example.test")
settings.setValue("server/verify_ssl", False)
remote_repository = object()
demo_repository = DemoDoctorRepository()
config = SimpleNamespace(
api_base_url="https://prod.example.test/adminapi",
request_timeout=30,
verify_ssl=True,
demo_mode=True,
debug_mode=False,
remembered_account="",
)
window = LoginWindow(
remote_repository,
config=config,
demo_repository=demo_repository,
settings=settings,
)
window.show()
application.processEvents()
assert not window.demo_check.isVisible()
assert not window.debug_settings_section.isVisible()
assert not window.server_toggle.isVisible()
assert not window.server_panel.isVisible()
assert not window.demo_check.isChecked()
assert window.active_repository is remote_repository
assert window.server_url_edit.text() == "https://prod.example.test/adminapi"
assert window._credential_scope() == "https://prod.example.test/adminapi"
window._on_demo_toggled(True)
window._toggle_server_panel(True)
assert not window.demo_check.isChecked()
assert window.active_repository is remote_repository
assert window.server_panel.isHidden()
window.close()
application.processEvents()
def test_server_settings_can_persist_self_signed_debug_mode(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "self-signed.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="https://internal.example.test",
request_timeout=30,
verify_ssl=True,
demo_mode=False,
debug_mode=True,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
emitted: list[dict[str, Any]] = []
window.server_settings_changed.connect(emitted.append)
window.allow_self_signed_check.setChecked(True)
window._save_server_settings()
assert emitted[-1]["verify_ssl"] is False
assert settings.value("server/verify_ssl", type=bool) is False
assert "证书校验已关闭" in window.error_banner.label.text()
window.close()
application.processEvents()
def test_login_applies_self_signed_setting_before_authentication(
monkeypatch: Any,
tmp_path: Any,
) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "self-signed-login.ini"), QSettings.Format.IniFormat)
config = AppConfig(
api_base_url="https://internal.example.test/adminapi",
demo_mode=False,
debug_mode=True,
verify_ssl=True,
)
calls: list[str] = []
class Repository:
def __init__(self, name: str) -> None:
self.name = name
def login(self, **_payload: Any) -> object:
calls.append(self.name)
return object()
def get_current_user(self) -> object:
return object()
old_repository = Repository("old")
rebuilt_repository = Repository("rebuilt-without-verification")
window = LoginWindow(old_repository, config=config, settings=settings)
def rebuild_on_config_change(updated: AppConfig) -> None:
assert updated.verify_ssl is False
window.repository = rebuilt_repository
window.active_repository = rebuilt_repository
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
callbacks["on_success"](function())
except Exception as error: # pragma: no cover - assertion output is more useful
callbacks["on_error"](error)
finally:
callbacks["on_finished"]()
return object()
monkeypatch.setattr(login_module, "run_async", run_immediately)
window.config_changed.connect(rebuild_on_config_change)
window.account_edit.setText("admin")
window.password_edit.setText("secret")
window.allow_self_signed_check.setChecked(True)
window.submit()
assert calls == ["rebuilt-without-verification"]
assert settings.value("server/verify_ssl", type=bool) is False
window.close()
application.processEvents()
def test_certificate_error_explains_self_signed_server_setting() -> None:
message = friendly_error(
RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate")
)
assert "信任自签名证书" in message
assert "服务器设置" in message
def test_qt_standard_dialog_buttons_are_localized_to_chinese() -> None:
application = QApplication.instance() or QApplication([])
app_module._install_chinese_translations(application)
question = QMessageBox()
question.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
assert question.button(QMessageBox.StandardButton.Yes).text() == "是"
assert question.button(QMessageBox.StandardButton.Cancel).text() == "取消"
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Save
| QDialogButtonBox.StandardButton.Close
)
assert buttons.button(QDialogButtonBox.StandardButton.Ok).text() == "确定"
assert buttons.button(QDialogButtonBox.StandardButton.Save).text() == "保存"
assert buttons.button(QDialogButtonBox.StandardButton.Close).text() == "关闭"
assert QCoreApplication.translate("QPageSize", "A4") == "A4"
assert QPageSize(QPageSize.PageSizeId.A4).isValid()
def test_friendly_error_hides_english_technical_messages() -> None:
message = friendly_error(TypeError("invoke() takes 2 positional arguments but 3 were given"))
assert message == "程序执行失败,请重试;若问题持续出现,请联系管理员。"
assert friendly_error(RuntimeError("API response envelope must be an object")) == (
"服务器返回的数据格式不正确,请联系管理员检查接口。"
)
def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "certificate-error.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="https://internal.example.test",
request_timeout=30,
verify_ssl=True,
demo_mode=False,
debug_mode=True,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
window._on_login_error(RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate"))
assert window.server_toggle.isChecked()
assert not window.server_panel.isHidden()
assert "信任自签名证书" in window.error_banner.label.text()
window.close()
application.processEvents()
def test_certificate_error_does_not_reveal_production_server_settings(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "production-certificate.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="https://prod.example.test/adminapi",
request_timeout=30,
verify_ssl=True,
demo_mode=False,
debug_mode=False,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
window.show()
window._on_login_error(RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED]"))
application.processEvents()
assert not window.server_toggle.isChecked()
assert not window.debug_settings_section.isVisible()
assert window.server_panel.isHidden()
assert "联系管理员" in window.error_banner.label.text()
window.close()
application.processEvents()
def test_business_dialogs_can_be_maximized_but_prompts_cannot() -> None:
"""Dense AI panels and editors were stuck at their constructed size."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
from doctor_workstation.ui.theme import allow_dialog_resize
application = QApplication.instance() or QApplication([])
assert application is not None
dialog = QDialog()
dialog.resize(600, 400)
allow_dialog_resize(dialog)
flags = dialog.windowFlags()
assert flags & Qt.WindowType.WindowMaximizeButtonHint
assert flags & Qt.WindowType.WindowMinimizeButtonHint
assert dialog.isSizeGripEnabled()
dialog.deleteLater()
# Transient prompts keep their plain frame.
prompt = QMessageBox()
allow_dialog_resize(prompt)
assert not (prompt.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
prompt.deleteLater()
# A dialog that pinned itself to a fixed size keeps that decision.
fixed = QDialog()
fixed.setFixedSize(420, 300)
allow_dialog_resize(fixed)
assert not (fixed.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
fixed.deleteLater()