183 lines
8.6 KiB
Markdown
183 lines
8.6 KiB
Markdown
# 强制更新“退出软件”回归测试设计
|
||
|
||
## 结论
|
||
|
||
建议在 `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` 未提交改动均已保留。
|