Files
zyt/app/research/forced_update_exit_tests.md
T
2026-08-28 18:24:37 +08:00

8.6 KiB
Raw Blame History

强制更新“退出软件”回归测试设计

结论

建议在 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.pytest_app_update_ui.py 未提交改动均已保留。