Files
cmshoppe/tests/test_image_studio_generation.py
T

1042 lines
43 KiB
Python
Raw Normal View History

import io
import os
import sys
import threading
import time
import unittest
import base64
from concurrent.futures import CancelledError
from unittest import mock
sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin
from app import ai, appconfig, db, image_studio, image_studio_generation
class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
def _png_bytes(self):
from PIL import Image
output = io.BytesIO()
Image.new("RGB", (32, 32), (120, 80, 160)).save(output, format="PNG")
return output.getvalue()
def _config(self, temp_dir):
return {
"data_dir": temp_dir,
"db_path": os.path.join(temp_dir, "cmshopee.db"),
"image_dir": os.path.join(temp_dir, "images"),
"ai": {
"backend": "cmhub",
"image_concurrency": 1,
"retry": 0,
"resolution": "1k",
"jpg_quality": 90,
"cmhub": {
"base_url": "https://cmhub.example.com",
"image_alias": "image-hd",
"connect_timeout": 3,
"download_with_curl": "false",
},
},
}
def _project_source(self, temp_dir):
cfg = self._config(temp_dir)
db.init_db(cfg["db_path"])
project = image_studio.create_or_get_project(
account_alias="alias",
account_slug="alias_slug",
item_id="51100639510",
path=cfg["db_path"],
)
source_path = os.path.join(temp_dir, "source.png")
with open(source_path, "wb") as fh:
fh.write(self._png_bytes())
source = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
remote_url="https://cdn.example.com/source.png",
local_path=source_path,
source_order=1,
path=cfg["db_path"],
)
return cfg, project, source
def _runtime(self):
return {
"base_url": "https://cmhub.example.com",
"api_key": "sk-test",
"alias": "image-hd",
"connect_timeout": 3,
"use_system_proxy": False,
"download_with_curl": "false",
}
def _direct_config(self, temp_dir):
cfg = self._config(temp_dir)
models_path = os.path.join(temp_dir, "ai_models.json")
cfg["ai"]["backend"] = "direct"
cfg["ai"]["default_text_model"] = "Text"
cfg["ai"]["default_image_model"] = "Direct Image"
cfg["ai_models_path"] = models_path
appconfig.save_ai_models_config(
{
"models": [
{
"name": "Text",
"category": "text",
"enabled": True,
"url": "https://text.example.com/v1",
"model": "text-model",
"api_key": "sk-text",
"api_type": "chat",
"connect_timeout_seconds": 3,
"timeout_seconds": 10,
"extra_body": {},
},
{
"name": "Direct Image",
"category": "image",
"enabled": True,
"url": "https://image.example.com/v1",
"model": "image-model",
"api_key": "sk-image",
"api_type": "images_edits",
"connect_timeout_seconds": 7,
"timeout_seconds": 12,
"extra_body": {},
},
]
},
path=models_path,
)
return ai.freeze_runtime_config(
cfg,
models_path=models_path,
include_cmhub=False,
include_direct_models=True,
)
def test_generate_image_jobs_creates_independent_jobs_and_assets(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
submitted = []
poll_seen_persisted = []
def fake_submit(method, url, api_key, **kwargs):
self.assertEqual("POST", method)
task_id = "cmhub-task-%d" % (len(submitted) + 1)
submitted.append((task_id, kwargs["headers_extra"]["Idempotency-Key"]))
return {
"task_id": task_id,
"status": "queued",
"call_id": "call-%d" % len(submitted),
"points_cost": 2,
"points_balance": 100 - len(submitted) * 2,
}
def fake_poll(method, url, api_key, **kwargs):
self.assertEqual("GET", method)
task_id = url.rsplit("/", 1)[-1]
jobs = image_studio.list_resumable_jobs(path=cfg["db_path"], project_id=project.id)
poll_seen_persisted.append(any(job.task_id == task_id for job in jobs))
return {
"task_id": task_id,
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/%s.png" % task_id},
}
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
side_effect=fake_submit,
), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
side_effect=fake_poll,
), \
mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
return_value=(self._png_bytes(), 0.1),
), \
mock.patch("app.image_studio_generation.ai._sleep_cmhub_poll"):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"完整提示词",
3,
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(3, summary["success"])
self.assertEqual(0, summary["failed"])
self.assertEqual(3, len(submitted))
self.assertEqual(3, len({key for _, key in submitted}))
self.assertEqual([True, True, True], poll_seen_persisted)
jobs = image_studio.list_resumable_jobs(path=cfg["db_path"], project_id=project.id)
self.assertEqual([], jobs)
all_jobs = [
image_studio.get_job(result["job"].id, path=cfg["db_path"])
for result in summary["jobs"]
]
self.assertEqual(["succeeded", "succeeded", "succeeded"], [job.status for job in all_jobs])
self.assertTrue(all(job.task_id for job in all_jobs))
self.assertTrue(all(job.task_key for job in all_jobs))
self.assertEqual([2, 2, 2], [job.points_cost for job in all_jobs])
assets = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"])
self.assertEqual(3, len(assets))
self.assertTrue(all(os.path.isfile(asset.local_path) for asset in assets))
self.assertTrue(all(asset.parent_asset_id == source.id for asset in assets))
self.assertTrue(all(asset.prompt == "完整提示词" for asset in assets))
self.assert_removed(temp_dir)
def test_direct_gateway_creates_direct_suite_job_before_execution(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
cfg["ai"]["backend"] = "direct"
jobs = image_studio_generation.create_generation_jobs(
project.id,
source.id,
"提交前来源快照",
1,
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(1, len(jobs))
self.assertEqual("direct", jobs[0].generation_source)
self.assertEqual("openai_images_edits", jobs[0].provider)
self.assertIsNone(jobs[0].task_id)
self.assert_removed(temp_dir)
def test_direct_job_with_invalid_task_id_is_not_sent_to_any_gateway(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
cfg["ai"]["backend"] = "direct"
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="白底图",
prompt="旧任务",
generation_source="direct",
provider="openai_images_edits",
path=cfg["db_path"],
)
conn = db.connect(cfg["db_path"])
try:
conn.execute(
"UPDATE image_studio_jobs SET task_id = 'custom-task-1' WHERE id = ?",
(job.id,),
)
conn.commit()
finally:
conn.close()
job = image_studio.get_job(job.id, path=cfg["db_path"])
with mock.patch("app.image_studio_generation._runtime") as runtime, \
mock.patch("app.image_studio_generation._direct_runtime") as direct_runtime:
summary = image_studio_generation.run_jobs(
[job],
config=cfg,
path=cfg["db_path"],
)
runtime.assert_not_called()
direct_runtime.assert_not_called()
self.assertEqual(1, summary["total"])
self.assertEqual(1, summary["failed"])
self.assertIn("来源无效", summary["jobs"][0]["error"])
self.assert_removed(temp_dir)
def test_direct_job_uses_one_ordered_edit_request_and_records_output_dimensions(self):
with self.make_temp_dir() as temp_dir:
_cfg, project, source = self._project_source(temp_dir)
cfg = self._direct_config(temp_dir)
reference_path = os.path.join(temp_dir, "reference.png")
with open(reference_path, "wb") as fh:
fh.write(self._png_bytes())
reference = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=reference_path,
source_order=2,
path=cfg["db_path"],
)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
reference_asset_ids=[reference.id],
job_type="场景图",
prompt="已冻结的套图提示词",
generation_source="direct",
provider="openai_images_edits",
path=cfg["db_path"],
)
seen = []
encoded = base64.b64encode(self._png_bytes()).decode("ascii")
def fake_direct_call(model, body, config, attempts, **kwargs):
seen.append((model, body, config, attempts, kwargs))
return {"data": [{"b64_json": encoded}]}
with mock.patch(
"app.image_studio_generation.ai._call_with_retry",
side_effect=fake_direct_call,
), mock.patch("app.image_studio_generation._runtime") as cmhub_runtime, \
mock.patch("app.image_studio_generation.ai._cmhub_call_with_retry") as cmhub_submit, \
mock.patch("app.image_studio_generation.ai._cmhub_call_once") as cmhub_poll:
summary = image_studio_generation.run_jobs(
[job],
aspect_ratio="3:4",
config=cfg,
path=cfg["db_path"],
run_session_id="direct-test-session",
)
self.assertEqual(1, summary["success"])
self.assertEqual("1024x1536", summary["output"]["requested_output_size"])
self.assertTrue(summary["output"]["approximate_ratio"])
self.assertEqual(1, len(seen))
_model, body, _config, attempts, kwargs = seen[0]
self.assertEqual(1, attempts)
self.assertEqual("multipart", kwargs["request_kind"])
self.assertEqual(2, body.count(b'name="image[]"'))
self.assertLess(body.index(b"source.png"), body.index(b"reference.png"))
self.assertIn(b'name="n"', body)
self.assertIn(b"\r\n1\r\n", body)
cmhub_runtime.assert_not_called()
cmhub_submit.assert_not_called()
cmhub_poll.assert_not_called()
stored = image_studio.get_job(job.id, path=cfg["db_path"])
self.assertEqual("succeeded", stored.status)
self.assertEqual("direct", stored.generation_source)
self.assertEqual("openai_images_edits", stored.provider)
self.assertIsNone(stored.task_id)
self.assertEqual("direct-test-session", stored.run_session_id)
asset = image_studio.get_asset(stored.output_asset_id, path=cfg["db_path"])
self.assertIsNone(asset.remote_url)
self.assertEqual("3:4", asset.aspect_ratio)
self.assertEqual("1024x1536", asset.requested_output_size)
self.assertEqual((1024, 1536), (asset.rendered_width, asset.rendered_height))
self.assert_removed(temp_dir)
def test_direct_job_saves_returned_image_after_stop_and_never_retries(self):
with self.make_temp_dir() as temp_dir:
_cfg, project, source = self._project_source(temp_dir)
cfg = self._direct_config(temp_dir)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
prompt="停止后保存",
generation_source="direct",
provider="openai_images_edits",
path=cfg["db_path"],
)
stopped = {"value": False}
encoded = base64.b64encode(self._png_bytes()).decode("ascii")
def fake_direct_call(*args, **kwargs):
self.assertEqual(1, args[3])
stopped["value"] = True
return {"data": [{"b64_json": encoded}]}
with mock.patch(
"app.image_studio_generation.ai._call_with_retry",
side_effect=fake_direct_call,
) as direct_call:
summary = image_studio_generation.run_jobs(
[job],
config=cfg,
path=cfg["db_path"],
should_stop=lambda: stopped["value"],
)
self.assertEqual(1, summary["success"])
self.assertEqual(1, direct_call.call_count)
self.assertEqual("succeeded", image_studio.get_job(job.id, path=cfg["db_path"]).status)
self.assert_removed(temp_dir)
def test_startup_recovery_marks_only_stale_direct_running_jobs_failed(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
stale = image_studio.create_job(
project.id,
source_asset_id=source.id,
prompt="已中断",
generation_source="direct",
provider="openai_images_edits",
path=cfg["db_path"],
)
active = image_studio.create_job(
project.id,
source_asset_id=source.id,
prompt="仍在执行",
generation_source="direct",
provider="openai_images_edits",
path=cfg["db_path"],
)
image_studio.update_job_status(
stale.id,
"running",
run_session_id="previous-session",
path=cfg["db_path"],
)
image_studio.update_job_status(
active.id,
"running",
run_session_id="active-session",
path=cfg["db_path"],
)
recovered = image_studio.fail_stale_direct_jobs(
active_run_session_ids=["active-session"],
path=cfg["db_path"],
)
self.assertEqual([stale.id], [job.id for job in recovered])
stale = image_studio.get_job(stale.id, path=cfg["db_path"])
active = image_studio.get_job(active.id, path=cfg["db_path"])
self.assertEqual("failed", stale.status)
self.assertIn("程序中断", stale.error)
self.assertEqual(image_studio.JOB_RECOVERY_REGENERATE, stale.recovery_action)
self.assertEqual("running", active.status)
self.assert_removed(temp_dir)
def test_direct_selection_still_resumes_submitted_default_gateway_task(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
cfg["ai"]["backend"] = "direct"
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="白底图",
prompt="已扣点图片",
generation_source="cmhub",
provider="cmhub",
path=cfg["db_path"],
)
job = image_studio.set_job_submitted(
job.id,
"cmhub-task-1",
path=cfg["db_path"],
)
with mock.patch(
"app.image_studio_generation._runtime",
return_value=self._runtime(),
), mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
return_value={
"task_id": "cmhub-task-1",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/result.png"},
},
) as poll, mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
return_value=(self._png_bytes(), 0.1),
):
summary = image_studio_generation.run_jobs(
[job],
config=cfg,
path=cfg["db_path"],
)
poll.assert_called_once()
self.assertEqual(1, summary["success"])
self.assertEqual("succeeded", image_studio.get_job(job.id, path=cfg["db_path"]).status)
self.assert_removed(temp_dir)
def test_generate_image_jobs_sends_selected_aspect_ratio(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
submitted_payloads = []
def fake_submit(method, url, api_key, **kwargs):
submitted_payloads.append(dict(kwargs["payload"]))
return {"task_id": "cmhub-ratio", "status": "queued"}
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
side_effect=fake_submit,
), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
return_value={
"task_id": "cmhub-ratio",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/ratio.png"},
},
), \
mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
return_value=(self._png_bytes(), 0.1),
):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"比例测试",
1,
aspect_ratio="3:4",
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(1, summary["success"])
self.assertEqual("3:4", submitted_payloads[0]["aspect_ratio"])
self.assertEqual(1, len(submitted_payloads[0]["images"]))
self.assertIn("image_base64", submitted_payloads[0]["images"][0])
self.assertNotIn("image_base64", submitted_payloads[0])
self.assert_removed(temp_dir)
def test_build_cmhub_images_keeps_order_and_limits_to_eight_inputs(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
references = []
for index in range(2, 10):
path = os.path.join(temp_dir, "source-%d.png" % index)
with open(path, "wb") as fh:
fh.write(self._png_bytes())
references.append(
image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=path,
source_order=index,
path=cfg["db_path"],
)
)
with mock.patch(
"app.image_studio_generation.ai._image_data_url",
side_effect=lambda path: "data:image/png;base64,%s" % os.path.basename(path),
):
images, omitted_count = image_studio_generation._build_cmhub_images(
source,
references,
)
self.assertEqual(8, len(images))
self.assertEqual(1, omitted_count)
self.assertTrue(images[0]["image_base64"].endswith("source.png"))
self.assertTrue(images[-1]["image_base64"].endswith("source-8.png"))
self.assert_removed(temp_dir)
def test_build_cmhub_images_rejects_oversized_total_payload(self):
with self.make_temp_dir() as temp_dir:
_, _, source = self._project_source(temp_dir)
with mock.patch(
"app.image_studio_generation.ai._image_data_url",
return_value="x" * 32,
), mock.patch.object(
image_studio_generation,
"CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES",
16,
):
with self.assertRaisesRegex(
image_studio_generation.ImageStudioGenerationError,
"总大小超过32MiB",
):
image_studio_generation._build_cmhub_images(source)
self.assert_removed(temp_dir)
def test_job_reference_snapshot_submits_ordered_images(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
reference_path = os.path.join(temp_dir, "reference.png")
with open(reference_path, "wb") as fh:
fh.write(self._png_bytes())
reference = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=reference_path,
source_order=2,
path=cfg["db_path"],
)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
reference_asset_ids=[reference.id],
prompt="多图提示词",
path=cfg["db_path"],
)
submitted = []
def fake_submit(method, url, api_key, **kwargs):
submitted.append(kwargs["payload"])
return {"task_id": "multi-image-task", "status": "queued"}
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch("app.image_studio_generation.ai._cmhub_call_with_retry", side_effect=fake_submit), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
return_value={
"task_id": "multi-image-task",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/multi.png"},
},
), \
mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
return_value=(self._png_bytes(), 0.1),
):
summary = image_studio_generation.run_jobs(
[job],
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(1, summary["success"])
self.assertEqual(2, len(submitted[0]["images"]))
self.assertNotIn("image_base64", submitted[0])
self.assert_removed(temp_dir)
def test_missing_reference_snapshot_fails_without_submitting(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
reference = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=os.path.join(temp_dir, "missing-reference.png"),
source_order=2,
path=cfg["db_path"],
)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
reference_asset_ids=[reference.id],
prompt="多图提示词",
path=cfg["db_path"],
)
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch("app.image_studio_generation.ai._cmhub_call_with_retry") as submit:
summary = image_studio_generation.run_jobs(
[job],
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(1, summary["failed"])
self.assertIn("参考图尚未下载", summary["jobs"][0]["error"])
submit.assert_not_called()
self.assert_removed(temp_dir)
def test_stop_after_download_discards_temporary_result(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
stopped = {"value": False}
saved_paths = []
def fake_download(
request_result,
out_path,
config,
on_event,
job_id,
should_stop=None,
):
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "wb") as fh:
fh.write(self._png_bytes())
saved_paths.append(out_path)
stopped["value"] = True
return out_path
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
return_value={"task_id": "cmhub-stop", "status": "queued"},
), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
return_value={
"task_id": "cmhub-stop",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/stop.png"},
},
), \
mock.patch(
"app.image_studio_generation._download_and_save_job_image",
side_effect=fake_download,
):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"停止测试",
1,
config=cfg,
path=cfg["db_path"],
should_stop=lambda: stopped["value"],
)
self.assertEqual(1, summary["cancelled"])
self.assertEqual([], image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"]))
self.assertEqual(1, len(saved_paths))
self.assertFalse(os.path.exists(saved_paths[0]))
self.assert_removed(temp_dir)
def test_download_cancel_maps_to_cancelled_and_keeps_resume_action(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
stopped = {"value": False}
def fake_download(*args, **kwargs):
self.assertIsNotNone(kwargs.get("should_stop"))
stopped["value"] = True
raise CancelledError()
with mock.patch(
"app.image_studio_generation._runtime",
return_value=self._runtime(),
), mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
return_value={"task_id": "cmhub-cancel-download", "status": "queued"},
), mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
return_value={
"task_id": "cmhub-cancel-download",
"status": "succeeded",
"result": {
"image_url": "https://cdn.example.com/cancel-download.png"
},
},
), mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
side_effect=fake_download,
):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"下载停止测试",
1,
config=cfg,
path=cfg["db_path"],
should_stop=lambda: stopped["value"],
)
self.assertEqual(1, summary["cancelled"])
job = summary["jobs"][0]["job"]
stored = image_studio.get_job(job.id, path=cfg["db_path"])
self.assertEqual("cancelled", stored.status)
self.assertEqual(image_studio.JOB_RECOVERY_RESUME, stored.recovery_action)
self.assertEqual(
[],
image_studio.list_assets(
project.id,
kind="generated_main",
path=cfg["db_path"],
),
)
self.assert_removed(temp_dir)
def test_run_jobs_cancels_queued_future_before_running_slot_is_released(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
cfg["ai"]["image_concurrency"] = 1
jobs = [
image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="main",
prompt="排队停止测试",
path=cfg["db_path"],
)
for _ in range(2)
]
started_ids = []
first_started = threading.Event()
release_first = threading.Event()
stop_requested = threading.Event()
result_holder = {}
def fake_run_one(*args):
job_id = int(args[0])
started_ids.append(job_id)
if len(started_ids) == 1:
first_started.set()
release_first.wait(timeout=3)
updated = image_studio.update_job_status(
job_id,
"cancelled",
error="用户停止",
recovery_action=image_studio.JOB_RECOVERY_REGENERATE,
path=cfg["db_path"],
)
return {
"job": updated,
"status": "cancelled",
"error": "用户停止",
}
def run():
result_holder["summary"] = image_studio_generation.run_jobs(
jobs,
config=cfg,
path=cfg["db_path"],
should_stop=stop_requested.is_set,
)
with mock.patch(
"app.image_studio_generation._runtime",
return_value=self._runtime(),
), mock.patch(
"app.image_studio_generation._run_one_job_with_global_slot",
side_effect=fake_run_one,
):
thread = threading.Thread(target=run)
thread.start()
self.assertTrue(first_started.wait(timeout=2))
stop_requested.set()
time.sleep(0.35)
release_first.set()
thread.join(timeout=3)
self.assertFalse(thread.is_alive())
self.assertEqual([jobs[0].id], started_ids)
summary = result_holder["summary"]
self.assertEqual(2, summary["cancelled"])
self.assertEqual(2, len(summary["jobs"]))
self.assertEqual(
["cancelled", "cancelled"],
[
image_studio.get_job(job.id, path=cfg["db_path"]).status
for job in jobs
],
)
self.assert_removed(temp_dir)
def test_resume_existing_job_polls_without_new_submit(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="main",
prompt="续查提示词",
path=cfg["db_path"],
)
image_studio.set_job_submitted(job.id, "cmhub-task-resume", path=cfg["db_path"])
def fake_poll(method, url, api_key, **kwargs):
return {
"task_id": "cmhub-task-resume",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/resume.png"},
}
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch("app.image_studio_generation.ai._cmhub_call_with_retry") as submit, \
mock.patch("app.image_studio_generation.ai._cmhub_call_once", side_effect=fake_poll), \
mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
return_value=(self._png_bytes(), 0.1),
):
summary = image_studio_generation.resume_image_jobs(
project_id=project.id,
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(1, summary["success"])
submit.assert_not_called()
updated = image_studio.get_job(job.id, path=cfg["db_path"])
self.assertEqual("succeeded", updated.status)
self.assertEqual("cmhub-task-resume", updated.task_id)
self.assert_removed(temp_dir)
def test_download_failure_does_not_submit_again_or_create_asset(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
submit_count = 0
def fake_submit(method, url, api_key, **kwargs):
nonlocal submit_count
submit_count += 1
return {"task_id": "cmhub-task-1", "status": "queued"}
def fake_poll(method, url, api_key, **kwargs):
return {
"task_id": "cmhub-task-1",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/fail.png"},
}
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
side_effect=fake_submit,
), \
mock.patch("app.image_studio_generation.ai._cmhub_call_once", side_effect=fake_poll), \
mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
side_effect=RuntimeError("download failed"),
):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"完整提示词",
1,
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(0, summary["success"])
self.assertEqual(1, summary["failed"])
self.assertEqual(1, submit_count)
jobs = [result["job"] for result in summary["jobs"]]
self.assertEqual("failed", image_studio.get_job(jobs[0].id, path=cfg["db_path"]).status)
self.assertEqual([], image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"]))
self.assert_removed(temp_dir)
2026-07-11 14:29:58 +08:00
def test_resume_failed_download_job_polls_without_new_submit(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="main",
prompt="恢复下载",
path=cfg["db_path"],
)
image_studio.set_job_submitted(
job.id,
"cmhub-task-download",
call_id="call-download",
points_cost=2,
points_balance=88,
path=cfg["db_path"],
)
image_studio.update_job_status(
job.id,
"failed",
error="下载新封面失败",
path=cfg["db_path"],
)
failed_job = image_studio.get_job(job.id, path=cfg["db_path"])
self.assertEqual(image_studio.JOB_RECOVERY_RESUME, failed_job.recovery_action)
2026-07-11 14:29:58 +08:00
resumable = image_studio.list_resumable_jobs(
path=cfg["db_path"],
project_id=project.id,
include_failed_downloads=True,
)
self.assertEqual([job.id], [item.id for item in resumable])
def fake_poll(method, url, api_key, **kwargs):
self.assertEqual("GET", method)
return {
"task_id": "cmhub-task-download",
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/recovered.png"},
"points_balance": 88,
}
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch("app.image_studio_generation.ai._cmhub_call_with_retry") as submit, \
mock.patch("app.image_studio_generation.ai._cmhub_call_once", side_effect=fake_poll), \
mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
return_value=(self._png_bytes(), 0.1),
):
summary = image_studio_generation.resume_image_jobs(
project_id=project.id,
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(1, summary["success"])
submit.assert_not_called()
updated = image_studio.get_job(job.id, path=cfg["db_path"])
self.assertEqual("succeeded", updated.status)
self.assertEqual("cmhub-task-download", updated.task_id)
self.assertEqual("call-download", updated.call_id)
self.assertEqual(image_studio.JOB_RECOVERY_NONE, updated.recovery_action)
2026-07-11 14:29:58 +08:00
assets = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"])
self.assertEqual(1, len(assets))
self.assert_removed(temp_dir)
def test_failed_cmhub_task_marks_only_that_job_failed(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
submitted = []
def fake_submit(method, url, api_key, **kwargs):
task_id = "cmhub-task-%d" % (len(submitted) + 1)
submitted.append(task_id)
return {"task_id": task_id, "status": "queued"}
def fake_poll(method, url, api_key, **kwargs):
task_id = url.rsplit("/", 1)[-1]
if task_id.endswith("-2"):
return {"task_id": task_id, "status": "failed", "error": {"message": "上游失败"}}
return {
"task_id": task_id,
"status": "succeeded",
"result": {"image_url": "https://cdn.example.com/%s.png" % task_id},
}
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
side_effect=fake_submit,
), \
mock.patch("app.image_studio_generation.ai._cmhub_call_once", side_effect=fake_poll), \
mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
return_value=(self._png_bytes(), 0.1),
):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"完整提示词",
2,
config=cfg,
path=cfg["db_path"],
)
self.assertEqual(1, summary["success"])
self.assertEqual(1, summary["failed"])
assets = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"])
self.assertEqual(1, len(assets))
failed_jobs = [
job
for job in image_studio.list_resumable_jobs(
path=cfg["db_path"],
project_id=project.id,
include_failed_downloads=True,
)
if job.status == "failed"
]
self.assertEqual([], failed_jobs)
self.assert_removed(temp_dir)
if __name__ == "__main__":
unittest.main()