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

183 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 强制更新“退出软件”回归测试设计
## 结论
建议在 `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` 未提交改动均已保留。