feat: add startup forced update check

This commit is contained in:
chengma
2026-07-07 17:58:28 +08:00
parent 610a2304cc
commit 95b180e9cf
9 changed files with 527 additions and 12 deletions
+78 -1
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
import os
import sys
import webbrowser
from .. import appconfig
from .. import appconfig, diagnostics, update_check
from ..version import APP_NAME, display_name
from . import widgets as _widgets
from .widgets import *
@@ -42,6 +43,80 @@ def _ensure_offscreen_for_headless_tests():
os.environ["QT_QPA_PLATFORM"] = "offscreen"
def _write_update_check_diagnostic(message, *, result=None, exc=None):
payload = None
if result is not None:
payload = {
"checked": result.checked,
"forced": result.forced,
"current_version": result.current_version,
"latest_version": result.latest_version,
"min_supported_version": result.min_supported_version,
"download_url": result.download_url,
"error": result.error,
}
try:
diagnostics.write_diagnostic_log(
message,
level="WARNING",
step="startup_update_check",
payload=payload,
exc=exc,
)
except Exception:
# 版本检查第一版必须失败放行,诊断日志不可写也不能阻断启动。
pass
def _forced_update_details(result):
online_version = result.latest_version or result.min_supported_version or "未知"
lines = [
f"当前版本:{result.current_version}",
f"线上版本:{online_version}",
]
if result.min_supported_version:
lines.append(f"最低支持版本:{result.min_supported_version}")
if result.message:
lines.append(f"升级说明:{result.message}")
if result.download_url:
lines.append("请下载新版,关闭程序后覆盖程序文件和 _internal/,保留 data/ 目录。")
else:
lines.append("版本接口未提供下载地址,请联系管理员获取新版后再使用。")
return "\n".join(lines)
def _show_forced_update_dialog(result, *, parent=None, opener=None) -> bool:
opener = opener or webbrowser.open
box = QMessageBox(parent)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("必须升级")
box.setText("当前版本已不能继续使用,请先升级到新版。")
box.setInformativeText(_forced_update_details(result))
download_button = box.addButton("下载新版", QMessageBox.AcceptRole)
exit_button = box.addButton("退出程序", QMessageBox.RejectRole)
if not result.download_url and hasattr(download_button, "setEnabled"):
download_button.setEnabled(False)
box.setDefaultButton(download_button if result.download_url else exit_button)
box.exec()
if box.clickedButton() is download_button and result.download_url:
opener(result.download_url)
return False
def _run_startup_update_gate(*, checker=None, opener=None) -> bool:
try:
result = (checker or update_check.check_for_update)()
except Exception as exc:
_write_update_check_diagnostic("启动版本检查异常,已允许继续使用", exc=exc)
return True
if result.error:
_write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result)
if result.forced:
return _show_forced_update_dialog(result, opener=opener)
return True
def main() -> int:
if QT_IMPORT_ERROR is not None:
print(f"{APP_NAME} GUI 无法启动:当前 Python 环境未安装 PySide6。")
@@ -59,6 +134,8 @@ def main() -> int:
except appconfig.ConfigError as exc:
QMessageBox.critical(None, "启动配置错误", str(exc))
return 1
if not _run_startup_update_gate():
return 1
window = MainWindow()
window.show()
return app.exec()
+189
View File
@@ -0,0 +1,189 @@
"""Startup update-check helpers.
The first release only decides whether the app may enter the main window.
It never downloads, overwrites, deletes, or migrates local program/data files.
"""
from __future__ import annotations
import json
import re
import urllib.request
from dataclasses import dataclass
from .version import APP_CODE_NAME, APP_UPDATE_CHECK_URL, APP_VERSION
DEFAULT_TIMEOUT_SECONDS = 3.0
class UpdateCheckError(RuntimeError):
"""Raised for invalid update-check inputs or server responses."""
@dataclass(frozen=True)
class UpdateInfo:
latest_version: str = ""
min_supported_version: str = ""
force_update: bool = False
download_url: str = ""
sha256: str = ""
message: str = ""
@dataclass(frozen=True)
class UpdateCheckResult:
current_version: str
checked: bool = False
forced: bool = False
latest_version: str = ""
min_supported_version: str = ""
download_url: str = ""
sha256: str = ""
message: str = ""
error: str = ""
@property
def can_enter(self) -> bool:
return not self.forced
def parse_version(version) -> tuple[int, ...]:
"""Parse semantic numeric version segments for comparison."""
text = str(version or "").strip()
if text.lower().startswith("v"):
text = text[1:].strip()
if not text:
raise UpdateCheckError("版本号不能为空")
segments = []
for part in text.split("."):
match = re.match(r"^(\d+)", part.strip())
if match is None:
raise UpdateCheckError(f"版本号格式不正确:{version}")
segments.append(int(match.group(1)))
return tuple(segments)
def compare_versions(left, right) -> int:
"""Return -1/0/1 for numeric semantic version comparison."""
left_segments = parse_version(left)
right_segments = parse_version(right)
length = max(len(left_segments), len(right_segments))
left_padded = left_segments + (0,) * (length - len(left_segments))
right_padded = right_segments + (0,) * (length - len(right_segments))
if left_padded < right_padded:
return -1
if left_padded > right_padded:
return 1
return 0
def _as_bool(value) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
return False
def parse_update_info(payload) -> UpdateInfo:
if not isinstance(payload, dict):
raise UpdateCheckError("版本接口返回内容不是 JSON 对象")
release = payload.get("release")
if not isinstance(release, dict):
release = {}
latest_version = str(payload.get("latest_version") or release.get("version") or "").strip()
min_supported_version = str(
payload.get("min_supported_version") or release.get("min_supported_version") or ""
).strip()
if not latest_version and not min_supported_version:
raise UpdateCheckError("版本接口缺少 latest_version 或 min_supported_version")
return UpdateInfo(
latest_version=latest_version,
min_supported_version=min_supported_version,
force_update=_as_bool(payload.get("force_update", release.get("force_update"))),
download_url=str(payload.get("download_url") or release.get("download_url") or "").strip(),
sha256=str(payload.get("sha256") or release.get("sha256") or "").strip(),
message=str(
payload.get("message")
or payload.get("release_notes")
or release.get("message")
or release.get("release_notes")
or ""
).strip(),
)
def is_forced_update(info: UpdateInfo, current_version: str) -> bool:
if info.min_supported_version and compare_versions(current_version, info.min_supported_version) < 0:
return True
if (
info.force_update
and info.latest_version
and compare_versions(current_version, info.latest_version) < 0
):
return True
return False
def _decode_payload(raw_payload):
if isinstance(raw_payload, (bytes, bytearray)):
raw_payload = raw_payload.decode("utf-8")
if isinstance(raw_payload, str):
try:
return json.loads(raw_payload)
except ValueError as exc:
raise UpdateCheckError("版本接口返回内容不是合法 JSON") from exc
return raw_payload
def fetch_update_payload(url: str, timeout: float = DEFAULT_TIMEOUT_SECONDS):
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"User-Agent": f"{APP_CODE_NAME}/{APP_VERSION}",
},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return _decode_payload(response.read())
def check_for_update(
*,
current_version: str = APP_VERSION,
url: str = APP_UPDATE_CHECK_URL,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
fetcher=None,
) -> UpdateCheckResult:
if not str(url or "").strip():
return UpdateCheckResult(current_version=current_version, checked=False)
try:
payload = (fetcher or fetch_update_payload)(url, timeout)
info = parse_update_info(_decode_payload(payload))
forced = is_forced_update(info, current_version)
return UpdateCheckResult(
current_version=current_version,
checked=True,
forced=forced,
latest_version=info.latest_version,
min_supported_version=info.min_supported_version,
download_url=info.download_url,
sha256=info.sha256,
message=info.message,
)
except Exception as exc:
return UpdateCheckResult(
current_version=current_version,
checked=True,
forced=False,
error=f"启动版本检查失败,已允许继续使用:{exc}",
)
+1
View File
@@ -3,6 +3,7 @@
APP_NAME = "蝦皮圈優化助手"
APP_CODE_NAME = "cmshopee"
APP_VERSION = "0.1.0"
APP_UPDATE_CHECK_URL = "https://cm.833729.com/api/v1/client/releases/latest?platform=windows"
def display_name() -> str:
+1 -1
View File
@@ -149,7 +149,7 @@
| T-539 | ⑤ 隐藏数据路径设置(设置简化,非防逆向) | T-538, T-517 | 背景:T-538 后用户数据固定收进 exe 同级 `data/`,⑤「路径与端口」里的 DB 路径(`dbPathEdit`)、图片目录(`imageDirEdit`)、账号数据根目录(`userDataRootEdit`)已不该由用户改动——改了会指向 `data/` 之外导致数据分裂/丢失。**定性说明**:本任务是**设置简化**,与 T-517/T-529 隐藏 `test_item_id`/`dry_run` 同一思路;**不是防逆向手段**——`cmshopee.db` 在磁盘上任何 SQLite 浏览器可直接打开,隐藏 UI 字段对逆向无意义,防逆向由另行任务(发布包加固/价值上移 cmhub)承担。方案:① ⑤普通设置页**隐藏** `db_path`/`image_dir`/账号数据根三个输入框(不再占表单位置),`config.json` 对应字段保留为内部兼容——手工编辑配置仍生效(内部回滚路径),加载/保存不丢字段;② **保留可见**:Chrome 路径(每台机器安装位置不同,真实配置需求)与默认端口/端口区间/CDP 就绪超时(端口冲突需可调);③ 首次运行/迁移后各路径默认解析到 `data/` 下(沿用 T-538 的 `data_dir` 规则),无需用户感知。同步 `docs/routes.md` ⑤ 说明与 GUI 设置单测(隐藏字段不再可见、config 字段仍读写、手工配置值仍被尊重)。边界:只改⑤设置页展示层与文档;不改 `config.json` schema、路径解析逻辑(T-538 范围)、DB、业务流程、CDP/Shopee | DONE |
| T-540 | 打包版本号与 release 产物命名统一 | T-537, T-538, T-524 | 参考 `D:\chengma\cmbot` 的轻量版本控制方式,但本阶段不引入 Launcher/manifest/自动更新。方案:① 新增 `app/version.py`,统一维护 `APP_NAME="蝦皮圈優化助手"`、`APP_CODE_NAME="cmshopee"`、`APP_VERSION`,作为 GUI 标题、打包脚本、release 目录、zip 文件名和 `version.txt` 的唯一版本源;② 主窗口标题改为 `蝦皮圈優化助手 v<APP_VERSION>`,PySide6 缺失提示继续读中文品牌名;③ `scripts/build_exe.ps1` 从 `app/version.py` 读取版本,并固定通过 Windows Python Launcher 调用 `py -3.10`,返回版本必须是 `3.10.x`;④ PyInstaller 仍输出 `dist\cmshopee\` 作为中间产物,再组装 `release\cmshopee-<APP_VERSION>\` 和 `release\cmshopee-<APP_VERSION>-portable.zip`,发布目录内写 `version.txt`(无 BOM)和中文 `README.txt`;⑤ 发布目录/zip 仍不得包含 `data/`、DB、配置、图片、日志、Chrome 登录态或运营 Excel;当前 PyInstaller 6.11.1 onedir 结构必须包含 `cmshopee.exe` + `_internal\`,脚本会校验 `_internal\` 存在;⑥ 测试覆盖版本文件解析、窗口标题版本、构建脚本 release 命名/排除数据/固定 `py -3.10` 打包。边界:只做手动发版版本号一致性和 release 组装;不做在线更新、启动器、manifest、sha256 下载校验、强制更新或自动替换程序目录 | DONE |
| T-543 | 状态栏语义色与统一提示入口 | T-511, T-523, T-536 | 需求:当前左下角状态栏提示几乎都通过 `statusBar().showMessage(...)` 直接写入,颜色一致,用户很难快速区分“已保存/已完成”“正在处理”“没有可生成任务”“未登录/数据目录不可写”等不同严重程度。方案:① 在 `MainWindow` 增加统一入口 `show_status(message, level="muted")`,使用 `docs/ui-color-design.md` 已定语义色板映射 `muted/info/success/warning/danger`,内部先设置状态栏文字颜色再 `showMessage()`,普通/就绪提示必须重置为 muted 或默认色,避免上一条红/绿状态残留;② 逐步替换主窗口和 ①②③④⑤ Tab 传入的 `status_callback=self.statusBar().showMessage` 以及直接调用,新增代码必须显式传 level,不长期依赖中文关键词猜测;③ 颜色口径:普通/导航/就绪用 muted 或默认,进行中用 info,保存/完成/回写成功用 success,当前筛选无任务、请先采集/配置等用户可处理问题用 warning,失败、阻断、未登录、数据目录不可写、点数不足等中止当前动作的问题用 danger;④ 状态栏只改文字色,不做大面积背景,不替代弹窗、空状态、按钮禁用、运行日志或任务状态列;⑤ 复用 `app/gui/widgets.py` 语义色常量,不新增第二套颜色;⑥ GUI 单测覆盖 `show_status()` 各等级颜色、普通提示会清掉前一条错误颜色,以及至少一个保存成功/无任务/失败路径调用正确等级。边界:只改 GUI 提示层和测试;不改业务流程、DB schema、AI HTTP、Excel、Shopee/CDP、运行日志持久化 | DONE |
| T-544 | 启动时检查强制升级(第一版:提示下载,不自动覆盖) | T-540, T-538, T-524 | 需求:程序启动后先请求服务器版本接口;如果线上版本高于当前程序版本且服务端要求强制升级,则必须弹窗阻断进入主界面,用户只能下载新版或退出。第一版方案:① 新增 `app/update_check.py`,读取当前版本 `APP_VERSION`,请求固定版本接口(后续可放配置/构建常量),超时短且错误中文化;② 服务端返回建议结构:`latest_version`、`min_supported_version`、`force_update`、`download_url`、`sha256`、`message`。比较规则:当前版本 `< min_supported_version` 必须升级;或 `force_update=true` 且当前版本 `< latest_version` 必须升级;版本比较使用语义化数字段比较,不用字符串字典序;③ 启动流程在 `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前执行版本检查;命中强制升级时弹中文阻断框,显示当前版本/线上版本/升级说明,按钮只保留「下载新版」和「退出程序」,点击下载用系统浏览器打开 `download_url` 后退出或保持阻断,不允许继续进入主界面;④ 网络失败、接口超时、JSON 非法、版本字段缺失时第一版**允许进入软件**,只在本地诊断日志记录/可选状态提示,不因服务器或用户断网导致全员不可用;只有服务端明确返回强制升级才阻断;⑤ 第一版**不自动下载并覆盖** `cmshopee.exe` 或 `_internal/`,不删除/覆盖 `data/`,不做 Launcher、后台替换、增量补丁、签名校验或回滚;用户仍按打包文档手动下载 zip、关闭程序、覆盖程序文件和 `_internal/`、保留 `data/`;⑥ 单测覆盖版本比较、强制升级判定、网络失败允许进入、非法响应允许进入、启动流程强制阻断弹窗和打开下载链接。边界:只做启动检查与强制提示,不改 DB schema、业务流程、AI、Excel、Shopee/CDP,不自动替换任何本地文件 | TODO |
| T-544 | 启动时检查强制升级(第一版:提示下载,不自动覆盖) | T-540, T-538, T-524 | 需求:程序启动后先请求服务器版本接口;如果线上版本高于当前程序版本且服务端要求强制升级,则必须弹窗阻断进入主界面,用户只能下载新版或退出。第一版方案:① 新增 `app/update_check.py`,读取当前版本 `APP_VERSION`,请求固定版本接口(后续可放配置/构建常量),超时短且错误中文化;② 服务端返回建议结构:`latest_version`、`min_supported_version`、`force_update`、`download_url`、`sha256`、`message`。比较规则:当前版本 `< min_supported_version` 必须升级;或 `force_update=true` 且当前版本 `< latest_version` 必须升级;版本比较使用语义化数字段比较,不用字符串字典序;③ 启动流程在 `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前执行版本检查;命中强制升级时弹中文阻断框,显示当前版本/线上版本/升级说明,按钮只保留「下载新版」和「退出程序」,点击下载用系统浏览器打开 `download_url` 后退出或保持阻断,不允许继续进入主界面;④ 网络失败、接口超时、JSON 非法、版本字段缺失时第一版**允许进入软件**,只在本地诊断日志记录/可选状态提示,不因服务器或用户断网导致全员不可用;只有服务端明确返回强制升级才阻断;⑤ 第一版**不自动下载并覆盖** `cmshopee.exe` 或 `_internal/`,不删除/覆盖 `data/`,不做 Launcher、后台替换、增量补丁、签名校验或回滚;用户仍按打包文档手动下载 zip、关闭程序、覆盖程序文件和 `_internal/`、保留 `data/`;⑥ 单测覆盖版本比较、强制升级判定、网络失败允许进入、非法响应允许进入、启动流程强制阻断弹窗和打开下载链接。边界:只做启动检查与强制提示,不改 DB schema、业务流程、AI、Excel、Shopee/CDP,不自动替换任何本地文件 | DONE |
## 里程碑
File diff suppressed because one or more lines are too long
+20 -2
View File
@@ -63,11 +63,13 @@ cmshopee\
APP_NAME = "蝦皮圈優化助手"
APP_CODE_NAME = "cmshopee"
APP_VERSION = "0.1.0"
APP_UPDATE_CHECK_URL = "https://cm.833729.com/api/v1/client/releases/latest?platform=windows"
```
规则:
- 修改版本号只改 `app/version.py` 的 `APP_VERSION`。
- 线上版本检查接口只改 `app/version.py` 的 `APP_UPDATE_CHECK_URL`;当前正式接口为 `https://cm.833729.com/api/v1/client/releases/latest?platform=windows`。如需本地开发临时跳过启动检查,可把该值置空。
- GUI 窗口标题读取同一版本源,显示为 `蝦皮圈優化助手 v0.1.0`。
- 打包脚本读取同一版本源生成发布目录、压缩包和 `version.txt`。
- 禁止在 GUI、构建脚本或文档示例之外重复硬编码不同版本号。
@@ -113,8 +115,8 @@ T-544 第一版目标是**启动时检查是否必须升级**,但仍不做自
客户端启动流程:
1. `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前,请求服务器版本接口。
2. 当前版本从 `app/version.py` 的 `APP_VERSION` 读取。
1. `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前,请求 `app/version.py` 中 `APP_UPDATE_CHECK_URL` 指向的服务器版本接口。
2. 当前版本从 `app/version.py` 的 `APP_VERSION` 读取;`APP_UPDATE_CHECK_URL` 为空时跳过检查并允许进入软件,用于本地开发或尚未配置线上接口的构建。
3. 如果服务器明确返回“当前版本必须升级”,弹中文阻断框,不进入主界面。
4. 弹窗只提供「下载新版」和「退出程序」。点击「下载新版」用系统浏览器打开下载地址;用户下载后按第六节手动覆盖程序文件。
5. 网络失败、接口超时、JSON 非法或字段缺失时,第一版允许进入软件;只记录本地诊断日志或给低打扰提示,避免服务器故障导致所有用户不可用。
@@ -132,6 +134,22 @@ T-544 第一版目标是**启动时检查是否必须升级**,但仍不做自
}
```
客户端同时兼容当前线上接口返回结构:
```json
{
"platform": "windows",
"release": {
"version": "0.1.0",
"download_url": "https://example.com/cmshopee-0.1.0-portable.zip",
"sha256": "",
"release_notes": "发布说明"
}
}
```
映射规则:`release.version` 作为 `latest_version`,`release.download_url` 作为下载地址,`release.sha256` 作为校验值,`release.release_notes` 作为弹窗说明。若该结构未返回 `min_supported_version` 或 `force_update=true`,客户端只记录可用新版本信息,不会阻断启动。
客户端判定规则:
- `APP_VERSION < min_supported_version`:必须升级。
+17
View File
@@ -1413,3 +1413,20 @@
- 第一版方案:只做“启动检查 + 强制弹窗 + 下载新版/退出程序”。命中强制升级时显示当前版本、线上版本、升级说明,点击「下载新版」用系统浏览器打开 `download_url`;不允许继续进入主界面。
- 策略:只有服务端明确返回强制升级才阻断;网络失败、接口超时、JSON 非法或字段缺失时允许进入软件,避免服务器故障导致用户全部不可用。
- 边界:第一版不自动下载并覆盖 `cmshopee.exe` 或 `_internal/`,不删除/覆盖 `data/`,不做 Launcher、后台替换、增量补丁、签名校验或回滚;本轮只更新文档,未改代码。
## 【2026-07-07】T-544 完成 · 启动时检查强制升级
- 状态:DONE
- 代码:新增 `app/update_check.py`,实现版本号数字段比较、服务端版本响应解析、强制升级判定、短超时 HTTP 请求和失败放行结果;`app/version.py` 新增 `APP_UPDATE_CHECK_URL` 构建常量,默认空值跳过检查,正式发布前填线上版本接口。
- 启动流程:`app/gui/__init__.py` 在 `appconfig.prepare_data_dir()` 成功后、创建 `MainWindow` 前执行版本检查。服务端明确要求强制升级时弹中文阻断框,显示当前版本、线上版本、最低支持版本和升级说明,按钮只保留「下载新版」和「退出程序」;点击下载用系统浏览器打开 `download_url` 并退出启动流程。
- 失败策略:网络失败、超时、非法 JSON、缺少版本字段或诊断日志写入失败都不会阻断启动;可记录到本地 `data/logs/cmshopee.log`,避免服务器故障导致所有用户不可用。
- 文档:`docs/06-tasks.md` 标记 T-544 为 DONE;`docs/packaging.md` 补充 `APP_UPDATE_CHECK_URL` 配置口径;`docs/current-state.md` 更新当前快照、测试覆盖和看板状态。
- 验证:`python -m unittest discover -s tests -p "test_update_check.py"` 通过(7 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(101 tests);`python -m ruff check app tests main.py` 通过;`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(232 tests)。
- 边界:未改 DB schema、AI、Excel、Shopee/CDP 或业务更新流程;不自动下载、覆盖或删除 `cmshopee.exe`、`_internal/`、`data/`。
## 【2026-07-07】T-544 补充 · 配置线上版本接口并实测
- 代码:`APP_UPDATE_CHECK_URL` 已更新为 `https://cm.833729.com/api/v1/client/releases/latest?platform=windows`;`app/update_check.py` 兼容当前线上返回的 `release.version/download_url/sha256/release_notes` 嵌套结构。
- 实测:接口 HTTP 200,返回 `platform=windows`、`release.version=0.1.0`、`download_url=https://43.128.3.240:33717/down/tip8cOrTbZJT.zip`、发布说明等字段。
- 结果:当前程序 `APP_VERSION=0.1.0`,线上版本也是 `0.1.0`,且接口未返回 `min_supported_version` 或 `force_update=true`,所以客户端判定 `forced=False`、`can_enter=True`。
- 验证:`py -3.10 -m unittest discover -s tests -p "test_update_check.py"` 通过(8 tests);`py -3.10 -m compileall app main.py` 通过。
+92 -1
View File
@@ -12,7 +12,7 @@ sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin
from app import gui
from app import accounts, ai, appconfig, db, prompts
from app import accounts, ai, appconfig, db, prompts, update_check
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
@@ -203,6 +203,97 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_startup_update_gate_forced_blocks_and_opens_download(self):
boxes = []
class FakeButton:
def __init__(self, label):
self.label = label
self.enabled = True
def setEnabled(self, enabled):
self.enabled = enabled
class FakeMessageBox:
Warning = object()
AcceptRole = object()
RejectRole = object()
def __init__(self, parent=None):
self.parent = parent
self.icon = None
self.title = ""
self.text = ""
self.informative_text = ""
self.buttons = {}
self.default_button = None
boxes.append(self)
def setIcon(self, icon):
self.icon = icon
def setWindowTitle(self, title):
self.title = title
def setText(self, text):
self.text = text
def setInformativeText(self, text):
self.informative_text = text
def addButton(self, label, role):
button = FakeButton(label)
self.buttons[label] = button
return button
def setDefaultButton(self, button):
self.default_button = button
def exec(self):
return 0
def clickedButton(self):
return self.buttons["下载新版"]
result = update_check.UpdateCheckResult(
current_version="1.0.0",
checked=True,
forced=True,
latest_version="1.2.0",
min_supported_version="1.1.0",
download_url="https://example.test/cmshopee.zip",
message="必须升级",
)
opened = []
with mock.patch("app.gui.QMessageBox", FakeMessageBox):
allowed = gui._run_startup_update_gate(
checker=lambda: result,
opener=opened.append,
)
self.assertFalse(allowed)
self.assertEqual(["https://example.test/cmshopee.zip"], opened)
self.assertEqual("必须升级", boxes[0].title)
self.assertIn("当前版本:1.0.0", boxes[0].informative_text)
self.assertIn("线上版本:1.2.0", boxes[0].informative_text)
self.assertIn("保留 data/ 目录", boxes[0].informative_text)
self.assertEqual(boxes[0].buttons["下载新版"], boxes[0].default_button)
def test_startup_update_gate_check_failure_allows_entry_and_logs(self):
result = update_check.UpdateCheckResult(
current_version="1.0.0",
checked=True,
forced=False,
error="启动版本检查失败,已允许继续使用:网络超时",
)
with mock.patch("app.gui.diagnostics.write_diagnostic_log") as write_log:
allowed = gui._run_startup_update_gate(checker=lambda: result)
self.assertTrue(allowed)
write_log.assert_called_once()
def test_status_callbacks_classify_success_warning_and_failure(self):
with self.make_temp_dir() as temp_dir:
statuses = []
+122
View File
@@ -0,0 +1,122 @@
import socket
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
from _helpers import REPO_ROOT
from app import update_check
assert REPO_ROOT
class UpdateCheckTests(unittest.TestCase):
def test_compare_versions_uses_numeric_segments(self):
self.assertGreater(update_check.compare_versions("0.10.0", "0.2.0"), 0)
self.assertEqual(0, update_check.compare_versions("1.2", "1.2.0"))
self.assertLess(update_check.compare_versions("v1.2.3", "1.2.4"), 0)
def test_forced_update_by_min_supported_version(self):
info = update_check.UpdateInfo(
latest_version="1.2.0",
min_supported_version="1.1.0",
force_update=False,
)
self.assertTrue(update_check.is_forced_update(info, "1.0.9"))
self.assertFalse(update_check.is_forced_update(info, "1.1.0"))
def test_forced_update_by_force_flag_and_latest_version(self):
forced = update_check.UpdateInfo(latest_version="1.2.0", force_update=True)
optional = update_check.UpdateInfo(latest_version="1.2.0", force_update=False)
self.assertTrue(update_check.is_forced_update(forced, "1.1.9"))
self.assertFalse(update_check.is_forced_update(optional, "1.1.9"))
self.assertFalse(update_check.is_forced_update(forced, "1.2.0"))
def test_check_for_update_forced_response(self):
def fetcher(_url, _timeout):
return {
"latest_version": "1.2.0",
"min_supported_version": "1.1.0",
"force_update": True,
"download_url": "https://example.test/cmshopee.zip",
"sha256": "abc",
"message": "请升级后继续使用",
}
result = update_check.check_for_update(
current_version="1.0.0",
url="https://example.test/version.json",
fetcher=fetcher,
)
self.assertTrue(result.checked)
self.assertTrue(result.forced)
self.assertFalse(result.can_enter)
self.assertEqual("1.2.0", result.latest_version)
self.assertEqual("https://example.test/cmshopee.zip", result.download_url)
def test_check_for_update_accepts_release_wrapper_response(self):
def fetcher(_url, _timeout):
return {
"platform": "windows",
"release": {
"version": "0.1.1",
"download_url": "https://example.test/cmshopee-0.1.1.zip",
"sha256": "abc",
"release_notes": "新版说明",
},
}
result = update_check.check_for_update(
current_version="0.1.0",
url="https://example.test/releases/latest?platform=windows",
fetcher=fetcher,
)
self.assertTrue(result.checked)
self.assertFalse(result.forced)
self.assertEqual("0.1.1", result.latest_version)
self.assertEqual("https://example.test/cmshopee-0.1.1.zip", result.download_url)
self.assertEqual("abc", result.sha256)
self.assertEqual("新版说明", result.message)
def test_network_failure_allows_entry(self):
def fetcher(_url, _timeout):
raise socket.timeout("timeout")
result = update_check.check_for_update(
current_version="1.0.0",
url="https://example.test/version.json",
fetcher=fetcher,
)
self.assertTrue(result.checked)
self.assertFalse(result.forced)
self.assertTrue(result.can_enter)
self.assertIn("启动版本检查失败", result.error)
def test_invalid_response_allows_entry(self):
result = update_check.check_for_update(
current_version="1.0.0",
url="https://example.test/version.json",
fetcher=lambda _url, _timeout: {"message": "missing versions"},
)
self.assertTrue(result.checked)
self.assertFalse(result.forced)
self.assertTrue(result.can_enter)
self.assertIn("缺少 latest_version", result.error)
def test_empty_update_url_skips_check(self):
result = update_check.check_for_update(current_version="1.0.0", url="")
self.assertFalse(result.checked)
self.assertFalse(result.forced)
self.assertEqual("", result.error)
if __name__ == "__main__":
unittest.main()