Files
life-echo/api/tests/test_story_image_tasks.py
Kevin 7f57f96c25 重构回忆录为 story-first / markdown-first 架构并整合图片意图与前端 UI 修复
本次 squash merge 将 codex-story-first-image-intent 的整体改动合入 development,核心内容包括:

1. 后端数据与迁移:新增 stories、story_versions、story_image_intents、chapter_cover_intents、assets 等模型与 Alembic 迁移,建立 story-first、markdown-first、asset-first 的主数据链路。

2. 生成与任务链:引入 StoryBuilderOrchestrator、ChapterComposerOrchestrator、story_image_tasks、chapter_cover_tasks,图片生成从正文占位符改为结构化 intent -> asset -> markdown 回填。

3. 并发与一致性:为 story/chapter intent 增加 claim_token、claimed_at、attempt_count,采用数据库原子 claim 为主、Redis 锁为辅,避免重复生成、锁误删和 processing 卡死。

4. Memoir 读写路径:章节 canonical_markdown 成为正文真源,列表/详情接口补齐 markdown、cover_asset、word_count 等字段,PDF 与 asset 解析链路同步升级。

5. Memory / Retrieval:扩展 transcript ingest、chunking、evidence 检索与 story 聚合基础设施,为后续 story-first RAG 与多 agent 编排提供底座。

6. App 端体验:章节页继续走 MarkdownRenderer 阅读链,同时吸收 fix3-19 的跨平台 UI glitch 修复;更新对话页、首页、文案资源与章节列表映射逻辑。

7. 测试与文档:补充 asset resolver、story image task、章节封面派发、markdown 映射等回归测试,并加入图片占位符退役设计文档。
2026-03-20 10:31:51 +08:00

153 lines
5.1 KiB
Python

import unittest
from contextlib import contextmanager
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import Mock, patch
from PIL import Image
from app.ports.image_gen import ImageResult, TaskStatus
from app.tasks.story_image_tasks import generate_story_image
def _mock_db_cm(db):
@contextmanager
def _cm():
yield db
return _cm()
def _png_bytes() -> bytes:
buf = BytesIO()
Image.new("RGB", (1, 1), color="white").save(buf, format="PNG")
return buf.getvalue()
class _FakeUUID:
def __init__(self, value: str):
self.hex = value
self._value = value
def __str__(self) -> str:
return self._value
class GenerateStoryImageTaskTest(unittest.TestCase):
@patch("app.tasks.story_image_tasks.release_redis_lock")
@patch(
"app.tasks.story_image_tasks.acquire_redis_lock",
return_value=SimpleNamespace(key="lock:story-image:story-1"),
)
@patch("app.tasks.story_image_tasks._claim_story_image_intent_sync")
@patch("app.tasks.story_image_tasks.get_sync_db")
@patch("app.tasks.story_image_tasks.TencentCosStorageService")
@patch("app.tasks.story_image_tasks.get_image_generator")
@patch("app.features.memoir.memoir_images.settings.MemoirImageSettings.from_env")
@patch("app.tasks.story_image_tasks.uuid.uuid4")
def test_generate_story_image_resumes_processing_intent_and_backfills_markdown(
self,
uuid4_mock,
settings_from_env,
get_image_generator_mock,
storage_cls,
get_sync_db_mock,
claim_intent_mock,
acquire_lock_mock,
release_lock_mock,
):
uuid4_mock.side_effect = [
_FakeUUID("claim-token"),
_FakeUUID("asset-uuid"),
_FakeUUID("version-uuid"),
]
settings_from_env.return_value = SimpleNamespace(
provider="liblib",
default_style="watercolor",
default_size="1024x1024",
)
intent = SimpleNamespace(
id="intent-1",
prompt_brief="院子里的藤椅",
style_profile="watercolor",
story_version_id="ver-1",
caption="主插图",
source_span={"paragraph_index": 0},
status="processing",
)
story = SimpleNamespace(
id="story-1",
user_id="user-1",
title="童年的院子",
stage="childhood",
)
db_claim = Mock()
claim_intent_mock.return_value = (intent, story)
intent_db = SimpleNamespace(
id="intent-1",
story_version_id="ver-1",
caption="主插图",
source_span={"paragraph_index": 0},
status="processing",
style_profile="watercolor",
claim_token="claim-token",
asset_id=None,
error=None,
updated_at=None,
)
story_db = SimpleNamespace(
id="story-1",
current_version_id="ver-1",
canonical_markdown="第一段\n\n第二段",
)
version_db = SimpleNamespace(id="ver-1", markdown_snapshot="第一段\n\n第二段")
version_max_result = Mock()
version_max_result.scalar.return_value = 1
db_persist = Mock()
db_persist.get.side_effect = [intent_db, story_db, version_db]
db_persist.execute.return_value = version_max_result
get_sync_db_mock.side_effect = [_mock_db_cm(db_claim), _mock_db_cm(db_persist)]
generator = get_image_generator_mock.return_value
generator.generate.return_value = ImageResult(
status=TaskStatus.COMPLETED,
task_id="task-1",
image_url="https://provider.example.com/story.png",
)
generator.download_image.return_value = _png_bytes()
storage_cls.from_env.return_value.upload_bytes.return_value = (
"https://cos.example.com/stories/u1/s1.png"
)
result = generate_story_image.run("story-1")
self.assertEqual(result["status"], "success")
self.assertEqual(intent_db.status, "completed")
self.assertIsNotNone(intent_db.asset_id)
self.assertNotEqual(story_db.current_version_id, "ver-1")
self.assertIn("asset://", story_db.canonical_markdown)
generator.generate.assert_called_once()
storage_cls.from_env.return_value.upload_bytes.assert_called_once()
claim_intent_mock.assert_called_once()
acquire_lock_mock.assert_called_once()
release_lock_mock.assert_called_once()
@patch("app.tasks.story_image_tasks.acquire_redis_lock", return_value=None)
@patch("app.tasks.story_image_tasks.get_sync_db")
@patch("app.tasks.story_image_tasks.get_image_generator")
def test_generate_story_image_skips_when_lock_is_already_held(
self,
get_image_generator_mock,
get_sync_db_mock,
acquire_lock_mock,
):
result = generate_story_image.run("story-1")
self.assertEqual(result, {"status": "locked"})
get_sync_db_mock.assert_not_called()
get_image_generator_mock.assert_not_called()
acquire_lock_mock.assert_called_once()