# 强制更新“退出软件”回归测试设计 ## 结论 建议在 `app/tests/test_app_update_ui.py` 把“退出软件”作为强制更新对话框的独立显式动作测试,不把它等同于关闭窗口或 `reject()`: - 强制更新在初始状态和下载中状态都显示且启用“退出软件”。 - 点击只发出一次专用信号(下文假定为 `exit_requested`);对话框自身不静默 `reject()`。 - 普通更新仍显示“稍后提醒”,不显示“退出软件”,原有 `update_deferred` + `reject()` 行为不变。 - 强制更新无论初始还是下载中,标题栏关闭和 Escape 都继续被拦截;用户只能通过明确的“退出软件”动作退出。 生产实现若采用独立控件,建议公开 `exit_button`;这比把强制退出语义塞进现有 `later_button` 更容易测试,也避免 `_defer()` 同时承担“稍后”和“退出”两种相反行为。若实现选择复用 `later_button`,下述断言可把 `exit_button` 替换为该控件,但至少应保留独立的 `exit_requested` 信号。 ## 当前覆盖缺口 当前 `test_app_update_ui.py` 有以下相关覆盖: - `test_optional_update_dialog_allows_later` 只断言普通更新的稍后按钮可见和更新文案,未点击按钮,也未验证 `update_deferred`。 - `test_forced_update_dialog_hides_defer_and_blocks_escape` 断言稍后按钮隐藏,并用 `dialog.close()` 验证强更无法关闭;尽管测试名写有 `blocks_escape`,测试体没有发送 Escape。 - 没有覆盖 `set_busy(True)`。当前 `set_busy()` 会禁用 `later_button`,因此若复用该按钮显示“退出软件”,下载中会直接回归为不可退出。 - 没有覆盖退出信号的次数,也没有证明显式退出动作不会被当成普通 `reject()`。 现有 service 测试 `app/tests/test_app_update.py` 主要覆盖 offer 解析、下载、校验与更新应用,不适合承载 Qt 按钮和键盘行为;这些回归应继续留在 `test_app_update_ui.py`。 ## 建议测试矩阵 | offer | 对话框状态 | 稍后按钮 | 退出按钮 | 更新按钮 | 取消下载 | 关闭 / Escape | |---|---|---|---|---|---|---| | 强制 | 初始 | 隐藏 | 显示、启用 | 启用 | 隐藏 | 均拦截 | | 强制 | 下载中 | 隐藏 | 显示、启用 | 禁用 | 隐藏 | 均拦截 | | 普通 | 初始 | 显示、启用,文案“稍后提醒” | 隐藏 | 启用 | 隐藏 | 允许 | | 普通 | 下载中 | 保持现有禁用语义 | 隐藏 | 禁用 | 显示 | `closeEvent` 目前拦截;本次不要顺带定义 Escape 新语义 | 最后一格存在现有 Qt 行为不对称:普通更新下载中时 `closeEvent()` 会拦截标题栏关闭,但 `keyPressEvent()` 仅专门拦截强制更新的 Escape。除非产品需求明确要求调整普通更新下载中的 Escape,否则本次回归不要无意固化或改变该行为。 ## 推荐测试拆分 测试文件增加: ```python import pytest from PySide6.QtCore import Qt from PySide6.QtTest import QTest ``` ### 1. 强制更新在初始和下载中均可显式退出 用参数化覆盖两个状态,避免只测初始渲染: ```python @pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"]) def test_forced_update_exit_action_stays_available( busy: bool, application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) apply_theme(app) dialog = AppUpdateDialog(_offer(force=True)) dialog.show() if busy: dialog.set_busy(True) dialog.show_download_progress(256, 1024) app.processEvents() assert not dialog.later_button.isVisible() assert dialog.exit_button.isVisible() assert dialog.exit_button.isEnabled() assert dialog.exit_button.text() == "退出软件" assert dialog.update_button.isEnabled() is (not busy) assert not dialog.cancel_button.isVisible() dialog.hide() dialog.deleteLater() app.processEvents() ``` 这里必须在 `set_busy(True)` 后断言,才能捕获“统一禁用底部按钮”导致强制更新无法退出的回归。调用 `show_download_progress()` 同时让测试更贴近真实 `_start_install()` 顺序:先 `set_busy(True)`,再进入下载进度态。 ### 2. 点击退出按钮只发一次专用信号 ```python def test_forced_update_exit_button_emits_request( application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) dialog = AppUpdateDialog(_offer(force=True)) requested: list[bool] = [] dialog.exit_requested.connect(lambda: requested.append(True)) dialog.show() app.processEvents() dialog.exit_button.click() assert requested == [True] assert dialog.isVisible() dialog.hide() dialog.deleteLater() app.processEvents() ``` `assert dialog.isVisible()` 有意证明按钮在 dialog 层只表达“请求退出软件”,而不是绕开应用级清理流程直接 `reject()`。真正退出应由 `AppUpdateSession`/应用层的 slot 完成。若最终设计明确由 dialog 自身关闭,则删除这一条,但仍要保留信号次数断言。 还可把该测试参数化为初始/下载中并在两种状态点击;若测试数量需要控制,则第一个参数化测试负责可用性,第二个测试负责一次信号已足够定位大部分回归。 ### 3. 普通更新仍是“稍后提醒” 建议增强现有 optional 测试,而不是只检查可见性: ```python def test_optional_update_dialog_keeps_defer_action( application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) dialog = AppUpdateDialog(_offer(force=False)) deferred: list[bool] = [] dialog.update_deferred.connect(lambda: deferred.append(True)) dialog.show() app.processEvents() assert dialog.later_button.isVisible() assert dialog.later_button.isEnabled() assert dialog.later_button.text() == "稍后提醒" assert not dialog.exit_button.isVisible() dialog.later_button.click() assert deferred == [True] assert not dialog.isVisible() dialog.deleteLater() app.processEvents() ``` 这条会防止实现“退出软件”时误把普通更新的次按钮文案、信号或关闭行为一起改掉。 ### 4. 强制更新明确拦截关闭与 Escape 把当前名不副实的测试改成真实事件测试,并参数化初始/下载中: ```python @pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"]) def test_forced_update_only_allows_explicit_exit( busy: bool, application: QApplication | None = None, ) -> None: app = application or QApplication.instance() or QApplication([]) dialog = AppUpdateDialog(_offer(force=True)) dialog.show() if busy: dialog.set_busy(True) dialog.show_download_progress(256, 1024) app.processEvents() dialog.close() app.processEvents() assert dialog.isVisible() QTest.keyClick(dialog, Qt.Key.Key_Escape) app.processEvents() assert dialog.isVisible() dialog.hide() dialog.deleteLater() app.processEvents() ``` 行为断言比检查 window flags 更稳定:不同平台可能规范化窗口标志,但 `closeEvent()`/`keyPressEvent()` 是否真正保留对话框才是用户可观察契约。 ## 会话层边界 dialog 信号测试只能证明点击请求已发出,不能证明应用最终退出。生产接线还应满足: - `AppUpdateSession._present()` 连接 `exit_requested` 到一个应用级退出入口。 - 下载中退出时先设置取消标志,使 `download_package(..., cancelled=...)` 尽快结束并清理 `.part` 文件,再请求 `QApplication.quit()`;否则全局线程池任务可能拖延进程退出。 - 应用级退出必须走既有 `QApplication.aboutToQuit -> ApplicationController.shutdown`,不要从 dialog 直接调用 `sys.exit()` 或跳过资源清理。 若实现为可替换的 session 方法(例如 `_request_exit()`),可另补一个 session 单测,mock/monkeypatch 该方法后验证 dialog 信号接线;不要在 pytest 共享的真实 `QApplication` 上直接调用 `quit()`,以免污染同进程后续 UI 测试。本次题目明确要求的四项回归,以上 dialog 测试已经可以独立、稳定覆盖。 ## 验证记录 - 已读取根 `AGENTS.md`;工作树中不存在 `.trellis/workflow.md` 和 `.trellis/spec/`,因此无法应用额外 Trellis 分层规范。 - 只读运行现有基线:`app/.venv/Scripts/python.exe -m pytest tests/test_app_update_ui.py -q`,结果 `4 passed`。 - 本文之外未修改生产代码或测试代码;工作树中原有的 `app_update.py` 与 `test_app_update_ui.py` 未提交改动均已保留。