8.6 KiB
强制更新“退出软件”回归测试设计
结论
建议在 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,否则本次回归不要无意固化或改变该行为。
推荐测试拆分
测试文件增加:
import pytest
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
1. 强制更新在初始和下载中均可显式退出
用参数化覆盖两个状态,避免只测初始渲染:
@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. 点击退出按钮只发一次专用信号
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 测试,而不是只检查可见性:
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
把当前名不副实的测试改成真实事件测试,并参数化初始/下载中:
@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未提交改动均已保留。