feat: 完成T-504更新执行增强
- 新增 dry-run、多账号并行和最大并行账号数设置 - 增加 run_logs/run_log_events 运行日志表及读写接口 - ApplyWorker 支持 dry-run 预览、按账号并行、端口冲突阻断和日志展示 - 补充 DB/GUI 单元测试并同步任务、架构、API、路由和当前状态文档 验证: python -m compileall app main.py tests; python -m unittest discover -s tests
This commit is contained in:
@@ -139,6 +139,54 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_run_logs_and_events_are_persisted(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
db.init_db(db_path)
|
||||
|
||||
run_id = db.create_run_log(
|
||||
"apply",
|
||||
dry_run=True,
|
||||
total=2,
|
||||
options={"api_key": "secret", "mode": "preview"},
|
||||
path=db_path,
|
||||
)
|
||||
db.add_run_log_event(
|
||||
run_id,
|
||||
"dry-run 预览任务",
|
||||
task_id=7,
|
||||
alias="alias",
|
||||
item_id="51100639510",
|
||||
path=db_path,
|
||||
)
|
||||
db.finish_run_log(
|
||||
run_id,
|
||||
status="done",
|
||||
done=2,
|
||||
success_count=1,
|
||||
skipped_count=1,
|
||||
failed_count=0,
|
||||
summary_json={"password": "secret", "done": 2},
|
||||
path=db_path,
|
||||
)
|
||||
|
||||
run = db.list_run_logs(path=db_path)[0]
|
||||
self.assertEqual(run_id, run.id)
|
||||
self.assertEqual("apply", run.run_type)
|
||||
self.assertEqual(1, run.dry_run)
|
||||
self.assertEqual("done", run.status)
|
||||
self.assertEqual(2, run.done)
|
||||
self.assertEqual("***", run.options["api_key"])
|
||||
self.assertEqual("***", run.summary["password"])
|
||||
|
||||
events = db.list_run_log_events(run_id, path=db_path)
|
||||
self.assertEqual(1, len(events))
|
||||
self.assertEqual("alias", events[0].alias)
|
||||
self.assertEqual("51100639510", events[0].item_id)
|
||||
self.assertIn("dry-run", events[0].message)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+294
-16
@@ -1,6 +1,7 @@
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from unittest import mock
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
@@ -154,6 +155,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertFalse(tab.allow_cover_update_checkbox.isChecked())
|
||||
self.assertEqual(1, tab.max_items_per_run_spin.value())
|
||||
self.assertFalse(tab.close_success_tab_checkbox.isChecked())
|
||||
self.assertFalse(tab.dry_run_checkbox.isChecked())
|
||||
self.assertFalse(tab.parallel_accounts_checkbox.isChecked())
|
||||
self.assertEqual(2, tab.max_parallel_accounts_spin.value())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
@@ -352,6 +356,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
tab.allow_cover_update_checkbox.setChecked(True)
|
||||
tab.max_items_per_run_spin.setValue(2)
|
||||
tab.close_success_tab_checkbox.setChecked(True)
|
||||
tab.dry_run_checkbox.setChecked(True)
|
||||
tab.parallel_accounts_checkbox.setChecked(True)
|
||||
tab.max_parallel_accounts_spin.setValue(3)
|
||||
|
||||
tab.save_app_settings()
|
||||
|
||||
@@ -377,6 +384,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
"allow_cover_update": True,
|
||||
"max_items_per_run": 2,
|
||||
"close_success_tab": True,
|
||||
"dry_run": True,
|
||||
"parallel_accounts": True,
|
||||
"max_parallel_accounts": 3,
|
||||
},
|
||||
saved["shopee_update"],
|
||||
)
|
||||
@@ -403,6 +413,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
settings_tab.allow_cover_update_checkbox.setChecked(True)
|
||||
settings_tab.max_items_per_run_spin.setValue(3)
|
||||
settings_tab.close_success_tab_checkbox.setChecked(True)
|
||||
settings_tab.dry_run_checkbox.setChecked(True)
|
||||
settings_tab.parallel_accounts_checkbox.setChecked(True)
|
||||
settings_tab.max_parallel_accounts_spin.setValue(4)
|
||||
settings_tab.save_app_settings()
|
||||
|
||||
safety_cfg = apply_tab._shopee_update_config()
|
||||
@@ -411,6 +424,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertTrue(safety_cfg["allow_cover_update"])
|
||||
self.assertEqual(3, safety_cfg["max_items_per_run"])
|
||||
self.assertTrue(safety_cfg["close_success_tab"])
|
||||
self.assertTrue(safety_cfg["dry_run"])
|
||||
self.assertTrue(safety_cfg["parallel_accounts"])
|
||||
self.assertEqual(4, safety_cfg["max_parallel_accounts"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
@@ -840,6 +856,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertIs(tab.apply_thread, fake_thread)
|
||||
self.assertTrue(fake_thread.started)
|
||||
self.assertTrue(tab.apply_worker.close_success_tab)
|
||||
self.assertFalse(tab.apply_worker.dry_run)
|
||||
self.assertFalse(tab.apply_worker.parallel_accounts)
|
||||
self.assertFalse(tab.start_update_button.isEnabled())
|
||||
self.assertTrue(tab.stop_update_button.isEnabled())
|
||||
self.assertEqual("开始更新:1 条", statuses[-1])
|
||||
@@ -850,6 +868,73 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_tab_dry_run_starts_without_real_submit_switch(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["shopee_update"] = {
|
||||
"test_item_id": "51100639510",
|
||||
"allow_real_submit": False,
|
||||
"allow_cover_update": False,
|
||||
"max_items_per_run": 1,
|
||||
"close_success_tab": False,
|
||||
"dry_run": True,
|
||||
"parallel_accounts": True,
|
||||
"max_parallel_accounts": 2,
|
||||
}
|
||||
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",
|
||||
}
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||
statuses = []
|
||||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
class FakeSignal:
|
||||
def __init__(self):
|
||||
self.callbacks = []
|
||||
|
||||
def connect(self, callback):
|
||||
self.callbacks.append(callback)
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self):
|
||||
self.finished = FakeSignal()
|
||||
self.started = False
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
fake_thread = FakeThread()
|
||||
with mock.patch(
|
||||
"app.gui.QMessageBox.question",
|
||||
return_value=gui.QMessageBox.Yes,
|
||||
), mock.patch("app.gui.QMessageBox.warning") as warning, \
|
||||
mock.patch("app.gui.run_worker", return_value=fake_thread):
|
||||
tab.start_update()
|
||||
|
||||
warning.assert_not_called()
|
||||
self.assertTrue(tab.apply_worker.dry_run)
|
||||
self.assertTrue(tab.apply_worker.parallel_accounts)
|
||||
self.assertEqual(2, tab.apply_worker.max_parallel_accounts)
|
||||
self.assertEqual("开始 dry-run 预览:1 条", statuses[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_tab_blocks_update_when_real_submit_switch_is_off(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
@@ -1022,22 +1107,22 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assertEqual(["alias-a", "alias-b"], applied_aliases)
|
||||
self.assertEqual([True, True], close_flags)
|
||||
self.assertEqual(
|
||||
{
|
||||
"ok": False,
|
||||
"total": 3,
|
||||
"done": 3,
|
||||
"applied": 1,
|
||||
"skipped": 1,
|
||||
"failed": 1,
|
||||
"batch_ids": [batch_id],
|
||||
},
|
||||
summary,
|
||||
)
|
||||
self.assertEqual(
|
||||
{"done": 3, "total": 3, "applied": 1, "skipped": 1, "failed": 1},
|
||||
progress[-1],
|
||||
)
|
||||
self.assertFalse(summary["ok"])
|
||||
self.assertEqual(3, summary["total"])
|
||||
self.assertEqual(3, summary["done"])
|
||||
self.assertEqual(1, summary["applied"])
|
||||
self.assertEqual(1, summary["skipped"])
|
||||
self.assertEqual(1, summary["failed"])
|
||||
self.assertEqual([batch_id], summary["batch_ids"])
|
||||
self.assertFalse(summary["dry_run"])
|
||||
self.assertFalse(summary["parallel_accounts"])
|
||||
self.assertIsNotNone(summary["run_id"])
|
||||
self.assertEqual(3, progress[-1]["done"])
|
||||
self.assertEqual(3, progress[-1]["total"])
|
||||
self.assertEqual(1, progress[-1]["applied"])
|
||||
self.assertEqual(1, progress[-1]["skipped"])
|
||||
self.assertEqual(1, progress[-1]["failed"])
|
||||
self.assertFalse(progress[-1]["dry_run"])
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
by_alias = {task.alias: task for task in updated}
|
||||
self.assertEqual("applied", by_alias["alias-a"].stage)
|
||||
@@ -1051,6 +1136,199 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual("别名未匹配账号", by_alias["missing"].last_error)
|
||||
self.assertTrue(any(fields.get("stage") == "applied" for _task_id, fields in rows))
|
||||
self.assertTrue(any(fields.get("status") == "failed" for _task_id, fields in rows))
|
||||
run_logs = db.list_run_logs(run_type="apply", path=cfg["db_path"])
|
||||
self.assertEqual(1, len(run_logs))
|
||||
self.assertEqual("done", run_logs[0].status)
|
||||
self.assertGreaterEqual(
|
||||
len(db.list_run_log_events(run_logs[0].id, path=cfg["db_path"])),
|
||||
3,
|
||||
)
|
||||
|
||||
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)
|
||||
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": "missing",
|
||||
"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"])
|
||||
logs = []
|
||||
|
||||
with mock.patch("app.gui.chrome.is_running") as is_running, \
|
||||
mock.patch("app.gui.accounts.detect_login") as detect_login, \
|
||||
mock.patch("app.gui.editor.apply_task") as apply_task:
|
||||
worker = ApplyWorker(
|
||||
tasks,
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
dry_run=True,
|
||||
parallel_accounts=True,
|
||||
max_parallel_accounts=2,
|
||||
)
|
||||
worker.log.connect(logs.append)
|
||||
summary = worker.execute()
|
||||
|
||||
is_running.assert_not_called()
|
||||
detect_login.assert_not_called()
|
||||
apply_task.assert_not_called()
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertTrue(summary["dry_run"])
|
||||
self.assertEqual(2, summary["done"])
|
||||
self.assertEqual(1, summary["applied"])
|
||||
self.assertEqual(1, summary["skipped"])
|
||||
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
self.assertTrue(all(task.stage == "generated" for task in unchanged))
|
||||
self.assertTrue(all(task.status == "success" for task in unchanged))
|
||||
self.assertTrue(any("dry-run" in line for line in logs))
|
||||
run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0]
|
||||
self.assertEqual(1, run_log.dry_run)
|
||||
self.assertEqual("done", run_log.status)
|
||||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||||
self.assertTrue(any("将更新" in event.message for event in events))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_worker_parallel_accounts_runs_different_accounts_concurrently(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)
|
||||
accounts.create_account("副店", "alias-b", debug_port=9223, 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-b",
|
||||
"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"])
|
||||
started = {"alias-a": threading.Event(), "alias-b": threading.Event()}
|
||||
thread_names = set()
|
||||
|
||||
def fake_apply(account, task, close_success_tab=False):
|
||||
thread_names.add(threading.current_thread().name)
|
||||
started[account.alias].set()
|
||||
other = "alias-b" if account.alias == "alias-a" else "alias-a"
|
||||
self.assertTrue(started[other].wait(2))
|
||||
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,
|
||||
parallel_accounts=True,
|
||||
max_parallel_accounts=2,
|
||||
).execute()
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertTrue(summary["parallel_accounts"])
|
||||
self.assertEqual(2, summary["applied"])
|
||||
self.assertGreaterEqual(len(thread_names), 2)
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
self.assertTrue(all(task.stage == "applied" for task in updated))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_worker_blocks_real_update_when_required_accounts_share_debug_port(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
db.init_db(cfg["db_path"])
|
||||
db.add_account("主店", "alias-a", "seller.shopee.tw", 9222, path=cfg["db_path"])
|
||||
db.add_account("副店", "alias-b", "seller.shopee.tw", 9222, path=cfg["db_path"])
|
||||
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-b",
|
||||
"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"])
|
||||
|
||||
with mock.patch("app.gui.chrome.is_running") as is_running, \
|
||||
mock.patch("app.gui.editor.apply_task") as apply_task:
|
||||
summary = ApplyWorker(
|
||||
tasks,
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
parallel_accounts=True,
|
||||
max_parallel_accounts=2,
|
||||
).execute()
|
||||
|
||||
self.assertTrue(summary["blocked"])
|
||||
self.assertEqual("DUPLICATE_DEBUG_PORT", summary["reason"])
|
||||
self.assertEqual(9222, summary["duplicate_ports"][0]["debug_port"])
|
||||
self.assertEqual(["alias-a", "alias-b"], summary["duplicate_ports"][0]["aliases"])
|
||||
is_running.assert_not_called()
|
||||
apply_task.assert_not_called()
|
||||
run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0]
|
||||
self.assertEqual("blocked", run_log.status)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user