Files
Jellyfish/backend/tests/test_image_tasks_api_responses.py

375 lines
16 KiB
Python

"""image_tasks 接口响应壳测试。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from fastapi.testclient import TestClient
from app.api.v1.routes.studio import image_tasks as route
from app.dependencies import get_db
from app.main import app
class _DummyDB:
async def get(self, *_args, **_kwargs):
return None
async def execute(self, *_args, **_kwargs):
class _Result:
def scalars(self):
class _Scalars:
def first(self):
return None
return _Scalars()
return _Result()
def add(self, *_args, **_kwargs):
return None
async def flush(self):
return None
async def refresh(self, *_args, **_kwargs):
return None
def _override_db(db: _DummyDB):
async def _get_db() -> AsyncGenerator[_DummyDB, None]:
yield db
return _get_db
def test_create_actor_image_task_requires_prompt(client: TestClient) -> None:
db = _DummyDB()
app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.post(
"/api/v1/studio/image-tasks/actors/actor-1/image-tasks",
json={"image_id": 1, "prompt": " ", "images": []},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json() == {
"code": 400,
"message": "prompt is required for actor generation",
"data": None,
"meta": None,
}
def test_render_actor_image_prompt_returns_success_envelope(client: TestClient, monkeypatch) -> None:
db = _DummyDB()
class _Base:
prompt = "基础演员提示词"
default_images = ["file-1", "file-2"]
entity_type = "actor"
entity_id = "actor-1"
relation_type = "actor_image"
relation_entity_id = "1"
class _Derived:
prompt = "渲染后的演员提示词"
images = ["file-1", "file-2"]
async def _fake_build_base(*_args, **_kwargs):
return _Base()
def _fake_derive(*_args, **_kwargs):
return _Derived()
monkeypatch.setattr(route, "_build_actor_image_base_draft_service", _fake_build_base)
monkeypatch.setattr(route, "_derive_asset_image_preview_service", _fake_derive)
app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.post(
"/api/v1/studio/image-tasks/actors/actor-1/render-prompt",
json={"image_id": 1, "images": []},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["code"] == 200
assert body["message"] == "success"
assert body["data"]["prompt"] == "渲染后的演员提示词"
assert body["data"]["images"] == ["file-1", "file-2"]
def test_create_shot_frame_image_task_requires_prompt(client: TestClient) -> None:
db = _DummyDB()
app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.post(
"/api/v1/studio/image-tasks/shot/shot-1/frame-image-tasks",
json={"frame_type": "first", "prompt": " ", "target_ratio": "16:9", "images": []},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json() == {
"code": 400,
"message": "prompt is required for shot frame generation",
"data": None,
"meta": None,
}
def test_render_shot_frame_prompt_returns_success_envelope_when_prompt_given(client: TestClient, monkeypatch) -> None:
db = _DummyDB()
async def _fake_load_frame_render_guidance(**_kwargs):
return {
"director_command_summary": "必须:锁定主角视线方向",
"continuity_guidance": "当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局",
"frame_specific_guidance": "首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作",
"composition_anchor": "以温室门框和人物站位作为空间锚点,保持环境与人物同时可读",
"screen_direction_guidance": "保持陆远与环境入口的视线方向稳定,避免无故翻转朝向",
}
monkeypatch.setattr(route, "_load_frame_render_guidance", _fake_load_frame_render_guidance)
app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.post(
"/api/v1/studio/image-tasks/shot/shot-1/frame-render-prompt",
json={
"frame_type": "first",
"prompt": "生成一个紧张的首帧画面",
"images": [
{"type": "character", "id": "char-1", "name": "陆远", "file_id": "file-1"},
{"type": "scene", "id": "scene-1", "name": "温室", "file_id": "file-2"},
],
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["code"] == 200
assert body["message"] == "success"
assert body["data"]["base_prompt"] == "生成一个紧张的首帧画面"
assert body["data"]["selected_guidance"] == [
"高优先级导演指令:必须:锁定主角视线方向",
"当前帧职责:首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作",
"连续性要求:当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局",
]
assert body["data"]["dropped_guidance"] == [
"构图锚点:以温室门框和人物站位作为空间锚点,保持环境与人物同时可读",
"朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向",
]
assert body["data"]["selected_guidance_details"][1]["reason_tag"] == "首帧保时序"
assert body["data"]["selected_guidance_details"][1]["reason"] == "当前是首帧,系统优先保留触发瞬间与未完成态约束,避免画面直接跳到后续完成动作。"
assert body["data"]["dropped_guidance_details"][0]["reason_tag"] == "首帧降构图"
assert body["data"]["dropped_guidance_details"][1]["reason_tag"] == "首帧降轴线"
assert body["data"]["images"] == ["file-1", "file-2"]
assert body["data"]["mappings"] == [
{"token": "图1", "type": "character", "id": "char-1", "name": "陆远", "file_id": "file-1"},
{"token": "图2", "type": "scene", "id": "scene-1", "name": "温室", "file_id": "file-2"},
]
assert "高优先级导演指令:必须:锁定主角视线方向" in body["data"]["rendered_prompt"]
assert "当前帧职责:首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作" in body["data"]["rendered_prompt"]
assert "连续性要求:当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局" in body["data"]["rendered_prompt"]
assert "构图锚点:以温室门框和人物站位作为空间锚点,保持环境与人物同时可读" not in body["data"]["rendered_prompt"]
assert "朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向" not in body["data"]["rendered_prompt"]
assert "图1: 陆远" in body["data"]["rendered_prompt"]
assert "图2: 温室" in body["data"]["rendered_prompt"]
assert "生成一个紧张的首帧画面" in body["data"]["rendered_prompt"]
def test_render_shot_frame_prompt_requires_prompt(client: TestClient) -> None:
db = _DummyDB()
app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.post(
"/api/v1/studio/image-tasks/shot/shot-1/frame-render-prompt",
json={
"frame_type": "first",
"prompt": "",
"images": [],
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 422
def test_create_shot_frame_image_task_renders_prompt_before_submit(client: TestClient, monkeypatch) -> None:
class _ShotDetailDB(_DummyDB):
async def get(self, model, ident):
if getattr(model, "__name__", "") == "ShotDetail" and ident == "shot-1":
return object()
return None
db = _ShotDetailDB()
async def _fake_resolve_image_refs(*_args, **_kwargs):
return [{"image_url": "data:image/png;base64,abc"}]
async def _fake_load_frame_render_guidance(**_kwargs):
return {
"director_command_summary": "必须:锁定主角视线方向",
"continuity_guidance": "当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局",
"frame_specific_guidance": "首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作",
"composition_anchor": "以温室门框和人物站位作为空间锚点,保持环境与人物同时可读",
"screen_direction_guidance": "保持陆远与环境入口的视线方向稳定,避免无故翻转朝向",
}
async def _fake_create_image_task_and_link(*_args, **kwargs):
assert kwargs["prompt"].startswith("## 图片内容说明")
assert "高优先级导演指令:必须:锁定主角视线方向" in kwargs["prompt"]
assert "当前帧职责:首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作" in kwargs["prompt"]
assert "连续性要求:当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局" in kwargs["prompt"]
assert "构图锚点:以温室门框和人物站位作为空间锚点,保持环境与人物同时可读" not in kwargs["prompt"]
assert "朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向" not in kwargs["prompt"]
assert "图1: 陆远" in kwargs["prompt"]
assert kwargs["images"] == [{"image_url": "data:image/png;base64,abc"}]
assert kwargs["target_ratio"] == "9:16"
assert kwargs["resolution_profile"] == "standard"
assert kwargs["purpose"] == "video_reference"
assert kwargs["render_context"]["images"] == ["file-1"]
assert kwargs["render_context"]["mappings"][0]["token"] == "图1"
assert kwargs["render_context"]["selected_guidance"][0] == "高优先级导演指令:必须:锁定主角视线方向"
assert kwargs["render_context"]["selected_guidance_details"][1]["reason_tag"] == "首帧保时序"
assert kwargs["render_context"]["dropped_guidance_details"][0]["reason_tag"] == "首帧降构图"
assert kwargs["render_context"]["dropped_guidance_details"][1]["reason_tag"] == "首帧降轴线"
return "task-1"
monkeypatch.setattr(route, "_resolve_reference_image_refs_by_file_ids_service", _fake_resolve_image_refs)
monkeypatch.setattr(route, "_load_frame_render_guidance", _fake_load_frame_render_guidance)
monkeypatch.setattr(route, "_create_image_task_and_link_service", _fake_create_image_task_and_link)
app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.post(
"/api/v1/studio/image-tasks/shot/shot-1/frame-image-tasks",
json={
"frame_type": "first",
"prompt": "陆远站在温室里",
"target_ratio": "9:16",
"resolution_profile": "standard",
"images": [
{"type": "character", "id": "char-1", "name": "陆远", "file_id": "file-1"},
],
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["data"]["task_id"] == "task-1"
def test_run_image_generation_task_persists_render_context(monkeypatch) -> None:
from app.services.studio import image_task_runner as runner
calls: dict[str, object] = {}
class _FakeTaskStore:
def __init__(self, _session):
pass
async def set_status(self, *_args, **_kwargs):
return None
async def set_progress(self, *_args, **_kwargs):
return None
async def set_result(self, _task_id, payload):
calls["result_payload"] = payload
class _FakeSession:
async def commit(self):
return None
async def rollback(self):
return None
class _FakeSessionContext:
async def __aenter__(self):
return _FakeSession()
async def __aexit__(self, exc_type, exc, tb):
return False
class _FakeImageTask:
def __init__(self, *args, **kwargs):
pass
async def run(self):
return None
async def get_result(self):
class _Result:
def model_dump(self):
return {"images": [{"url": "http://example.com/a.png"}]}
return _Result()
async def _fake_cancel_if_requested_async(**_kwargs):
return False
async def _fake_persist_images_to_assets(*_args, **_kwargs):
return None
async def _fake_resolve_related_shot_id(*_args, **_kwargs):
return None
monkeypatch.setattr(runner, "SqlAlchemyTaskStore", _FakeTaskStore)
monkeypatch.setattr(runner, "async_session_maker", lambda: _FakeSessionContext())
monkeypatch.setattr(runner, "ImageGenerationTask", _FakeImageTask)
monkeypatch.setattr(runner, "cancel_if_requested_async", _fake_cancel_if_requested_async)
monkeypatch.setattr(runner, "_persist_images_to_assets", _fake_persist_images_to_assets)
monkeypatch.setattr(runner, "_resolve_related_shot_id", _fake_resolve_related_shot_id)
monkeypatch.setattr(runner, "log_task_event", lambda *_args, **_kwargs: None)
import asyncio
asyncio.run(
runner.run_image_generation_task(
"task-1",
{
"provider": "openai",
"api_key": "k",
"base_url": None,
"relation_type": "shot_frame_image",
"relation_entity_id": "1",
"render_context": {
"images": ["file-1"],
"mappings": [{"token": "图1", "name": "陆远", "file_id": "file-1"}],
"selected_guidance": ["高优先级导演指令:必须:锁定主角视线方向"],
"dropped_guidance": ["朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向"],
"selected_guidance_details": [
{
"text": "高优先级导演指令:必须:锁定主角视线方向",
"category": "summary",
"reason_tag": "导演主指令",
"reason": "导演主指令始终属于最高优先级约束,因此会优先保留。",
}
],
"dropped_guidance_details": [
{
"text": "朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向",
"category": "screen",
"reason_tag": "首帧降轴线",
"reason": "当前是首帧,系统更优先保空间建立与站位关系,因此将朝向与视线 guidance 降为次级。",
}
],
},
"input": {"prompt": "## 图片内容说明\n图1: 陆远", "model": "gpt-image-1"},
},
)
)
assert calls["result_payload"]["render_context"]["images"] == ["file-1"]
assert calls["result_payload"]["render_context"]["selected_guidance_details"][0]["reason_tag"] == "导演主指令"
assert calls["result_payload"]["render_context"]["dropped_guidance_details"][0]["reason_tag"] == "首帧降轴线"