diff --git a/app/editor.py b/app/editor.py index cca3d90..fea0e34 100644 --- a/app/editor.py +++ b/app/editor.py @@ -15,6 +15,9 @@ ITEMBOX_XPATH = ( "//div[@class='container']/div[@class='can-drag shopee-image-manager__itembox' " "and @data-draggable='true']" ) +COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS = 120 +COVER_FOREGROUND_UPLOAD_RECOVERY_REASONS = {"UPLOAD_STILL_PROCESSING", "UPLOAD_TIMEOUT"} +COVER_FOREGROUND_DRAG_RECOVERY_REASONS = {"DRAG_NOT_FIRST", "NEW_IMAGE_NOT_FOUND"} LOGIN_PATH_MARKERS = ( "/login", @@ -980,6 +983,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict } before = stable.get("rects") or _image_rects(cdp) before_srcs = {r.get("src") for r in before} + before_src_list = _src_snapshot(before_srcs) upload_click = _click_upload_tile(cdp) if not upload_click.get("clicked"): return { @@ -1075,13 +1079,20 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict "upload_state": last_state, "blob_seen": blob_seen, "file_size": file_size, + "before_srcs": before_src_list, } time.sleep(1) cur = _image_rects(cdp) new_rect = next((r for r in cur if r.get("src") == new_src), None) if not new_rect: - return {"ok": False, "reason": "NEW_IMAGE_NOT_FOUND", "new_src": new_src, "delete": delete_result} + return { + "ok": False, + "reason": "NEW_IMAGE_NOT_FOUND", + "new_src": new_src, + "delete": delete_result, + "count_after": len(cur), + } first = cur[0] cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"]) time.sleep(1.2) @@ -1101,6 +1112,170 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict } +def _src_snapshot(values): + return sorted(str(value) for value in values if value) + + +def _recover_cover_after_foreground(cdp, cover_result, timeout=COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS): + reason = str((cover_result or {}).get("reason") or "") + if reason in COVER_FOREGROUND_UPLOAD_RECOVERY_REASONS: + return _recover_cover_upload_after_foreground(cdp, cover_result, timeout=timeout) + if reason in COVER_FOREGROUND_DRAG_RECOVERY_REASONS: + return _recover_cover_drag_after_foreground(cdp, cover_result, timeout=timeout) + recovered = dict(cover_result or {}) + recovered["foreground_recovery"] = {"attempted": False, "reason": "NOT_RECOVERABLE"} + return recovered + + +def _recover_cover_upload_after_foreground(cdp, cover_result, timeout=COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS): + before_srcs = set(str(src) for src in (cover_result or {}).get("before_srcs") or [] if src) + if not before_srcs: + recovered = dict(cover_result or {}) + recovered["foreground_recovery"] = { + "attempted": True, + "ok": False, + "reason": "MISSING_BEFORE_SRCS", + } + return recovered + + original_reason = str((cover_result or {}).get("reason") or "UPLOAD_TIMEOUT") + end = time.time() + max(0.0, float(timeout)) + last_state = (cover_result or {}).get("upload_state") or {} + blob_seen = bool((cover_result or {}).get("blob_seen")) + last_rects = [] + while True: + cur = _image_rects(cdp) + last_rects = cur + last_state = _upload_state(cdp) + blob_seen = blob_seen or any(str(r.get("src") or "").startswith("blob:") for r in cur) + if (last_state.get("errors") or last_state.get("error_toasts")) and not last_state.get("busy_count"): + reason = "UPLOAD_DUPLICATE_IMAGE" if _has_duplicate_upload_error(last_state) else "UPLOAD_PAGE_ERROR" + return { + "ok": False, + "reason": reason, + "original_reason": original_reason, + "foreground_recovery": {"attempted": True, "ok": False, "reason": reason}, + "count_after": len(cur), + "upload_state": last_state, + "before_srcs": _src_snapshot(before_srcs), + "blob_seen": blob_seen, + } + ready = [ + r for r in cur + if r.get("src") not in before_srcs + and r.get("src") + and "susercontent" in r.get("src") + and "blob:" not in r.get("src") + ] + if ready: + return _drag_existing_cover_to_first( + cdp, + ready[-1]["src"], + original_reason=original_reason, + foreground_recovery_reason="UPLOAD_READY_AFTER_FOREGROUND", + timeout=min(max(0.0, float(timeout)), 30.0), + ) + if last_state.get("crop_modal"): + return { + "ok": False, + "reason": "UPLOAD_CROP_REQUIRED", + "original_reason": original_reason, + "foreground_recovery": {"attempted": True, "ok": False, "reason": "UPLOAD_CROP_REQUIRED"}, + "count_after": len(cur), + "upload_state": last_state, + "before_srcs": _src_snapshot(before_srcs), + "blob_seen": blob_seen, + } + if time.time() >= end: + break + time.sleep(1.5) + + reason = "UPLOAD_STILL_PROCESSING" if blob_seen or (last_state or {}).get("busy_count") else "UPLOAD_TIMEOUT" + return { + "ok": False, + "reason": reason, + "original_reason": original_reason, + "foreground_recovery": {"attempted": True, "ok": False, "reason": "RECOVERY_TIMEOUT"}, + "count_after": len(last_rects), + "upload_state": last_state, + "before_srcs": _src_snapshot(before_srcs), + "blob_seen": blob_seen, + } + + +def _recover_cover_drag_after_foreground(cdp, cover_result, timeout=COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS): + new_src = str((cover_result or {}).get("new_src") or "") + original_reason = str((cover_result or {}).get("reason") or "DRAG_NOT_FIRST") + return _drag_existing_cover_to_first( + cdp, + new_src, + original_reason=original_reason, + foreground_recovery_reason="REDRAG_AFTER_FOREGROUND", + timeout=timeout, + ) + + +def _drag_existing_cover_to_first( + cdp, + new_src, + original_reason, + foreground_recovery_reason, + timeout=COVER_FOREGROUND_RECOVERY_TIMEOUT_SECONDS, +): + if not new_src: + return { + "ok": False, + "reason": original_reason, + "original_reason": original_reason, + "foreground_recovery": { + "attempted": True, + "ok": False, + "reason": "NEW_SRC_MISSING", + }, + } + + end = time.time() + max(0.0, float(timeout)) + last_rects = [] + while True: + cur = _image_rects(cdp) + last_rects = cur + new_rect = next((r for r in cur if r.get("src") == new_src), None) + if new_rect: + if not cur: + break + first = cur[0] + cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"]) + time.sleep(1.2) + after = _image_rects(cdp) + cover_ok = bool(after and after[0].get("src") == new_src) + index = next((r["i"] for r in after if r.get("src") == new_src), None) + return { + "ok": cover_ok, + "reason": None if cover_ok else "DRAG_NOT_FIRST", + "original_reason": original_reason, + "foreground_recovery": { + "attempted": True, + "ok": cover_ok, + "reason": foreground_recovery_reason, + }, + "new_src": new_src, + "index": index, + "count_after": len(after), + } + if time.time() >= end: + break + time.sleep(1.5) + + return { + "ok": False, + "reason": "NEW_IMAGE_NOT_FOUND", + "original_reason": original_reason, + "foreground_recovery": {"attempted": True, "ok": False, "reason": "NEW_IMAGE_NOT_FOUND"}, + "new_src": new_src, + "count_after": len(last_rects), + } + + def _validated_old_cover_backup(old_cover_path): if not old_cover_path: return None @@ -1236,7 +1411,7 @@ def _confirm_update_modal(cdp, timeout=3): return last_present or {"present": False, "clicked": False, "reason": "NO_UPDATE_CONFIRM_MODAL"} -def apply_task(account, task, close_success_tab=False, on_step=None) -> dict: +def apply_task(account, task, close_success_tab=False, on_step=None, bring_to_front=True) -> dict: """Apply generated title/cover to Shopee. The caller must perform the batch confirmation before calling this function. @@ -1245,7 +1420,7 @@ def apply_task(account, task, close_success_tab=False, on_step=None) -> dict: item_id = _item_id(task) current_step = "open_product" _notify_apply_step(on_step, current_step, "start") - cdp = open_product(account, item_id) + cdp = open_product(account, item_id, bring_to_front=bring_to_front) _notify_apply_step(on_step, current_step, "success") committed = False try: @@ -1269,11 +1444,42 @@ def apply_task(account, task, close_success_tab=False, on_step=None) -> dict: new_cover_path, old_cover_path=_get(task, "old_cover_path"), ) + if not cover_result.get("ok"): + recoverable_reasons = ( + COVER_FOREGROUND_UPLOAD_RECOVERY_REASONS + | COVER_FOREGROUND_DRAG_RECOVERY_REASONS + ) + if not bring_to_front and str(cover_result.get("reason") or "") in recoverable_reasons: + current_step = "cover_retry_foreground" + _notify_apply_step( + on_step, + current_step, + "start", + "后台上传/拖拽疑似受限,已提前台恢复一次", + ) + try: + cdp.send("Page.bringToFront") + cover_result = _recover_cover_after_foreground(cdp, cover_result) + except Exception as exc: + cover_result = dict(cover_result) + cover_result["foreground_recovery"] = { + "attempted": True, + "ok": False, + "reason": str(exc), + } + if cover_result.get("ok"): + _notify_apply_step(on_step, current_step, "success", "前台恢复成功") + current_step = "replace_cover" + _notify_apply_step(on_step, current_step, "success") + else: + detail = _cover_upload_error_message(cover_result) + _notify_apply_step(on_step, current_step, "failed", detail) + current_step = "replace_cover" if not cover_result.get("ok"): error = _cover_upload_error_message(cover_result) _notify_apply_step(on_step, current_step, "failed", error) return {"committed": False, "error": error, "cover": cover_result} - _notify_apply_step(on_step, current_step, "success") + _notify_apply_step(on_step, "replace_cover", "success") current_step = "click_update" _notify_apply_step(on_step, current_step, "start") update_result = click_update(cdp) diff --git a/app/gui/workers.py b/app/gui/workers.py index 99c9e02..a3fd42c 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -568,6 +568,8 @@ class ApplyWorker(BaseWorker): self._current_batch_size = None self._batch_count = 0 self._progress_lock = threading.Lock() + self._foreground_lock = threading.Lock() + self._foregrounded_aliases = set() self.diagnostic_log_dir = diagnostic_log_dir self._run_id = None @@ -890,11 +892,13 @@ class ApplyWorker(BaseWorker): db.mark_running(task.id, "apply", path=self.db_path) self.row_updated.emit(task.id, {"status": "running", "last_error": None}) current_step = "apply_task" + bring_to_front = self._should_bring_account_to_front(account) result = editor.apply_task( account, task, close_success_tab=self.close_success_tab, on_step=on_step, + bring_to_front=bring_to_front, ) committed = bool(result.get("committed")) and not result.get("error") error = result.get("error") @@ -974,6 +978,17 @@ class ApplyWorker(BaseWorker): exc=exc, ) return "failed" + + def _should_bring_account_to_front(self, account): + alias = str(getattr(account, "alias", "") or "").strip() + if not alias: + return True + with self._foreground_lock: + if alias in self._foregrounded_aliases: + return False + self._foregrounded_aliases.add(alias) + return True + def _record_outcome(self, counters, total, outcome): with self._progress_lock: counters["done"] += 1 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 6a9106a..6fc9a67 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -392,7 +392,7 @@ data/images///__new. # AI 生成的新 - 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。 - `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 -- ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新仍使用默认 `bring_to_front=True`,保持可观察的上传、拖拽和提交行为。 +- ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新只在本轮每个账号的首条任务主动前台一次,后续同账号任务后台打开;若后台态上传/拖拽疑似受遮挡节流影响失败,才将当前 tab 提前台并做一次非破坏性安全恢复。 - 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。 - 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。 @@ -457,7 +457,7 @@ data/images///__new. # AI 生成的新 | 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 | | WebSocket Origin | `websocket-client` `suppress_origin=True` | | 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 仅在设置 `close_success_tab=true`、成功提交、且 tab 为本轮自动新建时关闭;确认成功跳回商品列表页时,关闭前等待 2 秒 | -| 采集前台激活 | ①采集只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新仍保持默认前台激活 | +| 前台激活 | ①采集只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交仍需要 UI 交互稳定性,但为降低抢焦点,本轮每账号只在首条任务主动前台一次;后台态封面上传/拖拽遇到疑似遮挡节流失败时,再提前台做一次非破坏性安全恢复,不完整重跑删图上传流程 | | SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | | 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | diff --git a/docs/routes.md b/docs/routes.md index 560df7d..0bb167a 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -127,6 +127,7 @@ - 安全开关通过后,弹窗展示本次筛选条件、任务总数、每批最大条数、预计批次数、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 - 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。 - 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。 +- 为降低批量更新时 Chrome 抢前台,③本轮每个账号只在第一条任务主动把 Chrome 提到前台,后续同账号任务后台打开;如果后台态封面上传/拖拽出现疑似遮挡节流失败,程序只对当前 tab 提前台做一次安全恢复,不重新执行完整删图上传流程。 - 打开编辑页失败时,如果 Shopee 弹出错误 toast(如商品 ID 不正确、商品不存在、无权限),③运行日志和任务失败原因必须显示该 toast 文案;同时把 toast HTML/URL/时间写入本地诊断日志。用户不需要手动复制瞬时 toast 的 HTML。 - 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。 diff --git a/docs/tasks/T-571.md b/docs/tasks/T-571.md index 369b10b..93a5684 100644 --- a/docs/tasks/T-571.md +++ b/docs/tasks/T-571.md @@ -3,7 +3,7 @@ id: T-571 title: ③更新抢前台降频:每账号只提一次 Chrome 前台 + 拖拽/上传失败安全升级前台恢复 phase: 7 deps: [T-402, T-562] -status: TODO +status: DONE created: 2026-07-09 --- @@ -85,4 +85,11 @@ created: 2026-07-09 ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。) +- 2026-07-09:完成 T-571。 + - `app/gui/workers.py`:`ApplyWorker` 增加线程安全的本轮账号前台集合,同一账号本轮首条更新传 `bring_to_front=True`,后续同账号任务传 `False`;分批更新跨批次保持该集合,多账号并行下用锁保护。 + - `app/editor.py`:`apply_task()` 增加 `bring_to_front` 参数并透传 `open_product()`;后台态封面替换遇到 `UPLOAD_STILL_PROCESSING` / `UPLOAD_TIMEOUT` / `DRAG_NOT_FIRST` / `NEW_IMAGE_NOT_FOUND` 时,只将当前 tab 提前台做一次非破坏性安全恢复,不完整重跑 `replace_cover()`。 + - `app/editor.py`:`UPLOAD_*` 失败返回新增 `before_srcs` 上传前快照;恢复上传时用 `before_srcs` 识别本次新图,固定恢复等待预算 120 秒;拖拽恢复只基于已有 `new_src` 重读图片列表并重拖。 + - `tests/test_editor_login.py`:覆盖 `before_srcs` 字段、后台拖拽失败前台重拖、后台上传处理中前台续等、缺少 `before_srcs` 时不猜测恢复且不点击更新。 + - `tests/test_gui.py`:覆盖同账号跨分批只首条前台、不同账号并行各自首条前台,并更新 fake `apply_task` 签名兼容 `bring_to_front`。 + - 文档:同步 `docs/04-architecture.md` 与 `docs/routes.md` 的③更新前台策略。 + - 验证通过:`py -3.10 -m unittest tests.test_editor_login`、`py -3.10 -m unittest tests.test_gui`、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。 diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index 7fa08fe..d649fc7 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -1002,6 +1002,9 @@ class EditorLoginTests(unittest.TestCase): self.assertTrue(result["blob_seen"]) self.assertEqual(1, result["upload_state"]["busy_count"]) self.assertIn("blob:", "\n".join(result["upload_state"]["srcs"])) + self.assertIn("before_srcs", result) + self.assertTrue(result["before_srcs"]) + self.assertFalse(any(str(src).startswith("blob:") for src in result["before_srcs"])) def test_replace_cover_reports_page_upload_error_with_state(self): cdp = FakeCoverCDP(count=8, upload_mode="error") @@ -1054,6 +1057,105 @@ class EditorLoginTests(unittest.TestCase): self.assertIn("新封面上传仍在处理中", result["error"]) click_update.assert_not_called() + def test_apply_task_background_drag_failure_recovers_foreground_without_replacing_again(self): + cdp = FakeCoverCDP(count=8) + cdp.confirm_clicked = True + cdp.uploaded = True + cdp.closed = False + cdp.close = lambda: setattr(cdp, "closed", True) + events = [] + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.replace_cover", + return_value={ + "ok": False, + "reason": "DRAG_NOT_FIRST", + "new_src": cdp.new_rect["src"], + }, + ) as replace_cover, mock.patch( + "app.editor.click_update", + return_value={"clicked": True, "reason": None}, + ): + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_cover_path": "new.jpg"}, + bring_to_front=False, + on_step=events.append, + ) + + self.assertTrue(result["committed"]) + replace_cover.assert_called_once() + self.assertIn(("Page.bringToFront", {}), cdp.sent) + self.assertTrue(cdp.dragged) + self.assertEqual(1, len(cdp.drag_calls)) + self.assertTrue(result["cover"]["foreground_recovery"]["ok"]) + self.assertTrue(any(event.get("step") == "cover_retry_foreground" for event in events)) + + def test_apply_task_background_upload_processing_recovers_by_waiting_current_upload(self): + cdp = FakeCoverCDP(count=8) + cdp.confirm_clicked = True + cdp.uploaded = True + cdp.closed = False + cdp.close = lambda: setattr(cdp, "closed", True) + before_srcs = [rect["src"] for rect in cdp.after_delete] + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.replace_cover", + return_value={ + "ok": False, + "reason": "UPLOAD_STILL_PROCESSING", + "before_srcs": before_srcs, + "upload_state": {"busy_count": 1}, + "blob_seen": True, + }, + ) as replace_cover, mock.patch( + "app.editor.click_update", + return_value={"clicked": True, "reason": None}, + ), mock.patch("app.editor.time.sleep"): + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_cover_path": "new.jpg"}, + bring_to_front=False, + ) + + self.assertTrue(result["committed"]) + replace_cover.assert_called_once() + self.assertIn(("Page.bringToFront", {}), cdp.sent) + self.assertTrue(cdp.dragged) + self.assertTrue(result["cover"]["foreground_recovery"]["ok"]) + self.assertEqual("UPLOAD_STILL_PROCESSING", result["cover"]["original_reason"]) + + def test_apply_task_background_upload_recovery_requires_before_srcs(self): + cdp = FakeCoverCDP(count=8) + cdp.confirm_clicked = True + cdp.uploaded = True + cdp.closed = False + cdp.close = lambda: setattr(cdp, "closed", True) + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.replace_cover", + return_value={ + "ok": False, + "reason": "UPLOAD_TIMEOUT", + "upload_state": {"busy_count": 0}, + }, + ) as replace_cover, mock.patch("app.editor.click_update") as click_update: + result = editor.apply_task( + {"debug_port": 9222}, + {"item_id": "51100639510", "new_cover_path": "new.jpg"}, + bring_to_front=False, + ) + + self.assertFalse(result["committed"]) + replace_cover.assert_called_once() + self.assertIn(("Page.bringToFront", {}), cdp.sent) + self.assertFalse(cdp.dragged) + self.assertEqual( + "MISSING_BEFORE_SRCS", + result["cover"]["foreground_recovery"]["reason"], + ) + click_update.assert_not_called() + def test_replace_cover_full_slots_reports_missing_delete_button(self): cdp = FakeCoverCDP(count=9, delete_click=False) diff --git a/tests/test_gui.py b/tests/test_gui.py index 4d71894..e100783 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -4423,12 +4423,14 @@ class GuiTests(TempDirMixin, unittest.TestCase): tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) applied_aliases = [] close_flags = [] + foreground_flags = [] progress = [] rows = [] - def fake_apply(account, task, close_success_tab=False, on_step=None): + def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True): applied_aliases.append(account.alias) close_flags.append(close_success_tab) + foreground_flags.append(bring_to_front) if account.alias == "alias-a": return {"committed": True, "error": None} if account.alias == "alias-b": @@ -4453,6 +4455,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(["alias-a", "alias-b"], applied_aliases) self.assertEqual([True, True], close_flags) + self.assertEqual([True, True], foreground_flags) self.assertFalse(summary["ok"]) self.assertEqual(3, summary["total"]) self.assertEqual(3, summary["done"]) @@ -4495,6 +4498,61 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_apply_worker_brings_each_account_to_front_only_once_across_batches(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 3, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639511", + }, + ], + path=cfg["db_path"], + ) + for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]): + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + foreground_flags = [] + + def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True): + foreground_flags.append(bring_to_front) + return {"committed": True, "error": None} + + with mock.patch("app.gui.chrome.is_running", return_value=True), \ + mock.patch( + "app.gui.accounts.detect_login", + return_value={"logged_in": True, "reason": None}, + ), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply): + summary = ApplyWorker( + tasks, + db_path=cfg["db_path"], + config=cfg, + batch_size=1, + ).execute() + + self.assertTrue(summary["ok"]) + self.assertEqual([True, False], foreground_flags) + self.assertEqual(2, summary["batch_count"]) + + self.assert_removed(temp_dir) + def test_apply_worker_dry_run_only_previews_and_logs_without_mutating_tasks(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -4596,9 +4654,11 @@ class GuiTests(TempDirMixin, unittest.TestCase): tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) started = {"alias-a": threading.Event(), "alias-b": threading.Event()} thread_names = set() + foreground_by_alias = {} - def fake_apply(account, task, close_success_tab=False, on_step=None): + def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True): thread_names.add(threading.current_thread().name) + foreground_by_alias[account.alias] = bring_to_front started[account.alias].set() other = "alias-b" if account.alias == "alias-a" else "alias-a" self.assertTrue(started[other].wait(2)) @@ -4621,6 +4681,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertTrue(summary["parallel_accounts"]) self.assertEqual(2, summary["applied"]) self.assertGreaterEqual(len(thread_names), 2) + self.assertEqual({"alias-a": True, "alias-b": True}, foreground_by_alias) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "applied" for task in updated)) @@ -6461,7 +6522,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] diagnostic_log_dir = os.path.join(temp_dir, "logs") - def fake_apply(account, task, close_success_tab=False, on_step=None): + def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True): on_step({"step": "open_product", "result": "start"}) on_step({"step": "replace_cover", "result": "failed", "detail": "token=SECRET"}) return {