feat(update): add forced upgrade progress flow

This commit is contained in:
chengma
2026-07-13 12:12:58 +08:00
parent bab2609a85
commit 04124060fc
11 changed files with 437 additions and 101 deletions
+148 -60
View File
@@ -3,6 +3,7 @@ import unittest
import os
import sys
import threading
import time
from types import SimpleNamespace
from unittest import mock
@@ -12,7 +13,17 @@ sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin
from app import gui
from app import accounts, ai, appconfig, db, image_paths, image_studio, prompts, update_check
from app import (
accounts,
ai,
appconfig,
db,
image_paths,
image_studio,
prompts,
update_check,
update_installer,
)
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
@@ -42,12 +53,14 @@ from app.gui import (
CollectTab,
GenerateWorker,
GenerateTab,
ForcedUpdateDialog,
ImageStudioTab,
MainWindow,
SettingsTab,
TAB_STYLE,
TAB_TITLES,
WriteBackWorker,
UpdatePreparationWorker,
)
from app.gui import file_manager
import app.gui.workers as gui_workers
@@ -1573,82 +1586,157 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertNotIn("/api/v1", message)
self.assertNotIn("/generated/images", message)
def test_startup_update_gate_forced_blocks_and_opens_download(self):
boxes = []
def test_startup_update_gate_forced_blocks_and_uses_upgrade_dialog(self):
dialogs = []
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):
class FakeDialog:
def __init__(self, result, parent=None):
self.result = result
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
self.executed = False
dialogs.append(self)
def exec(self):
self.executed = True
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",
download_url="https://cm.833729.com/cmshopee.zip",
sha256="a" * 64,
size_bytes=123,
package_format="cmshopee-portable-v1",
updater_protocol=1,
min_updater_protocol=1,
message="必须升级",
)
opened = []
with mock.patch("app.gui.QMessageBox", FakeMessageBox):
allowed = gui._run_startup_update_gate(
checker=lambda: result,
opener=opened.append,
)
allowed = gui._run_startup_update_gate(
checker=lambda: result,
dialog_factory=FakeDialog,
)
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)
self.assertEqual(1, len(dialogs))
self.assertTrue(dialogs[0].executed)
self.assertIs(result, dialogs[0].result)
def test_forced_update_dialog_prepares_and_launches_updater_without_blocking_ui(self):
with self.make_temp_dir() as temp_dir:
install_root = os.path.join(temp_dir, "install")
staging_root = os.path.join(install_root, ".cmshopee-update", "staging", "1.2.0-test")
os.makedirs(staging_root)
result = update_check.UpdateCheckResult(
current_version="1.0.0",
checked=True,
forced=True,
latest_version="1.2.0",
download_url="https://cm.833729.com/cmshopee.zip",
sha256="a" * 64,
size_bytes=100,
package_format="cmshopee-portable-v1",
updater_protocol=1,
min_updater_protocol=1,
message="修复并优化自动升级",
)
staged = SimpleNamespace(
version="1.2.0",
staging_dir=os.path.abspath(staging_root),
)
def prepare(_metadata, _install_root, **callbacks):
callbacks["stage_callback"]("正在下载新版")
callbacks["progress"](50, 100)
return staged
launched = []
def factory(info, root):
return UpdatePreparationWorker(info, root, prepare=prepare)
dialog = ForcedUpdateDialog(
result,
install_root=install_root,
worker_factory=factory,
updater_launcher=lambda updater, plan: launched.append((updater, plan)),
)
self.addCleanup(dialog.close)
with mock.patch(
"app.gui.update_dialog.updater_entry.create_plan",
return_value=os.path.join(temp_dir, "plan.json"),
):
dialog.start_update()
deadline = time.time() + 3
while dialog.thread is not None and time.time() < deadline:
self.app.processEvents()
time.sleep(0.01)
self.assertIsNone(dialog.thread)
self.assertTrue(dialog.update_started)
self.assertEqual(100, dialog.progress_bar.value())
self.assertIn("50 B / 100 B", dialog.bytes_label.text())
self.assertEqual(1, len(launched))
self.assertIn("cmshopee-updater.exe", str(launched[0][0]))
def test_forced_update_dialog_cancel_waits_for_worker_cleanup(self):
with self.make_temp_dir() as temp_dir:
result = update_check.UpdateCheckResult(
current_version="1.0.0",
checked=True,
forced=True,
latest_version="1.2.0",
download_url="https://cm.833729.com/cmshopee.zip",
sha256="a" * 64,
size_bytes=100,
package_format="cmshopee-portable-v1",
updater_protocol=1,
min_updater_protocol=1,
)
def prepare(_metadata, _install_root, **callbacks):
while not callbacks["cancelled"]():
time.sleep(0.01)
raise update_installer.UpdateCancelled("已取消下载新版")
def factory(info, root):
return UpdatePreparationWorker(info, root, prepare=prepare)
dialog = ForcedUpdateDialog(
result,
install_root=temp_dir,
worker_factory=factory,
)
self.addCleanup(dialog.close)
dialog.start_update()
deadline = time.time() + 1
while dialog.worker is None and time.time() < deadline:
self.app.processEvents()
dialog.request_exit()
deadline = time.time() + 3
while dialog.thread is not None and time.time() < deadline:
self.app.processEvents()
time.sleep(0.01)
self.assertIsNone(dialog.thread)
self.assertIsNone(dialog.worker)
def test_forced_update_dialog_blocks_when_auto_install_metadata_is_incomplete(self):
result = update_check.UpdateCheckResult(
current_version="1.0.0",
checked=True,
forced=True,
latest_version="1.2.0",
download_url="https://cm.833729.com/cmshopee.zip",
)
dialog = ForcedUpdateDialog(result)
self.addCleanup(dialog.close)
self.assertIn("校验值缺失", dialog.stage_label.text())
self.assertEqual("重试", dialog.action_button.text())
self.assertIsNone(dialog.thread)
def test_startup_update_gate_check_failure_allows_entry_and_logs(self):
result = update_check.UpdateCheckResult(