fix/various fixes
This commit is contained in:
@@ -7,7 +7,7 @@ from PIL import Image
|
||||
|
||||
from app.ports.image_gen import ImageResult, TaskStatus
|
||||
from app.tasks import memoir_tasks
|
||||
from app.tasks.memoir_tasks import generate_chapter_images
|
||||
from app.tasks.memoir_tasks import build_cos_key, generate_chapter_images
|
||||
|
||||
|
||||
def _mock_image_generator(
|
||||
@@ -30,56 +30,38 @@ def _mock_image_generator(
|
||||
return gen
|
||||
|
||||
|
||||
def _section_image_record(img_dict):
|
||||
"""把图片 dict 转成 image_record 用的 SimpleNamespace(可被任务更新属性)。"""
|
||||
d = dict(img_dict or {})
|
||||
return SimpleNamespace(
|
||||
order_index=d.get("index", 0),
|
||||
placeholder=d.get("placeholder"),
|
||||
description=d.get("description"),
|
||||
status=d.get("status"),
|
||||
prompt=d.get("prompt"),
|
||||
url=d.get("url"),
|
||||
storage_key=d.get("storage_key"),
|
||||
provider=d.get("provider"),
|
||||
style=d.get("style"),
|
||||
size=d.get("size"),
|
||||
error=d.get("error"),
|
||||
retryable=d.get("retryable"),
|
||||
created_at=d.get("created_at"),
|
||||
updated_at=d.get("updated_at"),
|
||||
def _chapter_with_cover_memoir_image(
|
||||
*,
|
||||
cover_status: str = "pending",
|
||||
cover_url: str | None = None,
|
||||
canonical_markdown: str = "# 童年\n\n那条路我一直记得。",
|
||||
):
|
||||
"""stories-first:章节级 MemoirImage(order_index 最小为封面槽位)。"""
|
||||
cover_rec = SimpleNamespace(
|
||||
id="cover-img-1",
|
||||
order_index=0,
|
||||
placeholder="",
|
||||
description="",
|
||||
status=cover_status,
|
||||
url=cover_url,
|
||||
storage_key=None,
|
||||
prompt=None,
|
||||
provider=None,
|
||||
style=None,
|
||||
size=None,
|
||||
error=None,
|
||||
retryable=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _chapter_with_sections(sections_data):
|
||||
"""构造带 sections 的 chapter stub,供 generate_chapter_images 使用(任务从 section.image_record 读/写)。"""
|
||||
sections = []
|
||||
for i, d in enumerate(sections_data):
|
||||
img = d.get("image")
|
||||
if img:
|
||||
rec = _section_image_record(img)
|
||||
sec = SimpleNamespace(
|
||||
content=d.get("content", ""),
|
||||
image_id="img-%s-%s" % (i, id(rec)),
|
||||
image_record=rec,
|
||||
order_index=d.get("order_index", i),
|
||||
)
|
||||
else:
|
||||
sec = SimpleNamespace(
|
||||
content=d.get("content", ""),
|
||||
image_id=None,
|
||||
image_record=None,
|
||||
order_index=d.get("order_index", i),
|
||||
)
|
||||
sections.append(sec)
|
||||
return SimpleNamespace(
|
||||
id="chapter-1",
|
||||
user_id="user-1",
|
||||
title="童年的夏天",
|
||||
category="childhood",
|
||||
canonical_markdown=canonical_markdown,
|
||||
cover_image=None,
|
||||
images=[],
|
||||
sections=sections,
|
||||
images=[cover_rec],
|
||||
)
|
||||
|
||||
|
||||
@@ -107,20 +89,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
get_sync_db_mock,
|
||||
redis_from_url,
|
||||
):
|
||||
chapter = _chapter_with_sections(
|
||||
[
|
||||
{
|
||||
"content": "那条路我一直记得。",
|
||||
"image": {
|
||||
"index": 0,
|
||||
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "pending",
|
||||
"url": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
chapter = _chapter_with_cover_memoir_image()
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
@@ -140,7 +109,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
@patch("app.tasks.memoir_tasks.ImagePromptOrchestrator")
|
||||
@patch("app.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
def test_generate_chapter_images_retries_when_any_item_generation_fails(
|
||||
def test_generate_chapter_images_retries_when_cover_generation_fails(
|
||||
self,
|
||||
_acquire_lock_mock,
|
||||
_release_lock_mock,
|
||||
@@ -149,25 +118,13 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
storage_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections(
|
||||
[
|
||||
{
|
||||
"content": "那条路我一直记得。",
|
||||
"image": {
|
||||
"index": 0,
|
||||
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "pending",
|
||||
"url": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
chapter = _chapter_with_cover_memoir_image()
|
||||
cover = chapter.images[0]
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
prompt_service_cls.return_value.build_prompt.return_value = {
|
||||
prompt_service_cls.return_value.build_cover_prompt.return_value = {
|
||||
"prompt": "A serene southern China town",
|
||||
"style": "watercolor",
|
||||
"size": "1024x1024",
|
||||
@@ -186,10 +143,8 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
generate_chapter_images.run.__func__(task_self, "chapter-1")
|
||||
|
||||
self.assertIs(ctx.exception, retry_error)
|
||||
self.assertEqual(chapter.sections[0].image_record.status, "failed")
|
||||
self.assertEqual(
|
||||
chapter.sections[0].image_record.error, "transient provider error"
|
||||
)
|
||||
self.assertEqual(cover.status, "failed")
|
||||
self.assertEqual(cover.error, "transient provider error")
|
||||
task_self.retry.assert_called_once()
|
||||
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
|
||||
|
||||
@@ -199,7 +154,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
@patch("app.tasks.memoir_tasks.ImagePromptOrchestrator")
|
||||
@patch("app.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
def test_generate_chapter_images_marks_successful_item_completed(
|
||||
def test_generate_chapter_images_marks_successful_cover_completed(
|
||||
self,
|
||||
_acquire_lock_mock,
|
||||
_release_lock_mock,
|
||||
@@ -208,51 +163,39 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
storage_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections(
|
||||
[
|
||||
{
|
||||
"content": "那条路我一直记得。",
|
||||
"image": {
|
||||
"index": 0,
|
||||
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "pending",
|
||||
"url": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
chapter = _chapter_with_cover_memoir_image()
|
||||
cover = chapter.images[0]
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
prompt_service_cls.return_value.build_prompt.return_value = {
|
||||
prompt_data = {
|
||||
"prompt": "A serene southern China town",
|
||||
"style": "watercolor",
|
||||
"size": "1024x1024",
|
||||
"prompt_context": "childhood: 童年的夏天",
|
||||
}
|
||||
prompt_service_cls.return_value.build_cover_prompt.return_value = prompt_data
|
||||
get_image_generator_mock.return_value = _mock_image_generator()
|
||||
storage_inst = storage_cls.from_env.return_value
|
||||
storage_inst.upload_bytes.return_value = (
|
||||
"https://cos.example.com/memoirs/u1/c1/0.png"
|
||||
"https://cos.example.com/memoirs/u1/c1/cover.png"
|
||||
)
|
||||
|
||||
generate_chapter_images.run("chapter-1")
|
||||
|
||||
self.assertEqual(chapter.sections[0].image_record.status, "completed")
|
||||
self.assertEqual(
|
||||
chapter.sections[0].image_record.storage_key,
|
||||
"memoirs/user-1/chapter-1/0-7e1f860790.png",
|
||||
self.assertEqual(cover.status, "completed")
|
||||
expected_key = build_cos_key(
|
||||
"user-1", "chapter-1", "cover", prompt_data["prompt"]
|
||||
)
|
||||
self.assertEqual(cover.storage_key, expected_key)
|
||||
self.assertEqual(
|
||||
chapter.sections[0].image_record.url,
|
||||
"https://cos.example.com/memoirs/u1/c1/0.png",
|
||||
)
|
||||
self.assertEqual(
|
||||
chapter.sections[0].image_record.prompt, "A serene southern China town"
|
||||
cover.url,
|
||||
"https://cos.example.com/memoirs/u1/c1/cover.png",
|
||||
)
|
||||
self.assertEqual(cover.prompt, "A serene southern China town")
|
||||
get_image_generator_mock.return_value.generate.assert_called_once()
|
||||
prompt_service_cls.return_value.build_cover_prompt.assert_called_once()
|
||||
db.commit.assert_called()
|
||||
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@@ -268,20 +211,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
storage_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections(
|
||||
[
|
||||
{
|
||||
"content": "那条路我一直记得。",
|
||||
"image": {
|
||||
"index": 0,
|
||||
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "pending",
|
||||
"url": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
chapter = _chapter_with_cover_memoir_image()
|
||||
settings_from_env.return_value = SimpleNamespace(
|
||||
enabled=False,
|
||||
max_per_chapter=2,
|
||||
@@ -320,41 +250,28 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
storage_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections(
|
||||
[
|
||||
{
|
||||
"content": "那条路我一直记得。",
|
||||
"image": {
|
||||
"index": 0,
|
||||
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "pending",
|
||||
"url": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
image_buffer = BytesIO()
|
||||
Image.new("RGB", (2, 1), color="white").save(image_buffer, format="JPEG")
|
||||
jpeg_bytes = image_buffer.getvalue()
|
||||
|
||||
chapter = _chapter_with_cover_memoir_image()
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
prompt_service_cls.return_value.build_prompt.return_value = {
|
||||
prompt_service_cls.return_value.build_cover_prompt.return_value = {
|
||||
"prompt": "A serene southern China town",
|
||||
"style": "watercolor",
|
||||
"size": "1024x1024",
|
||||
"prompt_context": "childhood: 童年的夏天",
|
||||
}
|
||||
image_buffer = BytesIO()
|
||||
Image.new("RGB", (2, 1), color="white").save(image_buffer, format="JPEG")
|
||||
jpeg_bytes = image_buffer.getvalue()
|
||||
|
||||
get_image_generator_mock.return_value = _mock_image_generator(
|
||||
image_url="https://provider.example.com/1.jpg",
|
||||
image_bytes=jpeg_bytes,
|
||||
)
|
||||
storage_inst = storage_cls.from_env.return_value
|
||||
storage_inst.upload_bytes.return_value = (
|
||||
"https://cos.example.com/memoirs/u1/c1/0.png"
|
||||
"https://cos.example.com/memoirs/u1/c1/cover.png"
|
||||
)
|
||||
|
||||
generate_chapter_images.run("chapter-1")
|
||||
@@ -378,25 +295,13 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
storage_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections(
|
||||
[
|
||||
{
|
||||
"content": "那条路我一直记得。",
|
||||
"image": {
|
||||
"index": 0,
|
||||
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "pending",
|
||||
"url": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
chapter = _chapter_with_cover_memoir_image()
|
||||
cover = chapter.images[0]
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
prompt_service_cls.return_value.build_prompt.return_value = {
|
||||
prompt_service_cls.return_value.build_cover_prompt.return_value = {
|
||||
"prompt": "A serene southern China town",
|
||||
"style": "watercolor",
|
||||
"size": "1024x1024",
|
||||
@@ -408,13 +313,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
"AccessDenied", retryable=False, request_id="req-403"
|
||||
)
|
||||
task_self = SimpleNamespace(request=SimpleNamespace(id="task-1"), retry=Mock())
|
||||
img_rec = chapter.sections[0].image_record
|
||||
|
||||
result = generate_chapter_images.run.__func__(task_self, "chapter-1")
|
||||
|
||||
self.assertEqual(result, {"status": "success"})
|
||||
self.assertIsNone(chapter.sections[0].image_id)
|
||||
db.delete.assert_called_with(img_rec)
|
||||
db.delete.assert_called_with(cover)
|
||||
task_self.retry.assert_not_called()
|
||||
|
||||
@patch("app.tasks.memoir_tasks.get_sync_db")
|
||||
@@ -423,7 +326,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
@patch("app.tasks.memoir_tasks.ImagePromptOrchestrator")
|
||||
@patch("app.tasks.memoir_tasks._release_chapter_image_lock")
|
||||
@patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True)
|
||||
def test_generate_chapter_images_skips_completed_items_for_idempotency(
|
||||
def test_generate_chapter_images_skips_completed_cover_for_idempotency(
|
||||
self,
|
||||
_acquire_lock_mock,
|
||||
_release_lock_mock,
|
||||
@@ -432,26 +335,17 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
storage_cls,
|
||||
get_sync_db_mock,
|
||||
):
|
||||
chapter = _chapter_with_sections(
|
||||
[
|
||||
{
|
||||
"content": "那条路我一直记得。",
|
||||
"image": {
|
||||
"index": 0,
|
||||
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "completed",
|
||||
"url": "https://cos.example.com/already-there.png",
|
||||
},
|
||||
},
|
||||
]
|
||||
chapter = _chapter_with_cover_memoir_image(
|
||||
cover_status="completed",
|
||||
cover_url="https://cos.example.com/already-there.png",
|
||||
)
|
||||
db = Mock()
|
||||
_bind_db_execute_to_chapter(db, chapter)
|
||||
get_sync_db_mock.return_value.__enter__.return_value = db
|
||||
get_sync_db_mock.return_value.__exit__.return_value = False
|
||||
|
||||
generate_chapter_images.run("chapter-1")
|
||||
result = generate_chapter_images.run("chapter-1")
|
||||
|
||||
self.assertEqual(result, {"status": "no_images"})
|
||||
get_image_generator_mock.return_value.generate.assert_not_called()
|
||||
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user