feat: add voice co-creation session skeleton
This commit is contained in:
@@ -187,6 +187,18 @@ def isolated_story_audio_cache(tmp_path, monkeypatch):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_voice_session_storage(tmp_path, monkeypatch):
|
||||
"""Use an isolated directory for voice session assets."""
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"voice_session_storage_dir",
|
||||
str(tmp_path / "voice_sessions"),
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_text_provider():
|
||||
"""Mock text generation."""
|
||||
|
||||
201
backend/tests/test_voice_sessions.py
Normal file
201
backend/tests/test_voice_sessions.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.main import app
|
||||
from app.services.adapters.text.models import StoryOutput
|
||||
|
||||
|
||||
async def test_voice_session_create_and_fallback_turn_returns_audio(
|
||||
db_session,
|
||||
auth_token,
|
||||
):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.voice_session_service.generate_story_content",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_generate,
|
||||
patch(
|
||||
"app.services.voice_session_service.text_to_speech",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_tts,
|
||||
):
|
||||
mock_generate.return_value = StoryOutput(
|
||||
mode="generated",
|
||||
title="小猫去太空",
|
||||
story_text="小猫跳上纸飞机,朝着月亮轻轻挥手。",
|
||||
cover_prompt_suggestion="温暖儿童绘本封面,小猫与月亮",
|
||||
)
|
||||
mock_tts.return_value = b"fake-turn-audio"
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
client.cookies.set("access_token", auth_token)
|
||||
|
||||
response = await client.post("/api/voice-sessions", json={})
|
||||
assert response.status_code == 201
|
||||
session_id = response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/fallback",
|
||||
json={"transcript_text": "我想听一个小猫去太空的故事"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
turn_id = response.json()["turn_id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/voice-sessions/{session_id}/turns/{turn_id}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
turn_data = response.json()
|
||||
assert turn_data["status"] == "audio_ready"
|
||||
assert turn_data["detected_intent"] == "start_story"
|
||||
assert turn_data["assistant_audio_ready"] is True
|
||||
assert turn_data["assistant_audio_url"].endswith("/audio")
|
||||
|
||||
response = await client.get(turn_data["assistant_audio_url"])
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"fake-turn-audio"
|
||||
assert response.headers["content-type"] == "audio/mpeg"
|
||||
|
||||
response = await client.get(f"/api/voice-sessions/{session_id}")
|
||||
assert response.status_code == 200
|
||||
session_data = response.json()
|
||||
assert session_data["status"] == "waiting_user"
|
||||
assert session_data["working_title"] == "小猫去太空"
|
||||
assert session_data["can_continue"] is True
|
||||
assert session_data["can_finalize"] is True
|
||||
assert len(session_data["recent_turns"]) == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_voice_session_correct_turn_and_finalize_to_story(
|
||||
db_session,
|
||||
auth_token,
|
||||
):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.voice_session_service.generate_story_content",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_generate,
|
||||
patch(
|
||||
"app.services.voice_session_service.text_to_speech",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_tts,
|
||||
):
|
||||
mock_generate.side_effect = [
|
||||
StoryOutput(
|
||||
mode="generated",
|
||||
title="小猫去太空",
|
||||
story_text="第一段故事:小猫坐着纸飞机飞向月亮。",
|
||||
cover_prompt_suggestion="温暖儿童绘本封面,小猫飞向月亮",
|
||||
),
|
||||
StoryOutput(
|
||||
mode="generated",
|
||||
title="小猫去太空",
|
||||
story_text="第二段故事:它在月亮上遇见了会发光的新朋友。",
|
||||
cover_prompt_suggestion="温暖儿童绘本封面,小猫与月亮朋友",
|
||||
),
|
||||
]
|
||||
mock_tts.side_effect = [b"turn-1-audio", b"turn-2-audio"]
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
client.cookies.set("access_token", auth_token)
|
||||
|
||||
response = await client.post("/api/voice-sessions", json={})
|
||||
assert response.status_code == 201
|
||||
session_id = response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/fallback",
|
||||
json={"transcript_text": "我想听一个小猫去太空的故事"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/fallback",
|
||||
json={"transcript_text": "不要让它哭了,我想让它找到一个朋友"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
turn_id = response.json()["turn_id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/voice-sessions/{session_id}/turns/{turn_id}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["detected_intent"] == "correct_story"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/finalize",
|
||||
json={"save_story": True, "generate_cover": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finalize_data = response.json()
|
||||
story_id = finalize_data["story_id"]
|
||||
assert finalize_data["status"] == "completed"
|
||||
|
||||
response = await client.get(f"/api/stories/{story_id}")
|
||||
assert response.status_code == 200
|
||||
story_data = response.json()
|
||||
assert story_data["title"] == "小猫去太空"
|
||||
assert "第一段故事" in story_data["story_text"]
|
||||
assert "第二段故事" in story_data["story_text"]
|
||||
assert story_data["generation_status"] == "partial_ready"
|
||||
|
||||
response = await client.get(f"/api/voice-sessions/{session_id}")
|
||||
assert response.status_code == 200
|
||||
session_data = response.json()
|
||||
assert session_data["status"] == "completed"
|
||||
assert session_data["final_story_id"] == story_id
|
||||
assert session_data["can_continue"] is False
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_voice_session_abandon_blocks_future_turns(
|
||||
db_session,
|
||||
auth_token,
|
||||
):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
client.cookies.set("access_token", auth_token)
|
||||
|
||||
response = await client.post("/api/voice-sessions", json={})
|
||||
assert response.status_code == 201
|
||||
session_id = response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/abandon",
|
||||
json={"reason": "孩子先去吃饭了"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "abandoned"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/voice-sessions/{session_id}/turns/fallback",
|
||||
json={"transcript_text": "我们继续讲吧"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Reference in New Issue
Block a user