feat: LAN update check on startup with notify banner (stage 2)
Read manifest.json from the configured update_source and show a dismissable banner when a newer version is available. Notify-only — no install yet. - services/update_service.py: version compare + check_for_update (pure, tested) - config: add update_source key (empty = no check) - main_window: top banner, background-thread check; open folder via os.startfile (QDesktopServices.openUrl mishandles file:// folder URLs — ShellExecute err 2) - tests: +15 covering version compare and check_for_update branches - docs 02/05/10 + tasks 17.16 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -330,7 +330,8 @@ CMBot/
|
|||||||
"last_garment_dir": "",
|
"last_garment_dir": "",
|
||||||
"last_print_dir": "",
|
"last_print_dir": "",
|
||||||
"last_template": "正方形模板",
|
"last_template": "正方形模板",
|
||||||
"last_batch_mode": "full_combo"
|
"last_batch_mode": "full_combo",
|
||||||
|
"update_source": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -339,6 +340,7 @@ CMBot/
|
|||||||
- `last_garment_dir` / `last_print_dir`:上次打开的衣服 / 印花文件夹路径,用于让文件夹对话框定位到上次位置。
|
- `last_garment_dir` / `last_print_dir`:上次打开的衣服 / 印花文件夹路径,用于让文件夹对话框定位到上次位置。
|
||||||
- `last_template`:上次选中的模板名称,用于启动恢复。
|
- `last_template`:上次选中的模板名称,用于启动恢复。
|
||||||
- `last_batch_mode`:上次选择的批量模式(取 `BatchMode` 枚举值,如 `full_combo` / `many_garments` / `many_prints` / `one_to_one`)。
|
- `last_batch_mode`:上次选择的批量模式(取 `BatchMode` 枚举值,如 `full_combo` / `many_garments` / `many_prints` / `one_to_one`)。
|
||||||
|
- `update_source`:局域网更新源目录(含 `manifest.json`)。为空时不做更新检查。详见 `docs/10-lan-update.md`。
|
||||||
- 偏好的读取、分发与「改一次存一次」由主窗口集中处理,UI 控件不直接读写配置文件。
|
- 偏好的读取、分发与「改一次存一次」由主窗口集中处理,UI 控件不直接读写配置文件。
|
||||||
|
|
||||||
## 10. 验收标准
|
## 10. 验收标准
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ BatchResult
|
|||||||
- 读取和保存应用配置。
|
- 读取和保存应用配置。
|
||||||
- 管理默认配置。
|
- 管理默认配置。
|
||||||
- 在配置损坏或缺失时提供安全默认值。
|
- 在配置损坏或缺失时提供安全默认值。
|
||||||
- 持久化用户偏好:输出设置、上次的衣服/印花文件夹(`last_garment_dir`/`last_print_dir`)、上次选择的模板(`last_template`)、上次选择的批量模式(`last_batch_mode`)等。
|
- 持久化用户偏好:输出设置、上次的衣服/印花文件夹(`last_garment_dir`/`last_print_dir`)、上次选择的模板(`last_template`)、上次选择的批量模式(`last_batch_mode`)、局域网更新源(`update_source`)等。
|
||||||
|
|
||||||
由主窗口集中使用:启动时加载一次并把初值分发给各面板,面板选择变化时「改一次存一次」回写。各 UI 控件不直接读写配置文件,避免分散解析。
|
由主窗口集中使用:启动时加载一次并把初值分发给各面板,面板选择变化时「改一次存一次」回写。各 UI 控件不直接读写配置文件,避免分散解析。
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
## 7. 更新源与版本清单
|
## 7. 更新源与版本清单
|
||||||
|
|
||||||
更新源为内网共享目录(UNC 路径)或内网文件服务,例如:
|
更新源路径由 `app_config.json` 的 `update_source` 配置(见 `docs/02-prd.md`);为空时不做更新检查。更新源为内网共享目录(UNC 路径)或内网文件服务,例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
\\nas\cmbot\releases\
|
\\nas\cmbot\releases\
|
||||||
@@ -225,12 +225,12 @@
|
|||||||
|
|
||||||
## 16. 实现阶段建议
|
## 16. 实现阶段建议
|
||||||
|
|
||||||
建议分阶段落地,每阶段可独立验证:
|
分阶段落地,每阶段可独立验证:
|
||||||
|
|
||||||
1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提,先行完成并通过现有测试。
|
1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。
|
||||||
2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。验证版本检查与降级逻辑。
|
2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测)+ 主窗口顶部通知横幅,检查在后台线程进行(更新源不可达不阻塞启动),更新源路径由 `update_source` 配置。
|
||||||
3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。
|
3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。⛔ 未做。
|
||||||
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。
|
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。⛔ 未做。
|
||||||
|
|
||||||
## 17. 暂不做
|
## 17. 暂不做
|
||||||
|
|
||||||
|
|||||||
+138
-1
@@ -1,10 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt, QUrl, Signal
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
@@ -16,6 +21,7 @@ from PySide6.QtWidgets import (
|
|||||||
from version import APP_NAME, APP_VERSION
|
from version import APP_NAME, APP_VERSION
|
||||||
from core.models import BatchMode, TransformState
|
from core.models import BatchMode, TransformState
|
||||||
from services.config_service import load_config, save_config
|
from services.config_service import load_config, save_config
|
||||||
|
from services.update_service import check_for_update
|
||||||
from app.widgets.export_panel import ExportPanel
|
from app.widgets.export_panel import ExportPanel
|
||||||
from app.widgets.image_canvas import ImageCanvas
|
from app.widgets.image_canvas import ImageCanvas
|
||||||
from app.widgets.image_list_panel import ImageListPanel
|
from app.widgets.image_list_panel import ImageListPanel
|
||||||
@@ -33,6 +39,10 @@ _TABBAR_HEIGHT = 34
|
|||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
|
# Emitted from the background update-check thread; delivered to the UI
|
||||||
|
# thread via Qt's queued connection so the banner is built on the main thread.
|
||||||
|
_update_found = Signal(object) # UpdateInfo
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("{} v{}".format(APP_NAME, APP_VERSION))
|
self.setWindowTitle("{} v{}".format(APP_NAME, APP_VERSION))
|
||||||
@@ -59,6 +69,9 @@ class MainWindow(QMainWindow):
|
|||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Update notification banner (hidden until a newer version is found).
|
||||||
|
layout.addWidget(self._create_update_banner())
|
||||||
|
|
||||||
# No in-app title bar: the OS window title (setWindowTitle) already
|
# No in-app title bar: the OS window title (setWindowTitle) already
|
||||||
# shows the app name and version, so an in-content header would just
|
# shows the app name and version, so an in-content header would just
|
||||||
# duplicate it. Start straight from the workflow tab bar.
|
# duplicate it. Start straight from the workflow tab bar.
|
||||||
@@ -70,6 +83,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._apply_stylesheet()
|
self._apply_stylesheet()
|
||||||
self._connect_signals()
|
self._connect_signals()
|
||||||
self._restore_preferences()
|
self._restore_preferences()
|
||||||
|
self._start_update_check()
|
||||||
|
|
||||||
def _create_tab_bar(self):
|
def _create_tab_bar(self):
|
||||||
"""Workflow step selector (QTabBar only — no swappable pane)."""
|
"""Workflow step selector (QTabBar only — no swappable pane)."""
|
||||||
@@ -96,6 +110,102 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
return container
|
return container
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Update notification (stage 2: notify-only, see docs/10-lan-update.md)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _create_update_banner(self):
|
||||||
|
"""A thin info bar shown when a newer version is found. Hidden by default."""
|
||||||
|
self._update_info = None
|
||||||
|
|
||||||
|
bar = QWidget()
|
||||||
|
bar.setObjectName("updateBanner")
|
||||||
|
bar.setVisible(False)
|
||||||
|
row = QHBoxLayout(bar)
|
||||||
|
row.setContentsMargins(12, 5, 8, 5)
|
||||||
|
row.setSpacing(8)
|
||||||
|
|
||||||
|
self._update_label = QLabel()
|
||||||
|
self._update_label.setObjectName("updateBannerText")
|
||||||
|
|
||||||
|
open_btn = QPushButton("打开更新目录")
|
||||||
|
open_btn.setObjectName("updateBannerOpen")
|
||||||
|
open_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
open_btn.clicked.connect(self._open_update_source)
|
||||||
|
|
||||||
|
close_btn = QPushButton("✕")
|
||||||
|
close_btn.setObjectName("updateBannerClose")
|
||||||
|
close_btn.setFixedWidth(24)
|
||||||
|
close_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
close_btn.setToolTip("关闭")
|
||||||
|
close_btn.clicked.connect(lambda: self._update_banner.setVisible(False))
|
||||||
|
|
||||||
|
row.addWidget(self._update_label)
|
||||||
|
row.addStretch()
|
||||||
|
row.addWidget(open_btn)
|
||||||
|
row.addWidget(close_btn)
|
||||||
|
|
||||||
|
self._update_banner = bar
|
||||||
|
return bar
|
||||||
|
|
||||||
|
def _start_update_check(self):
|
||||||
|
"""Check the configured LAN source for a newer version, off the UI thread."""
|
||||||
|
source = self._config.get("update_source", "")
|
||||||
|
if not source:
|
||||||
|
return
|
||||||
|
self._update_found.connect(self._on_update_found)
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
info = check_for_update(source, APP_VERSION)
|
||||||
|
except Exception: # never let the thread crash startup
|
||||||
|
logger.exception("Update check failed")
|
||||||
|
return
|
||||||
|
if info:
|
||||||
|
self._update_found.emit(info)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, name="update-check", daemon=True).start()
|
||||||
|
|
||||||
|
def _on_update_found(self, info):
|
||||||
|
"""Show the update banner (runs on the UI thread via queued signal)."""
|
||||||
|
self._update_info = info
|
||||||
|
text = "发现新版本 v{},当前 v{}。".format(info.version, APP_VERSION)
|
||||||
|
if info.notes:
|
||||||
|
text += " " + info.notes
|
||||||
|
self._update_label.setText(text)
|
||||||
|
self._update_banner.setVisible(True)
|
||||||
|
|
||||||
|
def _open_update_source(self):
|
||||||
|
"""Open the update folder in the file explorer.
|
||||||
|
|
||||||
|
Prefer the version folder from the manifest; fall back to the configured
|
||||||
|
source root (which we know exists — the manifest was just read from it).
|
||||||
|
os.startfile is the reliable way to open a directory on Windows;
|
||||||
|
QDesktopServices.openUrl mishandles file:// URLs to folders (ShellExecute
|
||||||
|
error 2), so it is only a secondary fallback.
|
||||||
|
"""
|
||||||
|
if not self._update_info:
|
||||||
|
return
|
||||||
|
candidates = [
|
||||||
|
self._update_info.source,
|
||||||
|
self._config.get("update_source", ""),
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if not path or not Path(path).exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.startfile(path) # noqa: S606 — native Explorer open
|
||||||
|
return
|
||||||
|
except (OSError, AttributeError) as exc:
|
||||||
|
logger.warning("startfile failed for %s: %s", path, exc)
|
||||||
|
if QDesktopServices.openUrl(QUrl.fromLocalFile(path)):
|
||||||
|
return
|
||||||
|
shown = self._update_info.source or self._config.get("update_source", "")
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "更新目录",
|
||||||
|
"无法自动打开更新目录,请手动前往:\n{}".format(shown),
|
||||||
|
)
|
||||||
|
|
||||||
def _create_work_area(self):
|
def _create_work_area(self):
|
||||||
"""Horizontal splitter: left material panel | canvas | right params."""
|
"""Horizontal splitter: left material panel | canvas | right params."""
|
||||||
splitter = QSplitter(Qt.Horizontal)
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
@@ -469,6 +579,33 @@ class MainWindow(QMainWindow):
|
|||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Update notification banner */
|
||||||
|
#updateBanner {
|
||||||
|
background-color: #eef6ff;
|
||||||
|
border-bottom: 1px solid #cfe3fa;
|
||||||
|
}
|
||||||
|
#updateBannerText {
|
||||||
|
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1b4f86;
|
||||||
|
}
|
||||||
|
#updateBannerOpen {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
border: 1px solid #0078d4;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #0078d4;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
#updateBannerOpen:hover { background: #d8e9fb; }
|
||||||
|
#updateBannerClose {
|
||||||
|
font-size: 12px;
|
||||||
|
border: none;
|
||||||
|
color: #6a8bab;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
#updateBannerClose:hover { color: #1b4f86; }
|
||||||
|
|
||||||
/* Flow tab bar */
|
/* Flow tab bar */
|
||||||
#tabBarContainer {
|
#tabBarContainer {
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ DEFAULT_CONFIG = {
|
|||||||
"last_print_dir": "",
|
"last_print_dir": "",
|
||||||
"last_template": "", # name of the last-selected template
|
"last_template": "", # name of the last-selected template
|
||||||
"last_batch_mode": "full_combo", # BatchMode value of the last-used mode
|
"last_batch_mode": "full_combo", # BatchMode value of the last-used mode
|
||||||
|
"update_source": "", # LAN folder holding manifest.json (empty = no update check)
|
||||||
}
|
}
|
||||||
|
|
||||||
_CONFIG_FILENAME = "app_config.json"
|
_CONFIG_FILENAME = "app_config.json"
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""LAN update check (stage 2: notify-only).
|
||||||
|
|
||||||
|
Reads a manifest.json from a configured LAN folder and reports whether a newer
|
||||||
|
version is advertised. This module performs no installation — it only decides
|
||||||
|
whether to notify the user. See docs/10-lan-update.md.
|
||||||
|
|
||||||
|
All functions degrade gracefully: a missing/unreachable source or a malformed
|
||||||
|
manifest yields "no update" rather than an error, so an update check can never
|
||||||
|
block or break startup.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MANIFEST_NAME = "manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateInfo:
|
||||||
|
"""A newer version advertised by the update source."""
|
||||||
|
version: str
|
||||||
|
source: str # folder the user opens to get the new version
|
||||||
|
notes: str = ""
|
||||||
|
mandatory: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(text) -> tuple:
|
||||||
|
"""Parse 'a.b.c' into a comparable (a, b, c) int tuple.
|
||||||
|
|
||||||
|
Tolerant: missing parts pad with 0, non-numeric suffixes (e.g. '1rc2')
|
||||||
|
keep their leading digits, fully non-numeric parts become 0.
|
||||||
|
"""
|
||||||
|
nums = []
|
||||||
|
for part in str(text).strip().split(".")[:3]:
|
||||||
|
digits = ""
|
||||||
|
for ch in part:
|
||||||
|
if ch.isdigit():
|
||||||
|
digits += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
nums.append(int(digits) if digits else 0)
|
||||||
|
while len(nums) < 3:
|
||||||
|
nums.append(0)
|
||||||
|
return tuple(nums)
|
||||||
|
|
||||||
|
|
||||||
|
def is_newer(remote, local) -> bool:
|
||||||
|
"""True if version string *remote* is strictly newer than *local*."""
|
||||||
|
return parse_version(remote) > parse_version(local)
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_update(update_source, current_version) -> Optional[UpdateInfo]:
|
||||||
|
"""Return UpdateInfo if *update_source* advertises a version newer than
|
||||||
|
*current_version*, else None.
|
||||||
|
|
||||||
|
Returns None (never raises) when:
|
||||||
|
- no source is configured,
|
||||||
|
- the source/manifest is unreachable or unreadable,
|
||||||
|
- the manifest is malformed,
|
||||||
|
- the advertised version is not newer.
|
||||||
|
"""
|
||||||
|
if not update_source:
|
||||||
|
return None
|
||||||
|
|
||||||
|
manifest_path = Path(update_source) / MANIFEST_NAME
|
||||||
|
try:
|
||||||
|
with open(str(manifest_path), encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
logger.info("Update check skipped (%s): %s", type(exc).__name__, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning("Manifest is not a JSON object, ignoring: %s", manifest_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
version = str(data.get("version", "")).strip()
|
||||||
|
if not version or not is_newer(version, current_version):
|
||||||
|
return None
|
||||||
|
|
||||||
|
source = str(data.get("source", "")).strip() or str(update_source)
|
||||||
|
info = UpdateInfo(
|
||||||
|
version=version,
|
||||||
|
source=source,
|
||||||
|
notes=str(data.get("notes", "")),
|
||||||
|
mandatory=bool(data.get("mandatory", False)),
|
||||||
|
)
|
||||||
|
logger.info("Update available: v%s (current v%s)", version, current_version)
|
||||||
|
return info
|
||||||
@@ -846,6 +846,32 @@
|
|||||||
- [x] 一个批次内含不同尺寸的衣服/印花时,未微调项各自按模板正确落位
|
- [x] 一个批次内含不同尺寸的衣服/印花时,未微调项各自按模板正确落位
|
||||||
- [ ] GUI 实测:混合尺寸批量导出,结果图印花位置/大小均正确
|
- [ ] GUI 实测:混合尺寸批量导出,结果图印花位置/大小均正确
|
||||||
|
|
||||||
|
### 17.16 局域网更新 · 阶段②:启动时检测并通知
|
||||||
|
|
||||||
|
前置阅读:
|
||||||
|
|
||||||
|
- `docs/10-lan-update.md`(§7 更新源、§8 流程、§16 阶段②)
|
||||||
|
- `docs/02-prd.md`(app_config 的 `update_source`)
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 阶段①(数据目录分离)已在 commit `76f2c6d` 完成。本任务实现阶段②「只读通知」,不自动安装。
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- [x] 配置项 `update_source`(空 = 不检查)写入 `DEFAULT_CONFIG`
|
||||||
|
- [x] `services/update_service.py`:`parse_version` / `is_newer` / `check_for_update`,读取 `<source>/manifest.json`,任何不可达/损坏/非更新一律返回 None(不抛错、不阻塞)
|
||||||
|
- [x] 主窗口顶部通知横幅(默认隐藏):「发现新版本 vX.Y.Z」+「打开更新目录」+ 关闭
|
||||||
|
- [x] 检查在后台守护线程进行,经 Qt 队列信号回主线程显示横幅(更新源不可达不卡启动)
|
||||||
|
- [x] 「打开更新目录」用 `QDesktopServices` 打开 `manifest.source`
|
||||||
|
- [x] 15 个纯函数单测覆盖版本比较与 `check_for_update` 各分支
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- [x] 单测通过;更新源为空/不可达时静默跳过
|
||||||
|
- [ ] GUI 实测:配置可达更新源 + 高版本 manifest → 启动后显示横幅,点击打开目录
|
||||||
|
- [ ] GUI 实测:更新源不可达 → 正常启动、无横幅、无卡顿
|
||||||
|
|
||||||
## 18. 后续暂缓任务
|
## 18. 后续暂缓任务
|
||||||
|
|
||||||
以下任务第一阶段暂不做,后续需要时再新增设计文档:
|
以下任务第一阶段暂不做,后续需要时再新增设计文档:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for services.update_service — no GUI dependency."""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from services.update_service import (
|
||||||
|
UpdateInfo,
|
||||||
|
check_for_update,
|
||||||
|
is_newer,
|
||||||
|
parse_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionCompare(unittest.TestCase):
|
||||||
|
def test_parse_basic(self):
|
||||||
|
self.assertEqual(parse_version("1.2.3"), (1, 2, 3))
|
||||||
|
|
||||||
|
def test_parse_pads_missing_parts(self):
|
||||||
|
self.assertEqual(parse_version("1"), (1, 0, 0))
|
||||||
|
self.assertEqual(parse_version("1.5"), (1, 5, 0))
|
||||||
|
|
||||||
|
def test_parse_tolerates_suffix(self):
|
||||||
|
self.assertEqual(parse_version("1.2.3rc1"), (1, 2, 3))
|
||||||
|
self.assertEqual(parse_version("v"), (0, 0, 0))
|
||||||
|
|
||||||
|
def test_is_newer(self):
|
||||||
|
self.assertTrue(is_newer("1.1.0", "1.0.0"))
|
||||||
|
self.assertTrue(is_newer("1.0.1", "1.0.0"))
|
||||||
|
self.assertTrue(is_newer("2.0.0", "1.9.9"))
|
||||||
|
|
||||||
|
def test_is_not_newer(self):
|
||||||
|
self.assertFalse(is_newer("1.0.0", "1.0.0"))
|
||||||
|
self.assertFalse(is_newer("1.0.0", "1.1.0"))
|
||||||
|
self.assertFalse(is_newer("0.9.9", "1.0.0"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckForUpdate(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||||
|
|
||||||
|
def _write_manifest(self, data):
|
||||||
|
with open(str(self.tmp / "manifest.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
def test_no_source_returns_none(self):
|
||||||
|
self.assertIsNone(check_for_update("", "1.0.0"))
|
||||||
|
|
||||||
|
def test_missing_manifest_returns_none(self):
|
||||||
|
# tmp exists but has no manifest.json
|
||||||
|
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
|
||||||
|
|
||||||
|
def test_unreachable_source_returns_none(self):
|
||||||
|
self.assertIsNone(check_for_update(str(self.tmp / "nope"), "1.0.0"))
|
||||||
|
|
||||||
|
def test_malformed_manifest_returns_none(self):
|
||||||
|
with open(str(self.tmp / "manifest.json"), "w", encoding="utf-8") as f:
|
||||||
|
f.write("{ not valid json")
|
||||||
|
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
|
||||||
|
|
||||||
|
def test_non_object_manifest_returns_none(self):
|
||||||
|
self._write_manifest(["1.1.0"])
|
||||||
|
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
|
||||||
|
|
||||||
|
def test_newer_version_returns_info(self):
|
||||||
|
self._write_manifest({
|
||||||
|
"version": "1.1.0",
|
||||||
|
"source": r"\\nas\cmbot\releases\CMBot-1.1.0",
|
||||||
|
"notes": "fix batch export",
|
||||||
|
"mandatory": False,
|
||||||
|
})
|
||||||
|
info = check_for_update(str(self.tmp), "1.0.0")
|
||||||
|
self.assertIsInstance(info, UpdateInfo)
|
||||||
|
self.assertEqual(info.version, "1.1.0")
|
||||||
|
self.assertEqual(info.source, r"\\nas\cmbot\releases\CMBot-1.1.0")
|
||||||
|
self.assertEqual(info.notes, "fix batch export")
|
||||||
|
self.assertFalse(info.mandatory)
|
||||||
|
|
||||||
|
def test_same_version_returns_none(self):
|
||||||
|
self._write_manifest({"version": "1.0.0"})
|
||||||
|
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
|
||||||
|
|
||||||
|
def test_older_version_returns_none(self):
|
||||||
|
self._write_manifest({"version": "0.9.0"})
|
||||||
|
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
|
||||||
|
|
||||||
|
def test_missing_version_field_returns_none(self):
|
||||||
|
self._write_manifest({"notes": "no version here"})
|
||||||
|
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
|
||||||
|
|
||||||
|
def test_source_falls_back_to_update_source(self):
|
||||||
|
self._write_manifest({"version": "2.0.0"}) # no "source" field
|
||||||
|
info = check_for_update(str(self.tmp), "1.0.0")
|
||||||
|
self.assertEqual(info.source, str(self.tmp))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user