feat: 完成PySide6 worker基座

- 新增 BaseWorker 通用 signals、取消标记和统一 run 入口

- 新增 run_worker QThread 包装与生命周期清理

- 新增 worker 单元测试覆盖 signal、取消、异常和类型校验

- 更新任务看板、API 合约、架构、当前状态和进度记录
This commit is contained in:
chengma
2026-06-27 10:05:29 +08:00
parent b8318c881d
commit 479ab5c41d
7 changed files with 276 additions and 12 deletions
+127
View File
@@ -0,0 +1,127 @@
"""PySide6 worker base classes and thread helpers."""
from __future__ import annotations
from typing import Any, Dict, Optional
try:
from PySide6.QtCore import QObject, QThread, Signal, Slot
QT_IMPORT_ERROR = None
except ModuleNotFoundError as exc:
QObject = object
QThread = None
QT_IMPORT_ERROR = exc
def Signal(*args, **kwargs): # type: ignore
return None
def Slot(*args, **kwargs): # type: ignore
def decorator(func):
return func
return decorator
class WorkerError(RuntimeError):
"""Raised when a worker cannot be started."""
def _require_qt() -> None:
if QT_IMPORT_ERROR is not None:
raise WorkerError("PySide6 未安装,无法启动后台任务")
def _summary_dict(value: Any) -> Dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return dict(value)
return {"result": value}
if QT_IMPORT_ERROR is None:
class BaseWorker(QObject):
"""Base QObject worker for long-running GUI tasks."""
progress = Signal(dict)
row_updated = Signal(int, dict)
log = Signal(str)
failed = Signal(int, str)
finished = Signal(dict)
cancelled = Signal(dict)
def __init__(self, parent: Optional[QObject] = None):
super().__init__(parent)
self._cancel_requested = False
def cancel(self) -> None:
self._cancel_requested = True
def is_cancelled(self) -> bool:
return self._cancel_requested
def should_cancel(self) -> bool:
return self.is_cancelled()
@Slot()
def run(self) -> None:
if self.is_cancelled():
self.cancelled.emit({"cancelled": True})
return
try:
summary = self.execute()
except Exception as exc: # pragma: no cover - covered through signal behavior
error = str(exc) or exc.__class__.__name__
self.failed.emit(-1, error)
self.finished.emit({"ok": False, "error": error})
return
payload = _summary_dict(summary)
if self.is_cancelled():
payload["cancelled"] = True
self.cancelled.emit(payload)
else:
self.finished.emit(payload)
def execute(self) -> Optional[Dict[str, Any]]:
raise NotImplementedError
else:
class BaseWorker(QObject): # type: ignore
def __init__(self, *args, **kwargs):
_require_qt()
def run_worker(
worker: BaseWorker,
thread_name: Optional[str] = None,
start: bool = True,
) -> QThread:
"""Move a worker to a QThread, wire lifecycle signals, and start it."""
_require_qt()
if not isinstance(worker, BaseWorker):
raise TypeError("worker 必须是 BaseWorker 实例")
thread = QThread()
if thread_name:
thread.setObjectName(thread_name)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(thread.quit)
worker.cancelled.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
worker.cancelled.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
# Keep the Python wrapper alive until Qt owns the lifecycle.
thread._cmshopee_worker = worker # type: ignore[attr-defined]
if start:
thread.start()
return thread
+1 -1
View File
@@ -35,7 +35,7 @@ Shopee 卖家中心页面 / 本地图片目录
真实组件: 真实组件:
- GUI 入口:根目录 `main.py` 调用 `app/gui.py`(待建,PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);也支持 `python -m app`。 - GUI 入口:根目录 `main.py` 调用 `app/gui.py`(待建,PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);也支持 `python -m app`。
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`chrome.py`、`editor.py`、`ai.py`、`prompts.py`、`workers.py`(待建);CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。 - 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。 - 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像,服务商待定);`openpyxl`。 - 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像,服务商待定);`openpyxl`。
+1 -1
View File
@@ -39,7 +39,7 @@
| T-102 | `app/chrome.py` 启动器:拼参数并启动、探测端口 | T-101, T-002 | 含三参数;端口就绪可探测 | DONE | | T-102 | `app/chrome.py` 启动器:拼参数并启动、探测端口 | T-101, T-002 | 含三参数;端口就绪可探测 | DONE |
| T-103 | 首次登录保活 + 登录检测 `is_logged_in` | T-102, T-001 | 关闭再启动免重登;登录/未登录判断准确 | DONE | | T-103 | 首次登录保活 + 登录检测 `is_logged_in` | T-102, T-001 | 关闭再启动免重登;登录/未登录判断准确 | DONE |
| T-104 | PySide6 五 Tab 主窗口骨架(`QMainWindow` + `QTabWidget`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换;启动不阻塞;基础状态栏可用 | DONE | | T-104 | PySide6 五 Tab 主窗口骨架(`QMainWindow` + `QTabWidget`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换;启动不阻塞;基础状态栏可用 | DONE |
| T-104b | PySide6 worker 基类与线程启动工具(`BaseWorker` + `QThread` 包装) | T-104 | signals: progress/log/row_updated/failed/finished/cancelled;取消标记可用;worker 不直接操作 QWidget | TODO | | T-104b | PySide6 worker 基类与线程启动工具(`BaseWorker` + `QThread` 包装) | T-104 | signals: progress/log/row_updated/failed/finished/cancelled;取消标记可用;worker 不直接操作 QWidget | DONE |
| T-105 | Tab④ 账号增删改(账号名/别名/端口/密码本地明文仅参考)+ 启动登录 + 检测登录 | T-104, T-101, T-103 | 增删改入库、建目录;密码字段 UI 打码;状态列刷新;不自动登录/填密码 | TODO | | T-105 | Tab④ 账号增删改(账号名/别名/端口/密码本地明文仅参考)+ 启动登录 + 检测登录 | T-104, T-101, T-103 | 增删改入库、建目录;密码字段 UI 打码;状态列刷新;不自动登录/填密码 | TODO |
| T-106 | 可选:为账号生成桌面快捷方式 | T-102 | `.lnk` 目标含该账号参数;双击进对应账号 | TODO | | T-106 | 可选:为账号生成桌面快捷方式 | T-102 | `.lnk` 目标含该账号参数;双击进对应账号 | TODO |
+12 -2
View File
@@ -225,9 +225,11 @@ TAB_TITLES: list[str] # 固定 Tab 顺序
`MainWindow` 目前只实现五 Tab 空壳与基础状态栏;业务控件按后续 Tab 任务逐步填充。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 `MainWindow` 目前只实现五 Tab 空壳与基础状态栏;业务控件按后续 Tab 任务逐步填充。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
## workers 模块(`app/workers.py`,待建,PySide6) ## workers 模块(`app/workers.py`,已建,PySide6)
```python ```python
QT_IMPORT_ERROR: Exception|None
class WorkerError(RuntimeError): ...
# worker 约定 # worker 约定
class BaseWorker(QObject): class BaseWorker(QObject):
@@ -238,16 +240,24 @@ class BaseWorker(QObject):
finished = Signal(dict) # summary finished = Signal(dict) # summary
cancelled = Signal(dict) cancelled = Signal(dict)
def cancel(self) -> None: ... def cancel(self) -> None: ...
def is_cancelled(self) -> bool: ...
def should_cancel(self) -> bool: ...
def execute(self) -> dict|None: ... # 子类实现;不要直接操作 QWidget
def run(self) -> None: ... # QThread.started 触发,统一发 terminal signal
run_worker(worker: BaseWorker) -> QThread # 绑定 signals、启动、收尾 deleteLater run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread
# moveToThread + 绑定 started/finished/cancelled + 收尾 deleteLater;
# 默认立即 start;测试或调用方需要先连额外信号时可 start=False 后手动 thread.start()
``` ```
要点: 要点:
- GUI 线程只操作 Qt widget;后台 worker 不直接访问 QWidget。 - GUI 线程只操作 Qt widget;后台 worker 不直接访问 QWidget。
- `workers.py` 不导入 `QtWidgets`;业务 worker 子类只通过 signal 回传 UI 所需数据。
- 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。 - 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。 - 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。 - ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。
- `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。
## 启动入口 ## 启动入口
+10 -8
View File
@@ -6,10 +6,10 @@
## 当前快照 ## 当前快照
- 日期:2026-06-27 - 日期:2026-06-27
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架。 - 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座。
- 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/chrome.py` 已实现 Chrome 参数拼装、启动与 CDP 端口探测;`app/gui.py` 已实现 PySide6 `MainWindow` + 五 Tab 空壳 + 状态栏。 - 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/chrome.py` 已实现 Chrome 参数拼装、启动与 CDP 端口探测;`app/gui.py` 已实现 PySide6 `MainWindow` + 五 Tab 空壳 + 状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。
- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/chrome/editor 登录检测/gui 骨架,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。 - 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/chrome/editor 登录检测/gui 骨架/worker signal 与线程包装,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。
- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。
## 既定设计要点(文档已定) ## 既定设计要点(文档已定)
@@ -33,13 +33,14 @@
| `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 |
| `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 |
| `app/gui.py` | 已有 | T-104 产出:PySide6 `QMainWindow` + `QTabWidget` 五 Tab 空壳 + 状态栏 | | `app/gui.py` | 已有 | T-104 产出:PySide6 `QMainWindow` + `QTabWidget` 五 Tab 空壳 + 状态栏 |
| `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 |
| `app/editor.py` | 已有 | T-001/T-103 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task | | `app/editor.py` | 已有 | T-001/T-103 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task |
| `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 | | `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 |
| `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | | `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 |
| `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir |
| `app/chrome.py` | 已有 | T-102 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测;快捷方式待 T-106 | | `app/chrome.py` | 已有 | T-102 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测;快捷方式待 T-106 |
| `tests/` | 已有 | T-006 产出:stdlib unittest 基座;appconfig/db 单元测试;excel/prompts 模块契约占位测试 | | `tests/` | 已有 | T-006 产出:stdlib unittest 基座;覆盖 appconfig/db/config/chrome/editor/gui/workers;excel/prompts 模块契约占位测试 |
| `app/excel.py` / `app/workers.py` | 待建 | Phase 1-3 产出 | | `app/excel.py` | 待建 | Phase 2 产出 |
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 |
## 已验证能力(单账号) ## 已验证能力(单账号)
@@ -54,9 +55,9 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)。 - 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)。
- 正在进行:无。 - 正在进行:无。
- 下一个可领取任务:**T-104b(PySide6 worker 基类与线程启动工具)**。 - 下一个可领取任务:**T-105(Tab④ 账号增删改 + 启动登录 + 检测登录)**。
## 当前可运行内容 ## 当前可运行内容
@@ -82,7 +83,8 @@ py -3 -c "import tempfile; from app import config; d=tempfile.TemporaryDirectory
# chrome 参数拼装 / 端口探测由 tests/test_chrome.py 覆盖 # chrome 参数拼装 / 端口探测由 tests/test_chrome.py 覆盖
python -m unittest discover -s tests python -m unittest discover -s tests
# GUI 骨架由 tests/test_gui.py 覆盖;默认 python 当前 PySide6=6.5.3,py -3 环境缺 PySide6 时该测试会 skip # GUI 骨架由 tests/test_gui.py 覆盖;worker 基座由 tests/test_workers.py 覆盖
# 默认 python 当前 PySide6=6.5.3,py -3 环境缺 PySide6 时相关测试会 skip
# 当前入口占位 # 当前入口占位
python main.py python main.py
+8
View File
@@ -335,3 +335,11 @@
- 结论:按文档完成、忠实于已验证 CDP 事实、文档↔代码一致、22 测试通过;未臆造未验证内容(满 9 张删除留 T-502)。 - 结论:按文档完成、忠实于已验证 CDP 事实、文档↔代码一致、22 测试通过;未臆造未验证内容(满 9 张删除留 T-502)。
- 待办:`app/editor.py` 接 GUI(T-104+)前,建议用 prototypes 同款环境做一次真机冒烟(本轮测试均为 mock CDP,未连真实 Shopee)。 - 待办:`app/editor.py` 接 GUI(T-104+)前,建议用 prototypes 同款环境做一次真机冒烟(本轮测试均为 mock CDP,未连真实 Shopee)。
- 完整报告:[`docs/reviews/2026-06-27-T001-T103-acceptance.md`](docs/reviews/2026-06-27-T001-T103-acceptance.md) - 完整报告:[`docs/reviews/2026-06-27-T001-T103-acceptance.md`](docs/reviews/2026-06-27-T001-T103-acceptance.md)
## 【2026-06-27】T-104b PySide6 worker 基座
- 状态:DONE
- 变更:新增 `app/workers.py`,实现 `BaseWorker(QObject)`、`progress/log/row_updated/failed/finished/cancelled` 通用 signals、协作式取消标记 `cancel()/should_cancel()`、统一 `run()` 执行入口与 `run_worker()` QThread 包装;新增 `tests/test_workers.py` 覆盖 signal 回传、取消、异常转失败、普通对象拒绝;同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/api.md`、`docs/04-architecture.md`。
- 细节:`workers.py` 不导入 `QtWidgets`,业务 worker 子类只能通过 signal 回传 UI 所需数据;`run_worker()` 默认启动线程,也支持 `start=False` 便于测试或调用方先连接额外信号;缺 PySide6 时抛明确 `WorkerError`,测试自动 skip。
- 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(28 tests,skipped=2);`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过(24 tests,skipped=4,其中 GUI/worker 因 py -3 缺 PySide6 跳过)。
- 下一步:按任务看板领取 T-105。
+117
View File
@@ -0,0 +1,117 @@
import os
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from _helpers import REPO_ROOT # noqa: F401
from app import workers
if workers.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtCore import QEventLoop, QTimer
from PySide6.QtWidgets import QApplication
from app.workers import BaseWorker, run_worker
class DemoWorker(BaseWorker):
def execute(self):
self.log.emit("开始")
self.progress.emit({"done": 1, "total": 1})
self.row_updated.emit(7, {"status": "success"})
return {"ok": True, "done": 1}
class CancelAwareWorker(BaseWorker):
def execute(self):
if self.should_cancel():
return {"done": 0}
self.cancel()
return {"done": 0}
class FailingWorker(BaseWorker):
def execute(self):
raise RuntimeError("模拟失败")
class WorkerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def start_and_wait(self, thread, timeout_ms=2000):
loop = QEventLoop()
finished = []
def on_finished():
finished.append(True)
loop.quit()
timer = QTimer()
timer.setSingleShot(True)
timer.timeout.connect(loop.quit)
thread.finished.connect(on_finished)
timer.start(timeout_ms)
thread.start()
loop.exec()
self.assertTrue(finished, "worker thread did not finish before timeout")
def test_worker_emits_common_signals_and_finishes(self):
worker = DemoWorker()
logs = []
progress = []
rows = []
finished = []
worker.log.connect(logs.append)
worker.progress.connect(lambda payload: progress.append(dict(payload)))
worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, dict(fields))))
worker.finished.connect(lambda payload: finished.append(dict(payload)))
thread = run_worker(worker, start=False)
self.start_and_wait(thread)
self.assertEqual(["开始"], logs)
self.assertEqual([{"done": 1, "total": 1}], progress)
self.assertEqual([(7, {"status": "success"})], rows)
self.assertEqual([{"ok": True, "done": 1}], finished)
def test_cancel_flag_emits_cancelled_instead_of_finished(self):
worker = CancelAwareWorker()
cancelled = []
finished = []
worker.cancelled.connect(lambda payload: cancelled.append(dict(payload)))
worker.finished.connect(lambda payload: finished.append(dict(payload)))
thread = run_worker(worker, start=False)
self.start_and_wait(thread)
self.assertEqual([{"done": 0, "cancelled": True}], cancelled)
self.assertEqual([], finished)
def test_uncaught_exception_emits_failed_and_finished_summary(self):
worker = FailingWorker()
failed = []
finished = []
worker.failed.connect(lambda task_id, error: failed.append((task_id, error)))
worker.finished.connect(lambda payload: finished.append(dict(payload)))
thread = run_worker(worker, start=False)
self.start_and_wait(thread)
self.assertEqual([(-1, "模拟失败")], failed)
self.assertEqual([{"ok": False, "error": "模拟失败"}], finished)
def test_run_worker_rejects_plain_object(self):
with self.assertRaises(TypeError):
run_worker(object(), start=False)
if __name__ == "__main__":
unittest.main()