From c9afaad24b8012802cf37bb091bb6bcabcbdf132 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 16 Jul 2026 19:12:21 +0800 Subject: [PATCH] feat(product-suite): persist context and confirm actions --- app/appconfig.py | 18 +++ app/gui/tabs/product_suite.py | 162 +++++++++++++++++--- app/image_studio.py | 7 +- docs/04-architecture.md | 7 +- docs/tasks/T-641.md | 9 +- tests/test_appconfig.py | 18 +++ tests/test_image_studio.py | 11 ++ tests/test_product_suite_gui.py | 263 ++++++++++++++++++++++++++++++-- 8 files changed, 459 insertions(+), 36 deletions(-) diff --git a/app/appconfig.py b/app/appconfig.py index 02c4135..4e96138 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -113,6 +113,7 @@ DEFAULT_CONFIG = { "max_parallel_accounts": 1, }, "product_suite": { + "last_account_alias": "", "last_settings": { "platform": "Shopee", "country": "中国台湾", @@ -442,6 +443,9 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False): if not isinstance(suite, dict): suite = {} config["product_suite"] = suite + suite["last_account_alias"] = _normalize_product_suite_last_account_alias( + suite.get("last_account_alias") + ) suite["last_settings"] = _normalize_product_suite_last_settings( suite.get("last_settings") ) @@ -454,6 +458,10 @@ def _normalize_product_suite_last_settings(value): return product_suite.last_suite_settings(value) +def _normalize_product_suite_last_account_alias(value): + return str(value or "").strip() if isinstance(value, str) else "" + + def _clamp_int(value, minimum, maximum, default): try: number = int(value) @@ -706,6 +714,16 @@ def product_suite_last_settings(config=None) -> dict: return _normalize_product_suite_last_settings(suite.get("last_settings")) +def product_suite_last_account_alias(config=None) -> str: + cfg = _config_or_load(config) + suite = cfg.get("product_suite", {}) + if not isinstance(suite, dict): + suite = {} + return _normalize_product_suite_last_account_alias( + suite.get("last_account_alias") + ) + + def normalize_generate_mode(value=None, generate_cover=None) -> str: text = str(value or "").strip().lower() if text in AI_GENERATE_MODES: diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index a460413..b418869 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -1221,13 +1221,21 @@ class ProductSuiteTab(QWidget): box.setText(str(message)) box.exec() - def _confirm(self, title, message, *, destructive=False): + def _confirm( + self, + title, + message, + *, + destructive=False, + confirm_text="确认", + cancel_text="取消", + ): box = QMessageBox(self) box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question) box.setWindowTitle(str(title)) box.setText(str(message)) - confirm_button = box.addButton("确认", QMessageBox.AcceptRole) - box.addButton("取消", QMessageBox.RejectRole) + confirm_button = box.addButton(str(confirm_text), QMessageBox.AcceptRole) + box.addButton(str(cancel_text), QMessageBox.RejectRole) if not destructive: box.setDefaultButton(confirm_button) box.exec() @@ -1302,15 +1310,20 @@ class ProductSuiteTab(QWidget): if source is not None else appconfig.product_suite_last_settings(self.config) ) + valid_aliases = {account.alias for account in self.accounts} + recent_alias = appconfig.product_suite_last_account_alias(self.config) + account_alias = source.account_alias if source is not None else recent_alias + if account_alias not in valid_aliases: + account_alias = self.accounts[0].alias if self.accounts else "" + if source is None and account_alias != recent_alias: + self._persist_last_account_alias(account_alias) state = SuiteTaskState( key=self._next_key, serial=self._next_serial, - account_alias=(source.account_alias if source is not None else ""), + account_alias=account_alias, prompt=(source.prompt if source is not None else ""), settings=product_suite.normalize_suite_settings(initial_settings), ) - if not state.account_alias and self.accounts: - state.account_alias = self.accounts[0].alias self._next_key += 1 self._next_serial += 1 return self._append_task_state(state) @@ -1496,6 +1509,7 @@ class ProductSuiteTab(QWidget): return self._clear_project_binding(state) state.account_alias = alias + self._persist_last_account_alias(alias) self._update_context_actions(state) def _update_account_tooltip(self): @@ -1597,6 +1611,14 @@ class ProductSuiteTab(QWidget): def _account_for_alias(self, alias): return next((account for account in self.accounts if account.alias == alias), None) + def _account_context_label(self, state): + account = self._account_for_alias(getattr(state, "account_alias", "")) + alias = str(getattr(state, "account_alias", "") or "") + name = str(getattr(account, "account_name", "") or "").strip() + if name and name != alias: + return "%s(%s)" % (name, alias) + return name or alias or "未选择店铺" + def _state_project(self, state, *, include_deleted=False): if state is None or state.project_id is None: return None @@ -1687,7 +1709,7 @@ class ProductSuiteTab(QWidget): self._flush_prompt_save(state) return project - def _create_draft_project(self, state): + def _create_draft_project(self, state, *, announce=True): if not self._has_account_context(state): return None account = self._account_for_alias(state.account_alias) @@ -1708,7 +1730,8 @@ class ProductSuiteTab(QWidget): self._set_task_title(state) if state is self._displayed_state: self._update_context_actions(state) - self._status("已创建临时草稿,可继续添加本地图片", "info") + if announce: + self._status("已创建临时草稿,可继续添加本地图片", "info") return project def _prompt_save_timer(self, state): @@ -1724,7 +1747,17 @@ class ProductSuiteTab(QWidget): return timer def _schedule_prompt_save(self, state): - if state is None or state.project_id is None: + if state is None: + return + if state.project_id is None: + if ( + not str(state.prompt or "").strip() + or not state.account_alias + or self._account_for_alias(state.account_alias) is None + ): + self._cancel_prompt_save(state) + return + self._prompt_save_timer(state).start() return if state.prompt == state.last_saved_prompt: self._cancel_prompt_save(state) @@ -1752,9 +1785,19 @@ class ProductSuiteTab(QWidget): self._persist_prompt(state) def _persist_prompt(self, state): - if state is None or state.project_id is None: + if state is None: return True prompt = str(state.prompt or "") + if state.project_id is None: + if not prompt.strip(): + return True + if ( + not state.account_alias + or self._account_for_alias(state.account_alias) is None + ): + return True + if self._create_draft_project(state, announce=False) is None: + return False if prompt == state.last_saved_prompt: return True try: @@ -1820,6 +1863,29 @@ class ProductSuiteTab(QWidget): self.config.clear() self.config.update(saved) + def _persist_last_account_alias(self, alias): + value = str(alias or "").strip() + if value == appconfig.product_suite_last_account_alias(self.config): + return + try: + if os.path.exists(self.config_path): + saved = appconfig.update_config( + {"product_suite": {"last_account_alias": value}}, + path=self.config_path, + ) + else: + base = dict(self.config) + suite = base.get("product_suite", {}) + suite = dict(suite) if isinstance(suite, dict) else {} + suite["last_account_alias"] = value + base["product_suite"] = suite + saved = appconfig.save_config(base, path=self.config_path) + except Exception as exc: + self._status("商品套图最近店铺保存失败:%s" % _user_error(exc), "danger") + return + self.config.clear() + self.config.update(saved) + def _on_prompt_changed(self): if self._loading or self._displayed_state is None: return @@ -2190,9 +2256,24 @@ class ProductSuiteTab(QWidget): self._status("当前任务正在拉取蝦皮主图", "info") return existing = [asset for asset in self._original_assets(state, include_missing=False) if _asset_usable(asset)] - if existing and not self._confirm( - "覆盖拉取蝦皮主图", - "本地已有商品原图。继续拉取会刷新蝦皮原图列表,本地上传图片会保留。确认继续吗?", + message = ( + "店铺:%s\n" + "商品ID:%s\n" + "当前可用商品原图:%d张\n\n" + "将读取蝦皮主图并在后台下载,不会修改蝦皮线上商品。" + % ( + self._account_context_label(state), + state.item_id, + len(existing), + ) + ) + if existing: + message += "\n继续拉取会刷新蝦皮原图列表,本地手动添加图片会保留。" + if not self._confirm( + "确认拉取蝦皮主图", + message, + confirm_text="确认拉取", + cancel_text="取消", ): return worker = ImageStudioPullImagesWorker( @@ -2564,6 +2645,7 @@ class ProductSuiteTab(QWidget): return False retry_job_id = int(retry_job_id) if retry_job_id is not None else None retrying = retry_job_id is not None + confirm_batch = specs is None and not retrying if state is self._displayed_state: self._save_controls_to_state(state) template_text = None @@ -2597,12 +2679,17 @@ class ProductSuiteTab(QWidget): if not specs: self._message("生成数量为0", "请至少把一个套图分类的数量设为1。") return False - if len(specs) > product_suite.MAX_GENERATION_COUNT_WITHOUT_CONFIRM: - if not self._confirm( - "确认生成数量", - "本轮将生成%d张图片,预计耗时和点数较多。确认继续吗?" % len(specs), - ): - return False + if confirm_batch and not self._confirm( + "确认生成商品套图", + self._generation_confirmation_message( + state, + local_assets, + specs, + ), + confirm_text="确认生成", + cancel_text="返回修改", + ): + return False self._persist_state(state) run_token = uuid.uuid4().hex worker = ProductSuiteGenerateWorker( @@ -2661,6 +2748,43 @@ class ProductSuiteTab(QWidget): ) return True + def _generation_confirmation_message(self, state, local_assets, specs): + counts = {} + for spec in specs: + category = str( + spec.get("category") + or spec.get("job_type") + or "未分类" + ) + counts[category] = counts.get(category, 0) + 1 + ordered = list(product_suite.category_order(state.settings)) + ordered.extend(name for name in counts if name not in ordered) + category_lines = [ + "%s:%d张" % (name, counts[name]) + for name in ordered + if counts.get(name, 0) > 0 + ] + return ( + "店铺:%s\n" + "商品ID:%s\n" + "可用商品原图:%d张\n" + "每张上传图分别作为主图生成:%s\n" + "%s\n" + "图片比例:%s\n" + "生成总数:%d张\n" + "商品卖点:已填写\n\n" + "本次生成会消耗 cmhub 点数。" + % ( + self._account_context_label(state), + state.item_id or "未绑定商品", + len(local_assets), + "是" if state.settings.get("per_image_primary") else "否", + "\n".join(category_lines), + state.settings.get("ratio") or "1:1", + len(specs), + ) + ) + def _generation_signal_token(self, payload=None): token = str((payload or {}).get("run_token") or "") sender = self.sender() diff --git a/app/image_studio.py b/app/image_studio.py index b8da8fe..b847fd2 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -470,6 +470,8 @@ def list_recoverable_draft_projects(path=None, conn=None): WHERE p.deleted_at IS NULL AND p.binding_state = ? AND ( + TRIM(COALESCE(p.draft_prompt, '')) <> '' + OR EXISTS ( SELECT 1 FROM image_studio_assets AS a WHERE a.project_id = p.id @@ -493,9 +495,12 @@ def project_has_content(project_id, path=None, conn=None) -> bool: SELECT 1 FROM image_studio_assets WHERE project_id = ? ) OR EXISTS( SELECT 1 FROM image_studio_jobs WHERE project_id = ? + ) OR EXISTS( + SELECT 1 FROM image_studio_projects + WHERE id = ? AND TRIM(COALESCE(draft_prompt, '')) <> '' ) AS has_content """, - (int(project_id), int(project_id)), + (int(project_id), int(project_id), int(project_id)), ).fetchone() return bool(row["has_content"] if row is not None else False) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 96da36f..a383aa6 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -137,6 +137,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 "max_parallel_accounts": 1 }, "product_suite": { + "last_account_alias": "", "last_settings": { "platform": "Shopee", "country": "中国台湾", @@ -169,7 +170,9 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 该段不是替代 ③ 确认弹窗的常驻授权;③「开始更新」仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时以③确认弹窗作为线上提交前的唯一显式确认边界。普通正式更新不读取 `test_item_id` 做阻断。旧配置中的真实提交、封面更新、成功关页等开关只作迁移兼容读取,保存后不再写回。 -`product_suite.last_settings` 只保存⑥「商品套图」最近一次选择的平台、站点、语言和比例,作为软件重启后第一个未绑定商品任务的默认值。已有账号+商品 ID 项目仍以 SQLite `image_studio_projects.suite_settings_json` 为准;同一次运行中新任务优先继承当前任务;绑定已有项目后再由项目设置覆盖最近默认。旧配置缺少该段或值不在当前选项集合时,分别回退到 `Shopee / 中国台湾 / 繁体中文 / 1:1`。该段不保存账号、商品 ID、提示词、图片路径或密钥。 +`product_suite.last_account_alias` 只保存⑥「商品套图」最近一次有效选择的账号别名。软件重启后的第一个新任务优先恢复该账号;账号已删除或不可用时回退到第一个可用账号。已有账号+商品 ID 项目和恢复的临时草稿始终以 SQLite 中自己的 `account_alias` 为准,不被最近账号覆盖。 + +`product_suite.last_settings` 只保存⑥「商品套图」最近一次选择的平台、站点、语言和比例,作为软件重启后第一个未绑定商品任务的默认值。已有账号+商品 ID 项目仍以 SQLite `image_studio_projects.suite_settings_json` 为准;同一次运行中新任务优先继承当前任务;绑定已有项目后再由项目设置覆盖最近默认。旧配置缺少该段或值不在当前选项集合时,分别回退到 `Shopee / 中国台湾 / 繁体中文 / 1:1`。`product_suite` 配置不保存商品 ID、卖点提示词、图片路径或密钥。 ### 5.1b AI 模型清单 `data/config/ai_models.json` @@ -434,7 +437,7 @@ data/images///__new. # AI 生成的新 - `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。允许占位符为 `{套图名称}`、`{补充描述}`、平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点及四个只读规则;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。 - `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。⑥原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。 -- ⑥已选账号但未填写商品 ID 时允许导入、拖入或粘贴本地图片,首次有效导入才创建草稿;取消选择和全部导入失败不保留空草稿。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。 +- ⑥已选账号但未填写商品 ID 时允许输入卖点、导入、拖入或粘贴本地图片。非空卖点在现有防抖稳定后会创建一个可恢复临时草稿并保存到 `image_studio_projects.draft_prompt`;首次有效图片导入也会创建草稿。空白卖点、取消选择和全部导入失败不保留空草稿。只有卖点的草稿同样属于可恢复业务内容;卖点清空且没有资产/job 时仍可按空草稿规则清理。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。 - 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。 - 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。 - T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。 diff --git a/docs/tasks/T-641.md b/docs/tasks/T-641.md index 8d8e969..5bdc026 100644 --- a/docs/tasks/T-641.md +++ b/docs/tasks/T-641.md @@ -3,7 +3,7 @@ id: T-641 title: 商品套图最近上下文恢复与执行前确认 phase: 7 deps: [T-640] -status: TODO +status: DONE created: 2026-07-16 --- @@ -34,7 +34,7 @@ created: 2026-07-16 卖点是商品级业务内容,不能作为全局默认文本复制给其他商品。修改 `app/gui/tabs/product_suite.py`,沿用现有 `image_studio_projects.draft_prompt`: -- 当前任务尚无 `project_id` 时,用户选择了有效店铺且输入的卖点 `strip()` 后非空,在文本稳定约 `800ms` 后创建一次临时草稿并保存 `draft_prompt`。 +- 当前任务尚无 `project_id` 时,用户选择了有效店铺且输入的卖点 `strip()` 后非空,沿用现有约 `500ms` 防抖,在文本稳定后创建一次临时草稿并保存 `draft_prompt`。 - 不因第一个字符立即创建项目;连续输入只创建一个草稿、只在防抖稳定后落库。 - 切换任务、关闭任务、关闭软件、开始拉取、添加图片或开始生成前,必须同步刷新卖点;如内容非空且尚无项目,创建草稿并保存,避免用户在防抖到期前关闭造成丢失。 - 空白卖点、无有效店铺或程序化回填不得创建草稿。 @@ -129,4 +129,7 @@ git diff --check ## 执行记录 -- 待执行。 +- 2026-07-16:`product_suite.last_account_alias` 已加入 `config.json` 归一化与读取 helper。⑥首次新任务会恢复最后有效店铺;同一次运行仍优先继承当前任务;项目/草稿继续使用自己的账号。最近账号失效时回退到第一个可用账号并修正配置,不保存密码、Cookie 或登录态。 +- 2026-07-16:卖点防抖沿用现有 500ms。无 `project_id`、店铺有效且卖点非空时自动创建一个静默临时草稿并写入 `draft_prompt`;切换任务、关闭任务和关闭程序前的同步刷新也能补建草稿。仅含卖点的草稿已纳入恢复和 `project_has_content()` 语义;卖点清空且无资产/job 时仍可按空草稿清理。新建不继承任务不会全局带入上一商品卖点。 +- 2026-07-16:「拉取蝦皮主图」首次和覆盖拉取均增加上下文确认,展示店铺、商品 ID、当前原图数和只读说明;取消后不创建 worker 或启动 Chrome。「生成套图」在正常整轮构造 specs 后、创建 job 前始终确认,展示原图数、每图主图开关、分类实际数量、比例、总数和 cmhub 点数提示;单张失败重试不走整轮确认。 +- 2026-07-16:同步更新架构配置和草稿事实;补齐最近店铺恢复/失效回退、卖点草稿创建与恢复、空文本不建草稿、拉取/生成确认及取消边界测试,并将 T-640 测试中的已销毁 `QThread` 检查改为容忍 Qt 对象已正常释放。相关 73 项通过;商品套图 GUI 36 项在主工作区通过,其余测试文件在短路径隔离 worktree 中逐文件通过,共覆盖 539 项;Ruff、`compileall` 与 `git diff --check` 均通过。 diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 2d252f1..20dcfc0 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -41,6 +41,10 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): }, appconfig.product_suite_last_settings(config), ) + self.assertEqual( + "", + appconfig.product_suite_last_account_alias(config), + ) updated = appconfig.update_config( {"ai": {"resolution": "2k"}}, @@ -84,6 +88,7 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): "chrome_path": "custom-chrome.exe", "custom_section": {"keep": True}, "product_suite": { + "last_account_alias": ["invalid"], "last_settings": { "platform": "未知平台", "country": "新加坡", @@ -108,10 +113,15 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): }, appconfig.product_suite_last_settings(loaded), ) + self.assertEqual( + "", + appconfig.product_suite_last_account_alias(loaded), + ) updated = appconfig.update_config( { "product_suite": { + "last_account_alias": " alias-b ", "last_settings": { "platform": "Amazon", "country": "中国台湾", @@ -125,6 +135,10 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assertEqual("custom-chrome.exe", updated["chrome_path"]) self.assertEqual({"keep": True}, updated["custom_section"]) + self.assertEqual( + "alias-b", + appconfig.product_suite_last_account_alias(updated), + ) self.assertEqual("16:9", appconfig.product_suite_last_settings(updated)["ratio"]) with open(config_path, "r", encoding="utf-8") as fh: persisted = json.load(fh) @@ -132,6 +146,10 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): {"platform", "country", "language", "ratio"}, set(persisted["product_suite"]["last_settings"]), ) + self.assertEqual( + "alias-b", + persisted["product_suite"]["last_account_alias"], + ) self.assert_removed(temp_dir) diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index f64dc37..0383eef 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -237,6 +237,17 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assertTrue(draft.item_id.startswith(image_studio.TEMPORARY_ITEM_PREFIX)) self.assertEqual(draft.item_id, draft.storage_key) self.assertEqual(image_studio.PROJECT_BINDING_DRAFT, draft.binding_state) + self.assertTrue(image_studio.project_has_content(draft.id, path=db_path)) + self.assertEqual( + [draft.id], + [ + project.id + for project in image_studio.list_recoverable_draft_projects( + path=db_path + ) + ], + ) + image_studio.update_project_prompt(draft.id, "", path=db_path) self.assertFalse(image_studio.project_has_content(draft.id, path=db_path)) self.assertEqual([], image_studio.list_recoverable_draft_projects(path=db_path)) before_dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), draft) diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index a5b7b2c..3ce0268 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -312,7 +312,11 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.addCleanup(dialog.close) expected = dialog.preview_edit.toPlainText() - with mock.patch.object(tab, "_start_thread", return_value=object()): + with mock.patch.object( + tab, + "_confirm", + return_value=True, + ), mock.patch.object(tab, "_start_thread", return_value=object()): self.assertTrue(tab.start_generation(state)) self.assertEqual(expected, state.worker.job_specs[0]["prompt"]) state.worker = None @@ -532,6 +536,61 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_recent_account_restores_and_missing_account_falls_back(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + config = appconfig.save_config(self._config(temp_dir), path=config_path) + accounts.create_account("主店", "alias-a", debug_port=9222, config=config) + accounts.create_account("副店", "alias-b", debug_port=9223, config=config) + + first = ProductSuiteTab( + config=config, + config_path=config_path, + db_path=config["db_path"], + ) + first.account_combo.setCurrentIndex( + first.account_combo.findData("alias-b") + ) + self.app.processEvents() + self.assertEqual( + "alias-b", + appconfig.product_suite_last_account_alias( + appconfig.load_config(config_path) + ), + ) + first.close() + first.deleteLater() + self.app.processEvents() + + second_config = appconfig.load_config(config_path) + second = ProductSuiteTab( + config=second_config, + config_path=config_path, + db_path=second_config["db_path"], + ) + self.assertEqual("alias-b", second.account_combo.currentData()) + second.close() + second.deleteLater() + self.app.processEvents() + + accounts.delete_account("alias-b", config=second_config) + third_config = appconfig.load_config(config_path) + third = ProductSuiteTab( + config=third_config, + config_path=config_path, + db_path=third_config["db_path"], + ) + self.addCleanup(third.close) + self.assertEqual("alias-a", third.account_combo.currentData()) + self.assertEqual( + "alias-a", + appconfig.product_suite_last_account_alias( + appconfig.load_config(config_path) + ), + ) + + self.assert_removed(temp_dir) + def test_task_tabs_keep_independent_prompt_and_settings(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) @@ -681,24 +740,62 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_prompt_without_project_stays_in_memory_until_draft_creation(self): + def test_prompt_without_project_creates_and_restores_draft(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) accounts.create_account("主店", "alias-a", debug_port=9222, config=config) tab = ProductSuiteTab(config=config, db_path=config["db_path"]) - self.addCleanup(tab.close) state = tab._displayed_state tab.prompt_edit.setPlainText("尚未建立项目的卖点") QTest.qWait(650) self.app.processEvents() - self.assertEqual([], image_studio.list_projects(path=config["db_path"])) - draft = tab._create_draft_project(state) + projects = image_studio.list_projects(path=config["db_path"]) + self.assertEqual(1, len(projects)) + draft = projects[0] self.assertIsNotNone(draft) stored = image_studio.get_project(draft.id, path=config["db_path"]) self.assertEqual("尚未建立项目的卖点", stored.draft_prompt) self.assertEqual("尚未建立项目的卖点", state.last_saved_prompt) + self.assertEqual( + [draft.id], + [ + project.id + for project in image_studio.list_recoverable_draft_projects( + path=config["db_path"] + ) + ], + ) + + tab.close() + tab.deleteLater() + self.app.processEvents() + restored = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(restored.close) + restored_state = next( + value + for value in restored._states.values() + if value.project_id == draft.id + ) + self.assertEqual("尚未建立项目的卖点", restored_state.prompt) + new_state = restored.add_task(inherit=False) + self.assertEqual("", new_state.prompt) + + self.assert_removed(temp_dir) + + def test_blank_prompt_without_project_does_not_create_draft(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=config) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + + tab.prompt_edit.setPlainText(" ") + QTest.qWait(650) + self.app.processEvents() + + self.assertEqual([], image_studio.list_projects(path=config["db_path"])) self.assert_removed(temp_dir) @@ -1220,14 +1317,18 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): while state.worker is not None and time.monotonic() < deadline: QTest.qWait(20) self.app.processEvents() - while ( - generation_thread is not None - and generation_thread.isRunning() - and time.monotonic() < deadline - ): + while generation_thread is not None and time.monotonic() < deadline: + try: + running = generation_thread.isRunning() + except RuntimeError: + generation_thread = None + break + if not running: + break QTest.qWait(20) self.app.processEvents() - self.assertFalse(generation_thread.isRunning()) + if generation_thread is not None: + self.assertFalse(generation_thread.isRunning()) all_jobs = image_studio.list_jobs(project.id, path=config["db_path"]) retry_jobs = [ @@ -1710,6 +1811,146 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_pull_confirmation_always_shows_account_item_and_existing_count(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + account = accounts.create_account( + "主店", + "alias-a", + debug_port=9222, + config=config, + ) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + tab.item_id_edit.setText("51100639510") + confirmations = [] + tab._confirm = lambda title, message, **kwargs: confirmations.append( + (title, message, kwargs) + ) and False + + tab.pull_main_images() + + self.assertIsNone(state.pull_worker) + self.assertEqual("确认拉取蝦皮主图", confirmations[0][0]) + self.assertIn("主店(alias-a)", confirmations[0][1]) + self.assertIn("商品ID:51100639510", confirmations[0][1]) + self.assertIn("当前可用商品原图:0张", confirmations[0][1]) + self.assertIn("不会修改蝦皮线上商品", confirmations[0][1]) + self.assertEqual("确认拉取", confirmations[0][2]["confirm_text"]) + + project = image_studio.create_or_get_project( + account, + item_id="51100639510", + path=config["db_path"], + ) + source_path = os.path.join(temp_dir, "existing.png") + self._write_image(source_path) + image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=source_path, + path=config["db_path"], + ) + state.project_id = project.id + state.project_binding_state = project.binding_state + state.item_id = project.item_id + tab.item_id_edit.setText(project.item_id) + tab._refresh_originals(state) + + tab.pull_main_images() + + self.assertIsNone(state.pull_worker) + self.assertIn("当前可用商品原图:1张", confirmations[1][1]) + self.assertIn("本地手动添加图片会保留", confirmations[1][1]) + + self.assert_removed(temp_dir) + + def test_generation_confirmation_prevents_job_creation_and_retry_bypasses_it(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + account = accounts.create_account( + "主店", + "alias-a", + debug_port=9222, + config=config, + ) + project = image_studio.create_or_get_project( + account, + item_id="51100639510", + path=config["db_path"], + ) + source_path = os.path.join(temp_dir, "source.png") + self._write_image(source_path) + source = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=source_path, + path=config["db_path"], + ) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.prompt = "轻便耐用,适合日常使用" + state.settings["per_image_primary"] = True + tab._load_state(state) + confirmations = [] + tab._confirm = lambda title, message, **kwargs: confirmations.append( + (title, message, kwargs) + ) and False + + self.assertFalse(tab.start_generation(state)) + self.assertEqual([], image_studio.list_jobs(project.id, path=config["db_path"])) + self.assertEqual("确认生成商品套图", confirmations[0][0]) + self.assertIn("主店(alias-a)", confirmations[0][1]) + self.assertIn("商品ID:51100639510", confirmations[0][1]) + self.assertIn("可用商品原图:1张", confirmations[0][1]) + self.assertIn("每张上传图分别作为主图生成:是", confirmations[0][1]) + self.assertIn("图片比例:1:1", confirmations[0][1]) + self.assertIn("生成总数:", confirmations[0][1]) + self.assertIn("消耗 cmhub 点数", confirmations[0][1]) + self.assertEqual("确认生成", confirmations[0][2]["confirm_text"]) + self.assertEqual("返回修改", confirmations[0][2]["cancel_text"]) + + failed_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="场景图", + prompt="失败重试", + path=config["db_path"], + ) + failed_job = image_studio.update_job_status( + failed_job.id, + "failed", + path=config["db_path"], + ) + confirmations.clear() + with mock.patch.object(tab, "_start_thread", return_value=mock.Mock()): + self.assertTrue( + tab.start_generation( + state, + specs=[ + { + "source_asset_id": source.id, + "job_type": failed_job.job_type, + "prompt": failed_job.prompt, + } + ], + retry_job_id=failed_job.id, + ) + ) + self.assertEqual([], confirmations) + state.worker = None + state.thread = None + state.generation_run_token = "" + tab._generation_run_states.clear() + + self.assert_removed(temp_dir) + def test_original_checkbox_click_and_keyboard_delete_keep_actions_separate(self): original_list = ProductOriginalList() self.addCleanup(original_list.close)