Coverage for tests/streamwise_app/test_streampersona.py: 97%
157 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 04:47 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 04:47 +0000
1#!/usr/bin/env python3
2"""
3Unit tests for StreamPersona.
4"""
6import os
7import sys
8import asyncio
9import pytest
10import aiofiles
12from typing import List
13from typing import Any
15from PIL import Image
17from http import HTTPStatus
19from pptx import Presentation
21from quart import Quart
23from unittest.mock import patch
24from unittest.mock import MagicMock
25from unittest.mock import AsyncMock
27# Add current path
28sys.path.append(os.getcwd())
30from file_utils import read_file_base64
31from file_utils import read_file_bytes
32from file_utils import binary_to_base64
34from tests.test_utils import temp_sys_path
35from tests.torch_mock import TorchMock
36from tests.streamwise_app.app_test_helpers import check_app_root
37from tests.streamwise_app.app_test_helpers import check_health
38from tests.streamwise_app.app_test_helpers import check_files
39from tests.streamwise_app.app_test_helpers import check_unknown_route
40from tests.streamwise_app.app_test_helpers import check_job_submit_page
41from tests.streamwise_app.app_test_helpers import check_job_status_page
42from tests.streamwise_app.app_test_helpers import check_api_job_status
43from tests.streamwise_app.app_test_helpers import check_api_job_requests
45mock_torch = TorchMock()
46mock_modules = {}
47mock_modules.update(mock_torch.get_sub_modules())
49with patch.dict(sys.modules, mock_modules):
50 from media_utils import save_video_frames
52 with temp_sys_path("apps", "apps/streampersona"):
53 from apps.streampersona.streampersona import StreamPersonaApp
54 from apps.streampersona.streampersona_job import StreamPersonaJob
55 from apps.streampersona.streampersona_job import overlay_image_on_image
56 from apps.streamwise_job import OutputMode
57 from apps.client import ServiceRequest
58 from apps.client import RequestStatus
60streampersona_app = StreamPersonaApp()
63@pytest.fixture(name="test_app")
64def _test_app() -> Quart:
65 return streampersona_app.app
68@pytest.mark.asyncio
69async def test_app(test_app: Quart) -> None:
70 """Check that GET / returns 200."""
71 await check_app_root(test_app, "StreamPersona")
74@pytest.mark.asyncio
75async def test_health(test_app: Quart) -> None:
76 """Check /health."""
77 await check_health(test_app)
80@pytest.mark.asyncio
81async def test_files(test_app: Quart) -> None:
82 """Check /files endpoint."""
83 await check_files(test_app, "streampersona")
86@pytest.mark.asyncio
87async def test_unknown_route(test_app: Quart) -> None:
88 """Check that an unknown route returns 404."""
89 await check_unknown_route(test_app)
92@pytest.mark.asyncio
93async def test_job_submit_page(test_app: Quart) -> None:
94 """Check the web page for job submission."""
95 await check_job_submit_page(test_app)
98@pytest.mark.asyncio
99async def test_job_status_page(test_app: Quart) -> None:
100 """Check the web page for job status."""
101 await check_job_status_page(test_app)
104@pytest.mark.asyncio
105async def test_api_job_status(test_app: Quart) -> None:
106 """Check the API for job status (returns UNKNOWN for nonexistent jobs)."""
107 await check_api_job_status(test_app)
110@pytest.mark.asyncio
111async def test_api_job_requests(test_app: Quart) -> None:
112 """Check the API for job requests listing (returns empty for nonexistent jobs)."""
113 await check_api_job_requests(test_app)
116def create_example_slides(
117 texts: List[str] = ["Hello", "World"],
118 file_name: str = "/tmp/example.pptx"
119) -> None:
120 ret = Presentation()
121 layout = ret.slide_layouts[0]
123 for text in texts:
124 slide = ret.slides.add_slide(layout)
125 slide.shapes.title.text = text
127 # Save the file
128 ret.save(file_name)
131@pytest.mark.asyncio
132async def test_api_job_submit(test_app: Quart) -> None:
133 """Check the API for job requests."""
134 client = test_app.test_client()
136 # Mock the service manager
137 streampersona_app.service_manager = MagicMock()
139 response = await client.post("/api/job", json={"pdf_base64": "AAAA"})
140 assert response.status_code == HTTPStatus.BAD_REQUEST
141 response_json = await response.get_json()
142 assert "error" in response_json
143 assert response_json["error"] == "Missing 'pptx_base64' in job config"
145 # Success case
146 create_example_slides(
147 texts=["Slide 1", "Slide 2"],
148 file_name="/tmp/example.pptx"
149 )
150 async with aiofiles.open("/tmp/example.pptx", "rb") as file:
151 pptx_binary = await file.read()
152 pptx_base64 = binary_to_base64(pptx_binary)
154 response = await client.post("/api/job", json={"pptx_base64": pptx_base64})
155 assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
156 response_json = await response.get_json()
157 assert "error" in response_json
158 assert "No such file or directory: 'libreoffice'" in response_json["error"]
160 """
161 # TODO mimic libreoffice being present
162 assert response.status_code == HTTPStatus.OK
163 response_json = await response.get_json()
164 assert "job_id" in response_json
165 assert response_json["status"] == "success"
166 """
169async def _mock_generation(
170 job: StreamPersonaJob
171) -> StreamPersonaJob:
172 """
173 Mock multi-modal generation components.
174 """
175 audio_base64 = await read_file_base64("tests/data/audio_4675.wav")
176 job.gen.service_manager.get_service_url = MagicMock(
177 return_value="http://mock_service_url:1234"
178 )
179 job.gen.gen_audio = AsyncMock(return_value=audio_base64)
180 loop = asyncio.get_running_loop()
181 image_future: asyncio.Future[Image.Image] = loop.create_future()
182 image_future.set_result(Image.new("RGB", (640, 480), color="blue"))
183 job.image_task = image_future # type: ignore[assignment]
185 video_frames: List[Image.Image] = [
186 Image.new("RGB", (640, 480), color=color)
187 for color in ["blue", "green", "red", "yellow"]
188 ]
189 video_path = await save_video_frames(video_frames)
190 video_binary = await read_file_bytes(video_path)
191 job.gen.gen_video_audio_from_img = AsyncMock(return_value=video_binary)
193 async def gen_video_mock(*args: Any, **kwargs: Any) -> Any:
194 if kwargs.get("wait_request", None):
195 return video_binary
196 req = ServiceRequest(
197 request_id="mock_request_id",
198 service_name="hunyuanframepackf1",
199 payload_json={"job_id": job.job_id},
200 )
201 req.status = RequestStatus.COMPLETED
202 req.future = asyncio.Future()
203 req.future.set_result(("video/mp4", video_binary))
204 return req
206 job.gen.gen_video = AsyncMock(side_effect=gen_video_mock)
207 return job
210@pytest.mark.asyncio
211async def test_gen_slide_video() -> None:
212 service_manager = MagicMock()
213 job_id = "test_gen_slide_video"
215 job = None
216 try:
217 job = StreamPersonaJob(job_id, service_manager)
219 with pytest.raises(Exception, match="Error generating audio"):
220 await job.gen_slide_video(
221 slide_number=0,
222 slide_text="Test Slide 0",
223 )
225 # Mocking generation
226 job = await _mock_generation(job)
228 await job.gen_slide_video(
229 slide_number=1,
230 slide_text="Test Slide 1",
231 )
232 finally:
233 if job:
234 await job.close()
237@pytest.mark.asyncio
238async def test_gen_slide_video_unsynced() -> None:
239 service_manager = MagicMock()
240 job_id = "test_gen_slide_video_unsynced"
242 job = None
243 try:
244 job = StreamPersonaJob(job_id, service_manager)
245 job.config["output_mode"] = OutputMode.VIDEO_AUDIO_UNSYNCED
247 with pytest.raises(Exception, match="Error generating audio"):
248 await job.gen_slide_video(
249 slide_number=0,
250 slide_text="Test Slide 0",
251 )
252 job = await _mock_generation(job)
253 await job.gen_slide_video(
254 slide_number=1,
255 slide_text="Test Slide Unsynced",
256 )
257 finally:
258 if job:
259 await job.close()
262def test_overlay_image_on_image() -> None:
263 """Test overlay_image_on_image()."""
264 base_image = Image.new("RGB", (200, 200), color="blue")
265 overlay_image = Image.new("RGBA", (100, 100), color="pink")
267 out_image = overlay_image_on_image(
268 base_image,
269 overlay_image,
270 position=("bottom", "left")
271 )
272 assert out_image is not None
273 assert out_image.size == base_image.size
275 # Corner case: overlay larger than base
276 out_image = overlay_image_on_image(
277 overlay_image,
278 base_image,
279 position=("top", "center")
280 )
281 assert out_image is not None
282 assert out_image.size == overlay_image.size
284 # More positions
285 for pos_vertical in ["top", "center", "bottom"]:
286 for pos_horizontal in ["left", "center", "right"]:
287 out_image = overlay_image_on_image(
288 base_image,
289 overlay_image,
290 position=(pos_vertical, pos_horizontal)
291 )
292 assert out_image is not None
293 assert out_image.size == base_image.size