"""Update dialog contract for optional and forced desktop upgrades.""" from __future__ import annotations import os from dataclasses import replace from types import SimpleNamespace os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtCore import QObject, Qt from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication from doctor_workstation.services.app_update import ( PACKAGE_TYPE_INNO_SETUP, UpdateOffer, UpdatePackage, ) from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog, AppUpdateSession from doctor_workstation.ui.theme import apply_theme def _offer( *, force: bool, can_install: bool = True, install_unavailable_reason: str = "", ) -> UpdateOffer: package = ( UpdatePackage( url="https://cdn.example.com/DoctorWorkstation.zip", sha256="a" * 64, size=1024, filename="DoctorWorkstation.zip", ) if can_install else None ) return UpdateOffer( has_update=True, force=force, enabled=True, current_version="0.1.0", latest_version="0.2.0", min_version="", title="医生工作站 0.2.0", notes="修复若干问题", platform="windows", arch="x64", package=package, can_install=can_install, install_unavailable_reason=install_unavailable_reason, ) def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None: app = application or QApplication.instance() or QApplication([]) apply_theme(app) dialog = AppUpdateDialog(_offer(force=False)) dialog.show() app.processEvents() deferred: list[bool] = [] dialog.update_deferred.connect(lambda: deferred.append(True)) assert dialog.later_button.isVisible() assert dialog.later_button.isEnabled() assert dialog.later_button.text() == "稍后提醒" assert not dialog.exit_button.isVisible() assert dialog.update_button.text() == "立即更新" assert dialog.notes.toPlainText() == "修复若干问题" dialog.later_button.click() assert deferred == [True] assert not dialog.isVisible() dialog.deleteLater() app.processEvents() def test_forced_update_dialog_has_explicit_exit_and_blocks_implicit_close( application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) apply_theme(app) dialog = AppUpdateDialog(_offer(force=True)) dialog.show() app.processEvents() assert not dialog.later_button.isVisible() assert dialog.exit_button.isVisible() assert dialog.exit_button.isEnabled() assert dialog.exit_button.text() == "退出软件" assert "必须更新" in dialog.badge.text() dialog.close() app.processEvents() assert dialog.isVisible() QTest.keyClick(dialog, Qt.Key.Key_Escape) app.processEvents() assert dialog.isVisible() dialog.set_busy(True) dialog.show_download_progress(256, 1024) app.processEvents() assert dialog.exit_button.isVisible() assert dialog.exit_button.isEnabled() assert not dialog.update_button.isEnabled() assert not dialog.cancel_button.isVisible() dialog.close() app.processEvents() assert dialog.isVisible() QTest.keyClick(dialog, Qt.Key.Key_Escape) app.processEvents() assert dialog.isVisible() dialog.allow_application_exit() dialog.close() app.processEvents() assert not dialog.isVisible() dialog.deleteLater() app.processEvents() def test_forced_update_exit_button_emits_dedicated_request( application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) dialog = AppUpdateDialog(_offer(force=True)) requests: list[bool] = [] dialog.exit_requested.connect(lambda: requests.append(True)) dialog.show() dialog.set_busy(True) app.processEvents() dialog.exit_button.click() assert requests == [True] assert dialog.isVisible() dialog.hide() dialog.deleteLater() app.processEvents() def test_session_quits_immediately_when_update_has_not_started( application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) host = QObject() quit_requests: list[bool] = [] host.request_quit = lambda: quit_requests.append(True) # type: ignore[attr-defined] session = AppUpdateSession(host) dialog = AppUpdateDialog(_offer(force=True)) session.dialog = dialog dialog.show() app.processEvents() session._request_exit(dialog) assert session._cancel_event.is_set() assert quit_requests == [True] assert not dialog.exit_button.isEnabled() assert not dialog.isVisible() dialog.hide() dialog.deleteLater() app.processEvents() def test_session_waits_for_update_worker_before_quitting( application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) host = QObject() quit_requests: list[bool] = [] host.request_quit = lambda: quit_requests.append(True) # type: ignore[attr-defined] session = AppUpdateSession(host) dialog = AppUpdateDialog(_offer(force=True)) session.dialog = dialog active_signals = QObject() session._signals = active_signals # type: ignore[assignment] session._active_install_signals = active_signals # type: ignore[assignment] dialog.show() app.processEvents() session._request_exit(dialog) assert session._cancel_event.is_set() assert quit_requests == [] assert not dialog.exit_button.isEnabled() assert "退出软件" in dialog.status_label.text() assert dialog.isVisible() session._finish_install(active_signals, dialog, object()) # type: ignore[arg-type] assert quit_requests == [] session._on_install_finished(active_signals) # type: ignore[arg-type] assert quit_requests == [True] assert session._active_install_signals is None assert not dialog.isVisible() dialog.hide() dialog.deleteLater() app.processEvents() def test_unavailable_update_dialog_shows_policy_reason( application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) apply_theme(app) reason = "无法自动安装:自动安装 Windows 更新必须开启 HTTPS 证书校验。" dialog = AppUpdateDialog( _offer( force=False, can_install=False, install_unavailable_reason=reason, ) ) dialog.show() app.processEvents() assert dialog.status_label.text() == reason assert not dialog.update_button.isEnabled() dialog.close() def test_session_explains_disabled_certificate_verification() -> None: host = QObject() host.config = SimpleNamespace(verify_ssl=False) # type: ignore[attr-defined] session = AppUpdateSession(host) session._generation = 1 presented: list[UpdateOffer] = [] session._present = presented.append # type: ignore[method-assign] offer = replace( _offer(force=True), package=UpdatePackage( url="https://cdn.example.com/DoctorWorkstation-Setup.exe", sha256="a" * 64, size=1024, filename="DoctorWorkstation-Setup.exe", type=PACKAGE_TYPE_INNO_SETUP, ), ) session._on_offer(offer, interactive=True, generation=1) assert len(presented) == 1 assert presented[0].can_install is False assert presented[0].force is False assert presented[0].package is None assert "开启 HTTPS 证书校验" in presented[0].install_unavailable_reason assert "取消勾选“信任自签名证书" in presented[0].install_unavailable_reason