feat(product-suite): add global generation history
This commit is contained in:
@@ -1028,6 +1028,160 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_global_generation_rounds_filter_paginate_and_exclude_deleted_projects(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
db.init_db(db_path)
|
||||
first_project = image_studio.create_or_get_project(
|
||||
account_alias="main-shop",
|
||||
account_name="主店",
|
||||
account_slug="main-shop",
|
||||
item_id="51100639510",
|
||||
path=db_path,
|
||||
)
|
||||
second_project = image_studio.create_or_get_project(
|
||||
account_alias="second-shop",
|
||||
account_name="副店",
|
||||
account_slug="second-shop",
|
||||
item_id="51100639511",
|
||||
path=db_path,
|
||||
)
|
||||
deleted_project = image_studio.create_or_get_project(
|
||||
account_alias="deleted-shop",
|
||||
account_slug="deleted-shop",
|
||||
item_id="51100639512",
|
||||
path=db_path,
|
||||
)
|
||||
first_source = image_studio.add_asset(
|
||||
first_project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
path=db_path,
|
||||
)
|
||||
second_source = image_studio.add_asset(
|
||||
second_project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
path=db_path,
|
||||
)
|
||||
deleted_source = image_studio.add_asset(
|
||||
deleted_project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
path=db_path,
|
||||
)
|
||||
first = image_studio.create_job(
|
||||
first_project.id,
|
||||
source_asset_id=first_source.id,
|
||||
task_key="global-first-slot",
|
||||
generation_round_key="first-round",
|
||||
generation_slot_index=0,
|
||||
path=db_path,
|
||||
)
|
||||
image_studio.update_job_status(first.id, "failed", path=db_path)
|
||||
retry = image_studio.create_job(
|
||||
first_project.id,
|
||||
source_asset_id=first_source.id,
|
||||
task_key="global-first-retry",
|
||||
generation_round_key="first-round",
|
||||
generation_slot_index=0,
|
||||
path=db_path,
|
||||
)
|
||||
image_studio.update_job_status(retry.id, "succeeded", path=db_path)
|
||||
second = image_studio.create_job(
|
||||
second_project.id,
|
||||
source_asset_id=second_source.id,
|
||||
task_key="global-second-round",
|
||||
generation_round_key="second-round",
|
||||
generation_slot_index=0,
|
||||
path=db_path,
|
||||
)
|
||||
image_studio.update_job_status(second.id, "succeeded", path=db_path)
|
||||
deleted = image_studio.create_job(
|
||||
deleted_project.id,
|
||||
source_asset_id=deleted_source.id,
|
||||
task_key="global-deleted-round",
|
||||
generation_round_key="deleted-round",
|
||||
generation_slot_index=0,
|
||||
path=db_path,
|
||||
)
|
||||
image_studio.update_job_status(deleted.id, "succeeded", path=db_path)
|
||||
legacy = image_studio.create_job(
|
||||
first_project.id,
|
||||
source_asset_id=first_source.id,
|
||||
task_key="global-legacy-history",
|
||||
path=db_path,
|
||||
)
|
||||
image_studio.update_job_status(legacy.id, "cancelled", path=db_path)
|
||||
image_studio.set_current_generation_round(
|
||||
first_project.id,
|
||||
"first-round",
|
||||
path=db_path,
|
||||
)
|
||||
image_studio.soft_delete_project(
|
||||
deleted_project.id,
|
||||
reason="测试软删除",
|
||||
path=db_path,
|
||||
)
|
||||
|
||||
rounds = image_studio.list_global_generation_rounds(path=db_path)
|
||||
self.assertEqual(
|
||||
{first_project.id, second_project.id},
|
||||
{round_.project_id for round_ in rounds},
|
||||
)
|
||||
self.assertIsNone(rounds[0].generation_round_key)
|
||||
self.assertTrue(rounds[0].is_legacy)
|
||||
self.assertEqual(first_project.id, rounds[0].project_id)
|
||||
self.assertEqual(second_project.id, rounds[1].project_id)
|
||||
first_round = next(
|
||||
round_
|
||||
for round_ in rounds
|
||||
if (
|
||||
round_.project_id == first_project.id
|
||||
and round_.generation_round_key == "first-round"
|
||||
)
|
||||
)
|
||||
self.assertEqual("first-round", first_round.generation_round_key)
|
||||
self.assertEqual("主店", first_round.account_name)
|
||||
self.assertEqual(1, first_round.job_count)
|
||||
self.assertEqual(1, first_round.slot_count)
|
||||
self.assertEqual(1, first_round.retry_count)
|
||||
self.assertEqual(1, first_round.succeeded_count)
|
||||
self.assertEqual(0, first_round.failed_count)
|
||||
self.assertTrue(first_round.is_current)
|
||||
|
||||
self.assertEqual(
|
||||
[first_project.id, first_project.id],
|
||||
[
|
||||
round_.project_id
|
||||
for round_ in image_studio.list_global_generation_rounds(
|
||||
account_query="主店",
|
||||
path=db_path,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[second_project.id],
|
||||
[
|
||||
round_.project_id
|
||||
for round_ in image_studio.list_global_generation_rounds(
|
||||
item_query="51100639511",
|
||||
path=db_path,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[first_project.id, first_project.id],
|
||||
[
|
||||
round_.project_id
|
||||
for round_ in image_studio.list_global_generation_rounds(
|
||||
project_id=first_project.id,
|
||||
path=db_path,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(1, len(image_studio.list_global_generation_rounds(limit=1, path=db_path)))
|
||||
self.assertEqual(1, len(image_studio.list_global_generation_rounds(limit=1, offset=1, path=db_path)))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_selections_are_consecutive_unique_and_replaceable(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
|
||||
@@ -53,6 +53,122 @@ class ImageStudioExportTests(TempDirMixin, unittest.TestCase):
|
||||
image_studio.replace_selections(project.id, "detail", [detail.id], path=cfg["db_path"])
|
||||
return cfg, project, main, detail
|
||||
|
||||
def test_export_generation_round_copies_successes_and_uses_new_directory_on_repeat(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg, project, _main, _detail = self._project_with_assets(temp_dir)
|
||||
source = image_studio.add_asset(
|
||||
project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
first_source = self._write_image(os.path.join(temp_dir, "round-first.png"))
|
||||
first_asset = image_studio.add_asset(
|
||||
project.id,
|
||||
"generated_main",
|
||||
local_path=first_source,
|
||||
parent_asset_id=source.id,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
first_job = image_studio.create_job(
|
||||
project.id,
|
||||
source_asset_id=source.id,
|
||||
job_type="白底图",
|
||||
task_key="round-export-first",
|
||||
generation_round_key="round-export",
|
||||
generation_slot_index=0,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
image_studio.update_job_status(
|
||||
first_job.id,
|
||||
"succeeded",
|
||||
output_asset_id=first_asset.id,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
missing_asset = image_studio.add_asset(
|
||||
project.id,
|
||||
"generated_main",
|
||||
local_path=os.path.join(temp_dir, "missing-round.png"),
|
||||
parent_asset_id=source.id,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
missing_job = image_studio.create_job(
|
||||
project.id,
|
||||
source_asset_id=source.id,
|
||||
job_type="场景图",
|
||||
task_key="round-export-missing",
|
||||
generation_round_key="round-export",
|
||||
generation_slot_index=1,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
image_studio.update_job_status(
|
||||
missing_job.id,
|
||||
"succeeded",
|
||||
output_asset_id=missing_asset.id,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
second_source = self._write_image(
|
||||
os.path.join(temp_dir, "round-second.png"),
|
||||
color=(220, 80, 40, 255),
|
||||
)
|
||||
second_asset = image_studio.add_asset(
|
||||
project.id,
|
||||
"generated_main",
|
||||
local_path=second_source,
|
||||
parent_asset_id=source.id,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
second_job = image_studio.create_job(
|
||||
project.id,
|
||||
source_asset_id=source.id,
|
||||
job_type="卖点图",
|
||||
task_key="round-export-second",
|
||||
generation_round_key="round-export",
|
||||
generation_slot_index=2,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
image_studio.update_job_status(
|
||||
second_job.id,
|
||||
"succeeded",
|
||||
output_asset_id=second_asset.id,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
parent = os.path.join(temp_dir, "exports")
|
||||
os.makedirs(parent)
|
||||
|
||||
result = image_studio_export.export_generation_round(
|
||||
project.id,
|
||||
"round-export",
|
||||
parent,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
|
||||
self.assertEqual(2, len(result.files))
|
||||
self.assertEqual(1, result.skipped_count)
|
||||
self.assertFalse(result.cancelled)
|
||||
self.assertTrue(os.path.isdir(result.target_dir))
|
||||
self.assertEqual(
|
||||
["01_白底图.png", "03_卖点图.png"],
|
||||
sorted(os.path.basename(item.output_path) for item in result.files),
|
||||
)
|
||||
self.assertTrue(os.path.isfile(first_source))
|
||||
self.assertTrue(os.path.isfile(second_source))
|
||||
with open(first_source, "rb") as source_file, open(
|
||||
result.files[0].output_path,
|
||||
"rb",
|
||||
) as exported_file:
|
||||
self.assertEqual(source_file.read(), exported_file.read())
|
||||
|
||||
second_result = image_studio_export.export_generation_round(
|
||||
project.id,
|
||||
"round-export",
|
||||
parent,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
self.assertNotEqual(result.target_dir, second_result.target_dir)
|
||||
self.assertTrue(second_result.target_dir.endswith("_2"))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_export_partial_selection_outputs_ordered_jpegs(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg, project, _main, _detail = self._project_with_assets(temp_dir)
|
||||
|
||||
@@ -25,9 +25,13 @@ from app.gui.tabs.product_suite import (
|
||||
ORIGINAL_CHECK_STATE_ROLE,
|
||||
ProductOriginalDelegate,
|
||||
ProductOriginalList,
|
||||
ProductSuiteGlobalHistoryDialog,
|
||||
ProductSuiteHistoryDialog,
|
||||
ProductSuitePreviewDialog,
|
||||
ProductSuiteRoundPreviewDialog,
|
||||
ProductSuiteTab,
|
||||
SuiteGlobalHistoryRoundRow,
|
||||
SuiteGlobalHistoryThumbnail,
|
||||
SuiteHistoryImageCard,
|
||||
SuiteResultCard,
|
||||
)
|
||||
@@ -1976,7 +1980,103 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_history_button_reuses_project_dialog_and_closes_with_task(self):
|
||||
def test_global_history_dialog_lists_filters_and_previews_round_images(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
config = self._config(temp_dir)
|
||||
project, sources = self._create_project_with_assets(temp_dir, config, 1)
|
||||
source = sources[0]
|
||||
other_project = image_studio.create_or_get_project(
|
||||
account_alias="other-shop",
|
||||
account_name="副店",
|
||||
account_slug="other-shop",
|
||||
item_id="51100639511",
|
||||
path=config["db_path"],
|
||||
)
|
||||
other_source = image_studio.add_asset(
|
||||
other_project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
path=config["db_path"],
|
||||
)
|
||||
for index in range(7):
|
||||
image_path = os.path.join(temp_dir, "global-%d.png" % index)
|
||||
self._write_image(image_path)
|
||||
self._create_history_job(
|
||||
project,
|
||||
source,
|
||||
config["db_path"],
|
||||
round_key="main-round",
|
||||
slot_index=index,
|
||||
local_path=image_path,
|
||||
job_type="场景图",
|
||||
)
|
||||
other_path = os.path.join(temp_dir, "other-global.png")
|
||||
self._write_image(other_path)
|
||||
self._create_history_job(
|
||||
other_project,
|
||||
other_source,
|
||||
config["db_path"],
|
||||
round_key="other-round",
|
||||
slot_index=0,
|
||||
local_path=other_path,
|
||||
job_type="卖点图",
|
||||
)
|
||||
image_studio.set_current_generation_round(
|
||||
project.id,
|
||||
"main-round",
|
||||
path=config["db_path"],
|
||||
)
|
||||
|
||||
dialog = ProductSuiteGlobalHistoryDialog(
|
||||
current_project_id=project.id,
|
||||
db_path=config["db_path"],
|
||||
)
|
||||
dialog.show()
|
||||
self.app.processEvents()
|
||||
|
||||
self.assertEqual(2, dialog._round_count)
|
||||
rows = dialog.findChildren(SuiteGlobalHistoryRoundRow)
|
||||
main_row = next(
|
||||
row for row in rows if row.round_info.project_id == project.id
|
||||
)
|
||||
self.assertEqual(5, len(main_row.findChildren(SuiteGlobalHistoryThumbnail)))
|
||||
self.assertTrue(
|
||||
any(
|
||||
label.text() == "+2"
|
||||
for label in main_row.findChildren(QLabel)
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
any(label.text() == "当前" for label in main_row.findChildren(QLabel))
|
||||
)
|
||||
with mock.patch.object(
|
||||
ProductSuiteRoundPreviewDialog,
|
||||
"exec",
|
||||
return_value=0,
|
||||
) as preview:
|
||||
dialog._preview_round(main_row, 1)
|
||||
preview.assert_called_once_with()
|
||||
|
||||
dialog.account_filter_edit.setText("副店")
|
||||
dialog.refresh_history()
|
||||
self.app.processEvents()
|
||||
self.assertEqual(1, dialog._round_count)
|
||||
self.assertEqual(
|
||||
[other_project.id],
|
||||
[row.round_info.project_id for row in dialog._history_rows],
|
||||
)
|
||||
|
||||
dialog.account_filter_edit.clear()
|
||||
dialog.current_project_checkbox.setChecked(True)
|
||||
self.app.processEvents()
|
||||
self.assertEqual(1, dialog._round_count)
|
||||
self.assertEqual(
|
||||
[project.id],
|
||||
[row.round_info.project_id for row in dialog._history_rows],
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_history_button_reuses_global_dialog_and_keeps_it_when_task_closes(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
config = self._config(temp_dir)
|
||||
project, sources = self._create_project_with_assets(temp_dir, config, 1)
|
||||
@@ -2014,18 +2114,20 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
||||
tab.open_history_dialog()
|
||||
self.app.processEvents()
|
||||
dialog = tab._history_dialog
|
||||
self.assertIsInstance(dialog, ProductSuiteHistoryDialog)
|
||||
self.assertIsInstance(dialog, ProductSuiteGlobalHistoryDialog)
|
||||
self.assertEqual(project.id, dialog.current_project_id)
|
||||
self.assertTrue(dialog.isVisible())
|
||||
|
||||
tab.open_history_dialog()
|
||||
self.assertIs(dialog, tab._history_dialog)
|
||||
tab.close_task(0)
|
||||
self.app.processEvents()
|
||||
self.assertIsNone(tab._history_dialog)
|
||||
self.assertIs(dialog, tab._history_dialog)
|
||||
self.assertTrue(dialog.isVisible())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_history_button_explains_empty_project_without_opening_dialog(self):
|
||||
def test_history_button_opens_global_dialog_for_empty_current_project(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
config = self._config(temp_dir)
|
||||
project, _ = self._create_project_with_assets(temp_dir, config, 0)
|
||||
@@ -2036,13 +2138,18 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
||||
state.item_id = project.item_id
|
||||
state.project_id = project.id
|
||||
state.project_binding_state = project.binding_state
|
||||
messages = []
|
||||
tab._message = lambda title, message, **kwargs: messages.append((title, message))
|
||||
|
||||
tab.open_history_dialog()
|
||||
self.app.processEvents()
|
||||
|
||||
self.assertEqual("暂无历史生成记录", messages[-1][0])
|
||||
self.assertIsNone(tab._history_dialog)
|
||||
dialog = tab._history_dialog
|
||||
self.assertIsInstance(dialog, ProductSuiteGlobalHistoryDialog)
|
||||
self.assertTrue(dialog.isVisible())
|
||||
self.assertTrue(
|
||||
any(
|
||||
label.text() == "暂无套图历史生成记录,完成套图生成后会自动出现在这里"
|
||||
for label in dialog.findChildren(QLabel)
|
||||
)
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.gui.workers import (
|
||||
ImageStudioPullImagesWorker,
|
||||
ProductSuiteAiWriteWorker,
|
||||
ProductSuiteGenerateWorker,
|
||||
ProductSuiteHistoryExportWorker,
|
||||
)
|
||||
|
||||
|
||||
@@ -229,6 +230,43 @@ class WorkerTests(unittest.TestCase):
|
||||
gen_title.assert_not_called()
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_product_suite_history_export_worker_uses_round_export_service(self):
|
||||
worker = ProductSuiteHistoryExportWorker(
|
||||
7,
|
||||
"round-key",
|
||||
"D:/exports",
|
||||
db_path="suite.db",
|
||||
)
|
||||
expected = SimpleNamespace(
|
||||
target_dir="D:/exports/main-shop_51100639510_20260717",
|
||||
files=(object(), object()),
|
||||
skipped_count=1,
|
||||
cancelled=False,
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"app.gui.workers.image_studio_export.export_generation_round",
|
||||
return_value=expected,
|
||||
) as export_round:
|
||||
result = worker.execute()
|
||||
|
||||
export_round.assert_called_once_with(
|
||||
7,
|
||||
"round-key",
|
||||
"D:/exports",
|
||||
path="suite.db",
|
||||
should_stop=worker.should_cancel,
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"target_dir": expected.target_dir,
|
||||
"file_count": 2,
|
||||
"skipped_count": 1,
|
||||
"cancelled": False,
|
||||
},
|
||||
result,
|
||||
)
|
||||
|
||||
def test_product_suite_worker_writes_generation_round_and_stable_slots(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
|
||||
Reference in New Issue
Block a user