diff --git a/app/editor.py b/app/editor.py index d5bc01b..dd547d3 100644 --- a/app/editor.py +++ b/app/editor.py @@ -84,6 +84,46 @@ JS_TOASTS = ( "return JSON.stringify(t.slice(0,5));})()" ) +JS_CLICK_FIRST_DELETE = ( + "(function(){" + f"var s=document.evaluate({json.dumps(ITEMBOX_XPATH)},document,null," + "XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);" + "if(!s.snapshotLength)return JSON.stringify({clicked:false,reason:'NO_IMAGE'});" + "var item=s.snapshotItem(0);" + "['mouseover','mouseenter','mousemove'].forEach(function(n){" + "item.dispatchEvent(new MouseEvent(n,{bubbles:true,view:window}));});" + "var selectors=['.shopee-image-manager__icon--delete','[class*=\"icon--delete\"]','[class*=\"delete\"]'];" + "var btn=null;" + "for(var i=0;i0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}" + "var rootSelectors='[role=dialog],[class*=dialog],[class*=Dialog],[class*=modal]," + "[class*=Modal],[class*=popover],[class*=Popover],[class*=popup],[class*=Popup]';" + "var roots=[].slice.call(document.querySelectorAll(rootSelectors)).filter(visible);" + "var yes=/(删除|刪除|確認|确认|確定|确定|OK|Yes)/i;" + "var no=/(取消|cancel|否|No)/i;" + "var seen=[];var candidates=[];" + "roots.forEach(function(root){[].slice.call(root.querySelectorAll('button,[role=button]')).forEach(function(b){" + "var text=((b.innerText||b.textContent||b.getAttribute('aria-label')||'')+'').trim();" + "if(!text||!visible(b))return;seen.push(text);" + "if(b.disabled||/disabled/i.test(b.className)||no.test(text)||!yes.test(text))return;" + "candidates.push({button:b,text:text,score:/(删除|刪除)/i.test(text)?0:1});});});" + "if(!candidates.length)return JSON.stringify({clicked:false,reason:'NO_CONFIRM_BUTTON',buttons:seen.slice(0,8)});" + "candidates.sort(function(a,b){return a.score-b.score;});" + "candidates[0].button.click();" + "return JSON.stringify({clicked:true,reason:null,text:candidates[0].text});})()" +) + class EditorError(RuntimeError): """Raised for expected editor automation failures with user-readable messages.""" @@ -383,11 +423,11 @@ def change_title(cdp, new_title) -> dict: return {"ok": ok, "written": written, "value": value, "modelvalue": modelvalue} -def replace_cover(cdp, image_win_path, timeout=90) -> dict: +def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=90) -> dict: """Upload an image and drag it to the first position. - Full 9-image deletion is intentionally left for T-502 because its confirm dialog - selector is not verified yet. + When the image manager is full, only delete the current first image if the + old cover backup from the collect stage exists locally. """ image_win_path = os.path.abspath(str(image_win_path)) @@ -400,12 +440,30 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict: ) time.sleep(0.5) before = _image_rects(cdp) + count_before = len(before) + delete_result = None if len(before) >= 9: - return {"ok": False, "reason": "FULL_IMAGE_SLOTS", "count_before": len(before)} + backup_path = _validated_old_cover_backup(old_cover_path) + if not backup_path: + return { + "ok": False, + "reason": "OLD_COVER_BACKUP_MISSING", + "count_before": count_before, + "old_cover_path": old_cover_path, + } + delete_result = _delete_first_cover(cdp, before) + if not delete_result.get("ok"): + return { + "ok": False, + "reason": delete_result.get("reason"), + "count_before": count_before, + "delete": delete_result, + } + before = _image_rects(cdp) before_srcs = {r.get("src") for r in before} oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')") if not oid: - return {"ok": False, "reason": "NO_UPLOAD_INPUT", "count_before": len(before)} + return {"ok": False, "reason": "NO_UPLOAD_INPUT", "count_before": count_before, "delete": delete_result} cdp.send("DOM.setFileInputFiles", {"objectId": oid, "files": [image_win_path]}) cdp.val( @@ -430,13 +488,13 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict: new_src = ready[-1]["src"] break if not new_src: - return {"ok": False, "reason": "UPLOAD_TIMEOUT", "count_before": len(before)} + return {"ok": False, "reason": "UPLOAD_TIMEOUT", "count_before": count_before, "delete": delete_result} 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} + return {"ok": False, "reason": "NEW_IMAGE_NOT_FOUND", "new_src": new_src, "delete": delete_result} first = cur[0] cdp.drag(new_rect["x"], new_rect["y"], first["left"] - first["w"] * 0.30, first["y"]) time.sleep(1.2) @@ -449,8 +507,77 @@ def replace_cover(cdp, image_win_path, timeout=90) -> dict: "reason": None if cover_ok else "DRAG_NOT_FIRST", "new_src": new_src, "index": index, - "count_before": len(before), + "count_before": count_before, "count_after": len(after), + "delete": delete_result, + } + + +def _validated_old_cover_backup(old_cover_path): + if not old_cover_path: + return None + path = os.path.abspath(str(old_cover_path)) + if not os.path.exists(path): + return None + return path + + +def _delete_first_cover(cdp, before, timeout=15): + count_before = len(before) + if not before: + return {"ok": False, "reason": "NO_IMAGE", "count_before": 0} + first = before[0] + try: + cdp.send( + "Input.dispatchMouseEvent", + {"type": "mouseMoved", "x": first["x"], "y": first["y"]}, + ) + time.sleep(0.2) + except Exception: + pass + click_result = _json_value( + cdp, + JS_CLICK_FIRST_DELETE, + default={"clicked": False, "reason": "NO_DELETE_BUTTON"}, + ) or {} + if not click_result.get("clicked"): + return { + "ok": False, + "reason": click_result.get("reason") or "NO_DELETE_BUTTON", + "count_before": count_before, + "click": click_result, + } + + confirm_result = None + end = time.time() + timeout + while time.time() < end: + cur = _image_rects(cdp) + if len(cur) < count_before: + return { + "ok": True, + "reason": None, + "count_before": count_before, + "count_after": len(cur), + "click": click_result, + "confirm": confirm_result, + } + confirm_attempt = _json_value( + cdp, + JS_CLICK_DELETE_CONFIRM, + default={"clicked": False, "reason": "NO_CONFIRM_BUTTON"}, + ) or {} + if confirm_attempt.get("clicked"): + confirm_result = confirm_attempt + time.sleep(0.5) + + cur = _image_rects(cdp) + return { + "ok": False, + "reason": "DELETE_TIMEOUT", + "count_before": count_before, + "count_after": len(cur), + "click": click_result, + "confirm": confirm_result, } @@ -491,7 +618,11 @@ def apply_task(account, task, close_success_tab=False) -> dict: if not title_result.get("ok"): return {"committed": False, "error": "标题写入后 value/modelvalue 未同步", "title": title_result} if new_cover_path: - cover_result = replace_cover(cdp, new_cover_path) + cover_result = replace_cover( + cdp, + new_cover_path, + old_cover_path=_get(task, "old_cover_path"), + ) if not cover_result.get("ok"): return {"committed": False, "error": cover_result.get("reason"), "cover": cover_result} update_result = click_update(cdp) diff --git a/docs/02-requirements.md b/docs/02-requirements.md index fe0aa34..de1a181 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -29,7 +29,7 @@ | 生成快捷方式 | 可选:为每账号生成桌面快捷方式,目标带该账号 user-data-dir,供手动打开对应账号 Chrome | P1 | | 加载商品页 | 选定账号,用其已登录 Chrome 打开指定商品详情页 | P0 | | 改标题 | 对该商品按规则修改标题(如去掉结尾若干字 / 指定新标题) | P0 | -| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时先删原封面再传) | P0 | +| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 | | 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 | ### V1 当前目标(多账号 + Excel + AI,5 Tab 流水线) @@ -74,7 +74,7 @@ - **启动并登录**:点击后 Chrome 用对应 user-data-dir 启动并开启调试端口;人工登录一次后,再次启动无需重新登录。 - **加载商品页**:选定账号执行时,能用该账号登录态打开目标商品详情页,标题框与图片管理器渲染就绪。 - **改标题**:写入后标题框 `value` 与 `modelvalue` 均等于目标值(确认页面模型已更新)。 -- **换封面**:上传成功后图片张数 +1(满 9 张时先删第一张再传);目标图被拖到第一位成为封面。 +- **换封面**:上传成功后图片张数 +1(满 9 张时先确认旧封面本地备份存在,再删第一张并上传;备份缺失则拒绝删除);目标图被拖到第一位成为封面。 - **提交更新**:③ 点击「开始更新」后必须弹窗确认本次筛选范围和任务数量;用户确认后才逐条点「更新」,按钮禁用则不点并记录失败原因。 - **安全**:任意失败路径不崩溃、不误删、不在缺少批量确认的情况下提交线上。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 70cb72f..d07b851 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -335,6 +335,7 @@ images//_new. # AI 生成的新封面 - 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。 - 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。 - 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。 +- 满 9 张封面时,T-502 的删除流程必须先确认该任务已有本地旧封面备份:`old_cover_path` 非空且文件存在。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。 - 串行、单条失败继续;每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。 - 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。 @@ -357,11 +358,11 @@ images//_new. # AI 生成的新封面 | 上传输入框 | `.shopee-image-manager__upload input[type=file]`;`DOM.setFileInputFiles` 传 Windows 路径 | | 上传成功 | 张数 +1 且新图 src 为 `susercontent` | | 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` | -| 满 9 张 | 上限 9;换封面先删第一张(`.shopee-image-manager__icon--delete`,确认框待实测)| +| 满 9 张 | 上限 9;T-502 换封面时先确认本地旧封面备份存在,再点第一张删除(`.shopee-image-manager__icon--delete` 或同类 delete 标记)并在可见 dialog/modal/popover 内点删除/确认按钮,随后上传新图并拖到第一位;备份缺失则拒绝删除。已在 9 图测试商品 `29671243750` 上实测不提交流程 | | 更新按钮 | `button.eds-button` 中 `更新`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败 | | 登录检测 | 重定向到登录页或缺 `SPC_ST` → 未登录 | -高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。 +高风险动作(删满 9 张封面、点更新提交、AI 图上线)先在测试商品验证。删满 9 张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。 ## 八、推荐开发顺序 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index d47035a..2fe4665 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -54,6 +54,7 @@ - [ ] `python -m compileall app main.py` 通过(T-000 前仅文档改动不要求)。 - [ ] T-006 完成后,纯逻辑改动有对应 `unittest`,至少覆盖正常路径和一个失败路径。 - [ ] 涉及 CDP 的改动,在测试商品(ITEM_ID 51100639510)上实跑验证。 +- [ ] 涉及满 9 张封面删除时,必须先验证该任务 `old_cover_path` 本地备份存在;备份缺失不得删除线上图片。 - [ ] 涉及 DB 的改动,覆盖 schema 初始化、重复初始化、短事务写入、失败状态写入。 - [ ] 涉及 Excel 的改动,覆盖 source_file_abs/source_sheet/source_row 回写定位。 - [ ] 对得上需求验收标准(如标题 `value`+`modelvalue` 双等于、封面在第一位)。 @@ -87,7 +88,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交) - 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。 - 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。 -- 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证。 +- 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证;删满 9 张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。 - V1 无常驻提交开关;③ 的批量确认弹窗是提交线上前的确认边界。dry-run 属 V2。 ## 9. 拿不准就问 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index fe1ccf3..b113609 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -81,7 +81,7 @@ | T-501 | Tab⑤ 设置 · AI 模型管理 UI(master-detail):模型下拉 + 新增/删除 + 详情(服务商名/类别/api_type/模型ID/网址/密钥打码/连接超时) + 测试连接 | T-005, T-104 | 复用 T-005 后端;name 唯一;类别 text/image;至少各一个;密钥本地明文保存、UI 打码;测试连接可用 | DONE | | T-501b | Tab⑤ 设置 · 角色与生成参数:标题大模型/图片大模型下拉、分辨率(512/1k/2k/4k)、返回超时随分辨率、并发/重试/jpg质量、路径/端口 | T-501 | 角色下拉按类别过滤;分辨率选后返回超时按 resolution_timeouts 自动;持久化 config.json | DONE | | T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | DONE | -| T-502 | 满 9 张封面:删第一张再上传 | T-001 | 实测删除确认框并处理;满 9 张流程正确 | TODO | +| T-502 | 满 9 张封面:删第一张再上传 | T-001 | 已实现备份校验、删第一张、确认弹窗、再上传和拖首位;删除前必须确认该任务已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失则拒绝删除并报错;已在 9 图测试商品 `29671243750` 上实测不提交流程 | DONE | | T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | TODO | | T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | TODO | diff --git a/docs/api.md b/docs/api.md index 58cd1a3..c05de0a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -215,7 +215,9 @@ collect(account, task) -> dict # -> {old_title, old_cover_path} # 应用 change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等 -replace_cover(cdp, image_win_path) -> dict # 上传→等CDN→拖第一位;满9张暂返回 FULL_IMAGE_SLOTS,删除流程留给 T-502 +replace_cover(cdp, image_win_path, old_cover_path=None) -> dict +# 上传→等CDN→拖第一位;满9张时先确认 old_cover_path 非空且文件存在, +# 再点第一张删除和可见确认框;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片 click_update(cdp) -> dict # {clicked, reason};禁用则记失败 apply_task(account, task, close_success_tab=False) -> dict # 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已通过安全开关和批量确认) @@ -231,6 +233,7 @@ apply_task(account, task, close_success_tab=False) -> dict - `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。 - `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。 - ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。 +- T-502 满 9 张封面删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。 ## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP) @@ -448,5 +451,5 @@ set UPDATE=1 && python prototypes/demo.py # 走完点击「更新」提交 ## 待实现时确认 - AI 服务商/模型/计费;图像 image-to-image 能力与合规。 -- 满 9 张删除封面的确认框选择器(需实测)。 +- 满 9 张删除封面已在测试商品 `29671243750` 上实测不提交流程;当前代码只在可见 dialog/modal/popover 内点击删除/确认类按钮,并保留“旧封面备份缺失则拒绝删除”的保护。 - 旧封面下载的图片格式/扩展名处理。 diff --git a/docs/current-state.md b/docs/current-state.md index f1892fd..8e4a0f9 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-06-29 -- 阶段: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 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关。 +- 阶段: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 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 满 9 张封面删除再上传。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取与 Shopee 更新安全默认值,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置与 Shopee 更新安全设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理、角色/生成参数设置和 Shopee 更新安全设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`replace_cover()` 已实现满 9 张时先校验本地旧封面备份,再点第一张删除、可见确认框、上传新图并拖到第一位的代码路径;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取与 Shopee 更新安全默认值,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置与 Shopee 更新安全设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、满 9 张封面备份缺失阻断/删除确认/上传拖首位 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理、角色/生成参数设置和 Shopee 更新安全设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -35,14 +35,14 @@ | `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全设置 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | -| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-501c 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | +| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-501c/T-502 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、满 9 张先校验旧封面备份再删第一张并上传、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | | `app/appconfig.py` | 已有 | T-002/T-501c 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全默认值;拒绝敏感字段写入 | | `app/ai.py` | 已有 | T-301/T-303 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、停止取消未开始项 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `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/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | -| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | @@ -60,8 +60,7 @@ 任务状态以 [`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(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-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)。 -- 正在进行:无。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(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-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(满 9 张封面删除再上传)。 - 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟验收)**。仅用测试商品,默认先只测标题更新,必须经过 ⑤ 安全开关和 ③ 二次确认。 ## 当前已知限制 @@ -70,6 +69,7 @@ - T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。 - T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;真实 Shopee 更新冒烟仍未执行,下一步 T-404 只允许在测试商品范围内做单条验收,默认先只测标题更新,封面更新作为可选子项。 +- T-502 满 9 张封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;删除第一张前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。 ## 当前可运行内容 diff --git a/docs/routes.md b/docs/routes.md index 638e50d..f0bf2bb 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -105,6 +105,7 @@ - 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断。 - 安全开关通过后,弹窗展示本次筛选条件、任务数量、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 - 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。 +- 满 9 张封面时,删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。 - 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。 - 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。 - 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;失败任务和用户原本打开的 tab 不关闭。 diff --git a/progress.md b/progress.md index cbcd995..8b5d27a 100644 --- a/progress.md +++ b/progress.md @@ -605,3 +605,31 @@ - 测试:`tests/test_gui.py` 覆盖安全设置默认值、保存到 `config.json`、⑤保存后③立即读取共享安全配置、③安全拦截、最大条数/测试商品 ID 校验、确认后 worker 接收 `close_success_tab`;`tests/test_editor_login.py` 覆盖成功提交后关闭本轮新开 tab、失败时不关闭。 - 文档:`docs/06-tasks.md` 将 T-501c 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-404。 - 验证:`python -m unittest discover -s tests -p test_gui.py` 通过(42 tests);`python -m unittest discover -s tests -p test_editor_login.py` 通过(11 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(100 tests)。未执行真实 Shopee 提交,T-404 仍需在测试商品上手动冒烟。 + +## 【2026-06-29】T-502 满 9 张封面删除前备份策略 + +- 状态:文档决策更新,代码未实现。 +- 决策:满 9 张封面需要删除第一张再上传时,不允许盲删线上图片;删除前必须确认该任务已有本地旧封面备份,即 `old_cover_path` 非空且文件存在。 +- 失败语义:备份缺失时直接阻断该条封面更新,返回明确错误,要求先回到①采集旧封面或修复本地备份;不进入删除确认框、不删除线上第一张图。 +- 实测边界:删除确认框选择器仍需在真实 Shopee 测试商品页面探查;真实保存线上变更仍需经过 ⑤ 安全开关和 ③ 二次确认。 +- 变更:同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/05-coding-rules.md`、`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`。 +- 验证:仅文档更新,未运行测试。 + +## 【2026-06-29】T-502 满 9 张封面删除代码路径 + +- 状态:BLOCKED(代码路径和 mock 单测已完成;真实 Shopee 9 图商品删除确认框仍需实测)。 +- 变更:`app/editor.py` 的 `replace_cover()` 支持 `old_cover_path`,满 9 张时先校验本地旧封面备份存在;缺失时返回 `OLD_COVER_BACKUP_MISSING`,不点击删除。备份存在时点击第一张图片的删除按钮,在可见 dialog/modal/popover 中点击删除/确认类按钮,确认图片数减少后再上传新图并拖到第一位。 +- 执行链路:`apply_task()` 会把任务的 `old_cover_path` 传给 `replace_cover()`,确保③真实封面更新继承①采集备份保护。 +- 测试:`tests/test_editor_login.py` 新增满 9 张备份缺失阻断、删除确认后上传并拖首位、删除按钮缺失报错、`apply_task()` 传递旧封面备份路径的覆盖。 +- 文档:`docs/06-tasks.md` 将 T-502 标为 BLOCKED;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/current-state.md`。 +- 验证:`python -m unittest discover -s tests -p test_editor_login.py` 通过(15 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(104 tests)。未执行真实 Shopee 删除/提交。 + +## 【2026-06-29】T-502 真实 9 图商品不提交实测 + +- 状态:DONE +- 商品:`29671243750`,URL `https://seller.shopee.tw/portal/product/29671243750?pageEntry=product_list&ignore-html-cache=1`,账号别名 `papa`,CDP 端口 `9224`。 +- 前置:本地旧封面备份存在:`images/papa_f722f20f/29671243750_old.jpg`;商品编辑页初始图片数为 9。 +- 删除确认框实测:新开测试 tab,执行 `_delete_first_cover()`,结果 `ok=True`,图片数 9 → 8,测试 tab 已关闭,未点击「更新」。 +- 满 9 张完整换封面实测:新开测试 tab,执行 `replace_cover(..., image=1_TY030.jpg, old_cover_path=备份路径)`,结果 `ok=True`,删除后上传新图,图片数回到 9,新图 `index=0` 且为第一位,测试 tab 已关闭。 +- 安全边界:全程只操作新开的测试 tab,未点击「更新」保存线上;线上真实保存仍需走 ⑤ 安全开关和 ③ 二次确认。 +- 文档:`docs/06-tasks.md` 将 T-502 标为 DONE;同步 `docs/04-architecture.md`、`docs/api.md`、`docs/current-state.md`。 diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index 4d52b48..c34824a 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -1,4 +1,5 @@ import os +import json import sys import unittest from unittest import mock @@ -51,6 +52,83 @@ class FakeProductCDP: self.closed = True +def cover_rects(count, prefix="old", start=0): + return [ + { + "i": index, + "x": 100 + index * 10, + "y": 100, + "left": 60 + index * 10, + "top": 60, + "w": 80, + "h": 80, + "src": f"https://susercontent.com/{prefix}-{start + index}.jpg", + } + for index in range(count) + ] + + +class FakeCoverCDP: + def __init__(self, count=9, delete_click=True, confirm_click=True): + self.initial = cover_rects(count) + self.after_delete = cover_rects(max(0, count - 1), prefix="kept", start=1) + self.new_rect = { + "i": max(0, count - 1), + "x": 300, + "y": 100, + "left": 260, + "top": 60, + "w": 80, + "h": 80, + "src": "https://susercontent.com/new-cover.jpg", + } + self.delete_click = delete_click + self.confirm_click = confirm_click + self.delete_clicked = False + self.confirm_clicked = False + self.uploaded = False + self.dragged = False + self.sent = [] + self.drag_calls = [] + + def _rects(self): + if self.uploaded and self.dragged: + return [self.new_rect] + self.after_delete + if self.uploaded: + return self.after_delete + [self.new_rect] + if self.confirm_clicked: + return self.after_delete + return self.initial + + def val(self, expr): + if expr == editor.JS_RECTS: + return json.dumps(self._rects()) + if expr == editor.JS_CLICK_FIRST_DELETE: + if not self.delete_click: + return json.dumps({"clicked": False, "reason": "NO_DELETE_BUTTON"}) + self.delete_clicked = True + return json.dumps({"clicked": True, "reason": None}) + if expr == editor.JS_CLICK_DELETE_CONFIRM: + if not self.confirm_click: + return json.dumps({"clicked": False, "reason": "NO_CONFIRM_BUTTON"}) + self.confirm_clicked = True + return json.dumps({"clicked": True, "reason": None, "text": "刪除"}) + return True + + def object_id(self, expr): + return "upload-input" + + def send(self, method, params=None): + self.sent.append((method, params or {})) + if method == "DOM.setFileInputFiles": + self.uploaded = True + return {} + + def drag(self, *args, **kwargs): + self.dragged = True + self.drag_calls.append((args, kwargs)) + + def cookie(name, domain=".shopee.tw"): return {"name": name, "domain": domain} @@ -293,6 +371,84 @@ class EditorLoginTests(unittest.TestCase): self.assertTrue(cdp.closed) close_tab.assert_not_called() + def test_apply_task_passes_old_cover_backup_to_replace_cover(self): + cdp = FakeProductCDP("ws-new") + + with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( + "app.editor.replace_cover", + return_value={"ok": True, "reason": None}, + ) 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", + "old_cover_path": "old.jpg", + }, + ) + + self.assertTrue(result["committed"]) + replace_cover.assert_called_once_with( + cdp, + "new.jpg", + old_cover_path="old.jpg", + ) + + def test_replace_cover_full_slots_requires_old_cover_backup(self): + cdp = FakeCoverCDP(count=9) + missing_backup = os.path.join(os.path.dirname(__file__), "missing-old-cover.jpg") + + with mock.patch("app.editor.time.sleep"): + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=missing_backup, + ) + + self.assertFalse(result["ok"]) + self.assertEqual("OLD_COVER_BACKUP_MISSING", result["reason"]) + self.assertFalse(cdp.delete_clicked) + self.assertFalse(cdp.uploaded) + + def test_replace_cover_full_slots_deletes_first_then_uploads_and_drags(self): + cdp = FakeCoverCDP(count=9) + + with mock.patch("app.editor.time.sleep"): + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=__file__, + timeout=1, + ) + + self.assertTrue(result["ok"]) + self.assertTrue(cdp.delete_clicked) + self.assertTrue(cdp.confirm_clicked) + self.assertTrue(cdp.uploaded) + self.assertTrue(cdp.dragged) + self.assertEqual(9, result["count_before"]) + self.assertEqual(9, result["count_after"]) + self.assertEqual(8, result["delete"]["count_after"]) + self.assertEqual("https://susercontent.com/new-cover.jpg", result["new_src"]) + + def test_replace_cover_full_slots_reports_missing_delete_button(self): + cdp = FakeCoverCDP(count=9, delete_click=False) + + with mock.patch("app.editor.time.sleep"): + result = editor.replace_cover( + cdp, + __file__, + old_cover_path=__file__, + timeout=1, + ) + + self.assertFalse(result["ok"]) + self.assertEqual("NO_DELETE_BUTTON", result["reason"]) + self.assertFalse(cdp.uploaded) + if __name__ == "__main__": unittest.main()