Coverage for tests/streamwise_app/test_streammovie.py: 99%

333 statements  

« 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 StreamMovie. 

4""" 

5 

6import os 

7import sys 

8import json 

9import pytest 

10import aiofiles 

11import aiofiles.os 

12 

13from PIL import Image # noqa: F401 - import before patch.dict to keep PIL in sys.modules 

14 

15from http import HTTPStatus 

16 

17from typing import AsyncGenerator 

18from typing import Dict 

19from typing import Any 

20 

21from quart import Quart 

22 

23from unittest.mock import patch 

24from unittest.mock import MagicMock 

25 

26# Add current path 

27sys.path.append(os.getcwd()) 

28 

29from tests.test_utils import temp_sys_path 

30from tests.torch_mock import TorchMock 

31from tests.k8s_mock import K8sMock 

32from tests.fantasytalking_mock import FantasyTalkingMock 

33from tests.streamwise_app.app_test_helpers import check_app_root 

34from tests.streamwise_app.app_test_helpers import check_health 

35from tests.streamwise_app.app_test_helpers import check_files 

36from tests.streamwise_app.app_test_helpers import check_unknown_route 

37from tests.streamwise_app.app_test_helpers import check_job_submit_page 

38from tests.streamwise_app.app_test_helpers import check_job_status_page 

39from tests.streamwise_app.app_test_helpers import check_api_job_status 

40from tests.streamwise_app.app_test_helpers import check_api_job_requests 

41 

42mock_torch = TorchMock() 

43mock_k8s = K8sMock() 

44mock_ft = FantasyTalkingMock() 

45 

46mock_modules = { 

47 "imageio": MagicMock(), 

48 "tabulate": MagicMock(), 

49 "soundfile": MagicMock(), 

50} 

51mock_modules.update(mock_torch.get_sub_modules()) 

52mock_modules.update(mock_k8s.get_sub_modules()) 

53mock_modules.update(mock_ft.get_sub_modules()) 

54 

55with patch.dict(sys.modules, mock_modules): 

56 with temp_sys_path("apps", "apps/streammovie"): 

57 from apps.streammovie.streammovie import StreamMovieApp 

58 from apps.streammovie.streammovie_job import StreamMovieJob 

59 from apps.streammovie.streammovie_job import JobStatus 

60 

61 from tests.streamwise_app.lmm_generator_mock import LMMGeneratorMock 

62 

63 

64streammovie_app = StreamMovieApp() 

65 

66 

67@pytest.fixture(name="test_app") 

68def _test_app() -> Quart: 

69 return streammovie_app.app 

70 

71 

72# --------------------------------------------------------------------------- 

73# Helper: a mock LMMGeneratorMock that also supports gen_text_stream 

74# --------------------------------------------------------------------------- 

75 

76# Sample JSONL script returned by the LLM mock 

77MOCK_SHOT_0 = { 

78 "type": "shot_description", 

79 "shot_id": "S001", 

80 "visual_prompt": "A wide-angle cinematic shot of a cyberpunk city at night.", 

81 "negative_prompt": "blur, watermark", 

82 "dialogue": None, 

83 "technical_specs": {"duration_seconds": 4}, 

84} 

85 

86MOCK_SHOT_1 = { 

87 "type": "shot_description", 

88 "shot_id": "S002", 

89 "visual_prompt": "Close-up of the hero's determined face.", 

90 "negative_prompt": "blur", 

91 "dialogue": "I will stop them.", 

92 "technical_specs": {"duration_seconds": 3}, 

93} 

94 

95 

96def _make_mock_script_chunks() -> Any: 

97 """Yield JSONL lines as streaming chunks.""" 

98 lines = [ 

99 json.dumps({"type": "movie_metadata", "title": "Test Movie"}), 

100 json.dumps(MOCK_SHOT_0), 

101 json.dumps(MOCK_SHOT_1), 

102 ] 

103 for line in lines: 

104 yield line + "\n" 

105 

106 

107def _make_mock_script_chunks_with_noise() -> Any: 

108 """Yield a mix of valid JSONL and prose/noise lines.""" 

109 items = [ 

110 "Okay, here's the Movie Bible for a test film.", # prose noise 

111 json.dumps({"type": "movie_metadata", "title": "Noisy Movie"}), 

112 "```jsonl", # markdown fence noise 

113 json.dumps(MOCK_SHOT_0), 

114 "This is an explanatory paragraph that should be filtered out.", # prose noise 

115 json.dumps(MOCK_SHOT_1), 

116 "```", # markdown fence noise 

117 "And now, some notes about the next act...", # prose noise 

118 ] 

119 for item in items: 

120 yield item + "\n" 

121 

122 

123class LMMGeneratorMovieMock(LMMGeneratorMock): 

124 """LMMGeneratorMock that handles gen_text_stream for movie script generation.""" 

125 

126 def __init__(self) -> None: 

127 super().__init__() 

128 self.last_messages: list = [] 

129 

130 async def gen_text_stream( 

131 self, 

132 *args: Any, 

133 **kwargs: Any, 

134 ) -> AsyncGenerator[str, None]: 

135 self.last_messages = kwargs.get("messages", []) 

136 for chunk in _make_mock_script_chunks(): 

137 yield chunk 

138 

139 async def gen_text( 

140 self, 

141 *args: Any, 

142 **kwargs: Any, 

143 ) -> str: 

144 return "" 

145 

146 async def stop(self) -> None: 

147 pass 

148 

149 

150class LMMGeneratorMovieNoisyMock(LMMGeneratorMock): 

151 """LMMGeneratorMock that emits prose noise mixed with valid JSONL.""" 

152 

153 async def gen_text_stream( 

154 self, 

155 *args: Any, 

156 **kwargs: Any, 

157 ) -> AsyncGenerator[str, None]: 

158 for chunk in _make_mock_script_chunks_with_noise(): 

159 yield chunk 

160 

161 async def gen_text( 

162 self, 

163 *args: Any, 

164 **kwargs: Any, 

165 ) -> str: 

166 return "" 

167 

168 async def stop(self) -> None: 

169 pass 

170 

171 

172def _make_job(job_id: str, config: Dict[str, Any] | None = None) -> StreamMovieJob: 

173 """Create a StreamMovieJob with a mocked service manager and generator.""" 

174 service_manager = MagicMock() 

175 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

176 job = StreamMovieJob(job_id=job_id, config=config or {}, service_manager=service_manager) 

177 job.gen = LMMGeneratorMovieMock() 

178 return job 

179 

180 

181def _make_noisy_job(job_id: str, config: Dict[str, Any] | None = None) -> StreamMovieJob: 

182 """Create a StreamMovieJob backed by the noisy LLM mock.""" 

183 service_manager = MagicMock() 

184 service_manager.get_service_url = MagicMock(return_value="http://mock:1234") 

185 job = StreamMovieJob(job_id=job_id, config=config or {}, service_manager=service_manager) 

186 job.gen = LMMGeneratorMovieNoisyMock() 

187 return job 

188 

189 

190# --------------------------------------------------------------------------- 

191# HTTP route tests 

192# --------------------------------------------------------------------------- 

193 

194@pytest.mark.asyncio 

195async def test_app(test_app: Quart) -> None: 

196 """Check that GET / returns 200 with the correct content.""" 

197 await check_app_root(test_app, "StreamMovie") 

198 

199 

200@pytest.mark.asyncio 

201async def test_health(test_app: Quart) -> None: 

202 """Check /health.""" 

203 await check_health(test_app) 

204 

205 

206@pytest.mark.asyncio 

207async def test_files(test_app: Quart) -> None: 

208 """Check /files endpoint.""" 

209 await check_files(test_app, "streammovie") 

210 

211 

212@pytest.mark.asyncio 

213async def test_unknown_route(test_app: Quart) -> None: 

214 """Check that an unknown route returns 404.""" 

215 await check_unknown_route(test_app) 

216 

217 

218@pytest.mark.asyncio 

219async def test_job_status_page(test_app: Quart) -> None: 

220 """Check the web page for job status.""" 

221 await check_job_status_page(test_app) 

222 

223 

224@pytest.mark.asyncio 

225async def test_api_job_status(test_app: Quart) -> None: 

226 """Check the API for job status (returns UNKNOWN for nonexistent jobs).""" 

227 await check_api_job_status(test_app) 

228 

229 

230@pytest.mark.asyncio 

231async def test_api_job_requests(test_app: Quart) -> None: 

232 """Check the API for job requests listing (returns empty for nonexistent jobs).""" 

233 await check_api_job_requests(test_app) 

234 

235 

236@pytest.mark.asyncio 

237async def test_job_submit_page(test_app: Quart) -> None: 

238 """Check the web page for job submission.""" 

239 await check_job_submit_page(test_app) 

240 

241 

242@pytest.mark.asyncio 

243async def test_submit_job_page(test_app: Quart) -> None: 

244 """Check that GET /job returns the submit job form with movie_description field.""" 

245 client = test_app.test_client() 

246 response = await client.get("/job") 

247 assert response.status_code == HTTPStatus.OK 

248 response_html = await response.get_data(as_text=True) 

249 assert "movie_description" in response_html 

250 

251 

252@pytest.mark.asyncio 

253async def test_submit_job_no_service_manager(test_app: Quart) -> None: 

254 """POST /api/job without service manager should return 400.""" 

255 # Reset service manager to None 

256 original = streammovie_app.service_manager 

257 streammovie_app.service_manager = None 

258 try: 

259 client = test_app.test_client() 

260 response = await client.post("/api/job", json={"movie_description": "Test movie"}) 

261 assert response.status_code == HTTPStatus.BAD_REQUEST 

262 response_json = await response.get_json() 

263 assert "error" in response_json 

264 assert response_json["error"] == "Service manager not initialized" 

265 finally: 

266 streammovie_app.service_manager = original 

267 

268 

269@pytest.mark.asyncio 

270async def test_submit_job_no_description(test_app: Quart) -> None: 

271 """POST /api/job without movie_description triggers job failure, returning 400.""" 

272 streammovie_app.service_manager = MagicMock() 

273 streammovie_app.service_manager.get_service_url = MagicMock( 

274 return_value="http://mock_service_url:1234" 

275 ) 

276 client = test_app.test_client() 

277 response = await client.post("/api/job", json={"video_base64": "AAAA"}) 

278 # missing movie_description causes the job to fail with ValueError → 400 BAD REQUEST 

279 assert response.status_code == HTTPStatus.BAD_REQUEST 

280 response_json = await response.get_json() 

281 assert response_json is not None 

282 assert response_json["status"] == "error" 

283 assert "error" in response_json 

284 assert "movie_description" in response_json["error"] 

285 

286 

287# --------------------------------------------------------------------------- 

288# StreamMovieJob unit tests 

289# --------------------------------------------------------------------------- 

290 

291 

292@pytest.mark.asyncio 

293async def test_get_shot_deadline() -> None: 

294 """Deadline should equal submission_time + offset + buffer.""" 

295 job = _make_job("test_deadline") 

296 t0 = job.get_submission_time() 

297 

298 deadline_0 = job._get_shot_deadline(0, 4.0) 

299 deadline_1 = job._get_shot_deadline(1, 4.0) 

300 deadline_5 = job._get_shot_deadline(5, 4.0) 

301 

302 assert deadline_0 == pytest.approx(t0 + 0 * 4.0 + 120.0) 

303 assert deadline_1 == pytest.approx(t0 + 1 * 4.0 + 120.0) 

304 assert deadline_5 == pytest.approx(t0 + 5 * 4.0 + 120.0) 

305 

306 # Later shots have a later deadline (gives earlier shots higher priority) 

307 assert deadline_1 > deadline_0 

308 assert deadline_5 > deadline_1 

309 

310 await job.close() 

311 

312 

313@pytest.mark.asyncio 

314async def test_try_parse_json() -> None: 

315 """Valid JSON parses correctly; invalid JSON returns None.""" 

316 job = _make_job("test_parse_json") 

317 

318 result = job._try_parse_json('{"type": "shot_description", "shot_id": 1}', 1) 

319 assert result is not None 

320 assert result["type"] == "shot_description" 

321 

322 result = job._try_parse_json("not json at all", 2) 

323 assert result is None 

324 

325 result = job._try_parse_json("", 3) 

326 assert result is None 

327 

328 await job.close() 

329 

330 

331@pytest.mark.asyncio 

332async def test_stream_movie_script() -> None: 

333 """_stream_movie_script should return shot_description objects from LLM stream.""" 

334 job = _make_job("test_stream_script") 

335 

336 shots = await job._stream_movie_script("A cyberpunk heist movie.") 

337 

338 # Two shot_description objects are yielded by the mock 

339 assert len(shots) == 2 

340 assert shots[0]["shot_id"] == "S001" 

341 assert shots[1]["shot_id"] == "S002" 

342 

343 # Script file should be saved 

344 script_path = f"{job.job_path}/movie_script.jsonl" 

345 assert await aiofiles.os.path.exists(script_path) 

346 async with aiofiles.open(script_path) as f: 

347 content = await f.read() 

348 # All three JSONL lines should be present (metadata + 2 shots) 

349 lines = [line for line in content.strip().splitlines() if line] 

350 assert len(lines) == 3 

351 

352 await job.close() 

353 

354 

355@pytest.mark.asyncio 

356async def test_stream_movie_script_returns_all_shots() -> None: 

357 """_stream_movie_script returns ALL parsed shots; max_shots cap is applied in gen_movie.""" 

358 job = _make_job("test_all_shots", config={"max_shots": 1}) 

359 

360 shots = await job._stream_movie_script("A sci-fi adventure.") 

361 # The LLM mock emits 2 shots; _stream_movie_script collects all of them. 

362 # The max_shots cap (1) is enforced by gen_movie, not _stream_movie_script. 

363 assert len(shots) == 2 

364 

365 await job.close() 

366 

367 

368@pytest.mark.asyncio 

369async def test_gen_shot_no_dialogue() -> None: 

370 """_gen_shot without dialogue should produce a plain video.""" 

371 job = _make_job("test_gen_shot_no_dialogue", config={"output_mode": "video_audio_synced"}) 

372 

373 shot = { 

374 "visual_prompt": "A wide panoramic shot of mountains.", 

375 "negative_prompt": "", 

376 "dialogue": None, 

377 "technical_specs": {"duration_seconds": 4.0}, 

378 } 

379 

380 shot_path = await job._gen_shot(0, shot) 

381 assert shot_path is not None 

382 assert shot_path.endswith(".mp4") 

383 assert await aiofiles.os.path.exists(shot_path) 

384 

385 # Image for the shot should also have been saved 

386 image_path = f"{job.job_path}/shot_000.png" 

387 assert await aiofiles.os.path.exists(image_path) 

388 

389 await job.close() 

390 

391 

392@pytest.mark.asyncio 

393async def test_gen_shot_with_dialogue_synced() -> None: 

394 """_gen_shot with dialogue in VIDEO_AUDIO_SYNCED mode uses gen_video_audio_from_img.""" 

395 job = _make_job("test_gen_shot_synced", config={"output_mode": "video_audio_synced"}) 

396 

397 shot = { 

398 "visual_prompt": "Close-up of the hero.", 

399 "negative_prompt": "blur", 

400 "dialogue": "I will stop them.", 

401 "technical_specs": {"duration_seconds": 3.0}, 

402 } 

403 

404 shot_path = await job._gen_shot(0, shot) 

405 assert shot_path is not None 

406 assert shot_path.endswith(".mp4") 

407 assert await aiofiles.os.path.exists(shot_path) 

408 

409 await job.close() 

410 

411 

412@pytest.mark.asyncio 

413async def test_gen_shot_with_dialogue_unsynced() -> None: 

414 """_gen_shot with dialogue in VIDEO_AUDIO_UNSYNCED mode merges audio separately.""" 

415 job = _make_job("test_gen_shot_unsynced", config={"output_mode": "video_audio_unsynced"}) 

416 

417 shot = { 

418 "visual_prompt": "Hero walking through rain.", 

419 "negative_prompt": "", 

420 "dialogue": "We have to go now.", 

421 "technical_specs": {"duration_seconds": 3.0}, 

422 } 

423 

424 shot_path = await job._gen_shot(0, shot) 

425 assert shot_path is not None 

426 assert shot_path.endswith(".mp4") 

427 assert await aiofiles.os.path.exists(shot_path) 

428 

429 await job.close() 

430 

431 

432@pytest.mark.asyncio 

433async def test_gen_shot_with_dialogue_audio_only() -> None: 

434 """In AUDIO_ONLY mode, dialogue presence doesn't trigger video+audio from img path.""" 

435 job = _make_job("test_gen_shot_audio_only", config={"output_mode": "audio_only"}) 

436 

437 shot = { 

438 "visual_prompt": "Hero close-up.", 

439 "negative_prompt": "", 

440 "dialogue": "Audio only dialogue.", 

441 "technical_specs": {"duration_seconds": 4.0}, 

442 } 

443 

444 shot_path = await job._gen_shot(0, shot) 

445 assert shot_path is not None 

446 assert shot_path.endswith(".mp4") 

447 assert await aiofiles.os.path.exists(shot_path) 

448 

449 await job.close() 

450 

451 

452@pytest.mark.asyncio 

453async def test_gen_movie_missing_description() -> None: 

454 """gen_movie with no description should fail immediately with ValueError.""" 

455 job = _make_job("test_gen_movie_no_desc") 

456 

457 with pytest.raises(ValueError, match="Missing 'movie_description'"): 

458 await job.gen_movie(None) 

459 

460 job_status = await job.get_status() 

461 assert job_status == JobStatus.FAILED 

462 

463 await job.close() 

464 

465 

466@pytest.mark.asyncio 

467async def test_gen_movie_success() -> None: 

468 """gen_movie end-to-end: produces a final .mp4 file.""" 

469 job = _make_job("test_gen_movie_success", config={"output_mode": "video_audio_synced"}) 

470 

471 await job.gen_movie("A short heist movie in Neo-Tokyo.") 

472 

473 job_status = await job.get_status() 

474 assert job_status == JobStatus.COMPLETED 

475 

476 final_path = f"{job.job_path}/{job.job_id}.mp4" 

477 assert await aiofiles.os.path.exists(final_path) 

478 

479 await job.close() 

480 

481 

482@pytest.mark.asyncio 

483async def test_gen_movie_max_shots_limits_output() -> None: 

484 """When max_shots=1, only one shot should be generated.""" 

485 job = _make_job("test_gen_movie_max1", config={"max_shots": 1}) 

486 

487 await job.gen_movie("A one-shot thriller.") 

488 

489 # Only shot_000.mp4 should exist; shot_001 should not 

490 assert await aiofiles.os.path.exists(f"{job.job_path}/shot_000.mp4") 

491 assert not await aiofiles.os.path.exists(f"{job.job_path}/shot_001.mp4") 

492 

493 job_status = await job.get_status() 

494 assert job_status == JobStatus.COMPLETED 

495 

496 await job.close() 

497 

498 

499@pytest.mark.asyncio 

500async def test_gen_movie_script_saved() -> None: 

501 """After gen_movie, the raw JSONL script should be on disk.""" 

502 job = _make_job("test_gen_movie_script_saved") 

503 

504 await job.gen_movie("A space adventure.") 

505 

506 script_path = f"{job.job_path}/movie_script.jsonl" 

507 assert await aiofiles.os.path.exists(script_path) 

508 async with aiofiles.open(script_path) as f: 

509 content = await f.read() 

510 assert "shot_description" in content 

511 

512 await job.close() 

513 

514 

515def test_build_movie_messages() -> None: 

516 """Test that build_movie_messages includes SYSTEM_PROMPT and user description.""" 

517 messages = StreamMovieJob.build_movie_messages("a sci-fi thriller") 

518 assert len(messages) == 2 

519 assert messages[0]["role"] == "system" 

520 assert "filmmaker" in messages[0]["content"] 

521 assert messages[1]["role"] == "user" 

522 assert "sci-fi thriller" in messages[1]["content"] 

523 # No shot count instruction when max_shots is not specified 

524 assert "EXACTLY" not in messages[1]["content"] 

525 

526 

527def test_build_movie_messages_with_max_shots() -> None: 

528 """build_movie_messages with max_shots includes the exact-count instruction.""" 

529 messages = StreamMovieJob.build_movie_messages("a heist drama", max_shots=3) 

530 assert len(messages) == 2 

531 user_content = messages[1]["content"] 

532 assert "heist drama" in user_content 

533 assert "EXACTLY 3 shots" in user_content 

534 

535 

536@pytest.mark.asyncio 

537async def test_api_get_movie_script_jsonl(test_app: Quart) -> None: 

538 """GET /api/job/{job_id}/movie_script.jsonl returns the raw JSONL content as text.""" 

539 job = _make_job("test_api_jsonl") 

540 await job._stream_movie_script("A noir detective story.") 

541 

542 client = test_app.test_client() 

543 response = await client.get(f"/api/job/{job.job_id}/movie_script.jsonl") 

544 assert response.status_code == HTTPStatus.OK 

545 content = await response.get_data(as_text=True) 

546 assert "shot_description" in content 

547 assert "movie_metadata" in content 

548 

549 await job.close() 

550 

551 

552@pytest.mark.asyncio 

553async def test_api_get_movie_script_jsonl_not_found(test_app: Quart) -> None: 

554 """GET /api/job/{job_id}/movie_script.jsonl returns error JSON when file is missing.""" 

555 client = test_app.test_client() 

556 response = await client.get("/api/job/nonexistent_job/movie_script.jsonl") 

557 assert response.status_code == HTTPStatus.OK 

558 data = await response.get_json() 

559 assert data is not None 

560 assert data["status"] == "error" 

561 assert "not found" in data["error"] 

562 

563 

564@pytest.mark.asyncio 

565async def test_stream_movie_script_filters_noise() -> None: 

566 """Non-JSON prose lines from the LLM are excluded from the saved script file.""" 

567 job = _make_noisy_job("test_noise_filter") 

568 

569 shots = await job._stream_movie_script("A noisy sci-fi drama.") 

570 

571 # 2 shot_description objects should still be extracted despite the noise 

572 assert len(shots) == 2 

573 assert shots[0]["shot_id"] == "S001" 

574 assert shots[1]["shot_id"] == "S002" 

575 

576 # The saved script file must contain ONLY valid JSON lines 

577 script_path = f"{job.job_path}/movie_script.jsonl" 

578 assert await aiofiles.os.path.exists(script_path) 

579 async with aiofiles.open(script_path) as f: 

580 content = await f.read() 

581 

582 lines = [line for line in content.strip().splitlines() if line] 

583 # Only 3 valid JSON lines: movie_metadata + 2 shot_descriptions 

584 assert len(lines) == 3 

585 for line in lines: 

586 parsed = json.loads(line) # must not raise 

587 assert "type" in parsed 

588 

589 # Noise strings must NOT appear in the file at all 

590 assert "Okay, here" not in content 

591 assert "explanatory paragraph" not in content 

592 assert "notes about the next act" not in content 

593 assert "```" not in content 

594 

595 await job.close() 

596 

597 

598@pytest.mark.asyncio 

599async def test_stream_movie_script_max_shots_instruction() -> None: 

600 """When max_shots is set, the LLM receives an exact shot-count instruction.""" 

601 job = _make_job("test_max_shots_instruction", config={"max_shots": 3}) 

602 mock_gen = job.gen 

603 

604 await job._stream_movie_script("A thriller.", max_shots=3) 

605 

606 # The last messages sent to the LLM must include the exact-count instruction 

607 assert mock_gen.last_messages, "No messages were captured by the mock" 

608 user_message = mock_gen.last_messages[-1] 

609 assert user_message["role"] == "user" 

610 assert "EXACTLY 3 shots" in user_message["content"] 

611 

612 await job.close() 

613 

614 

615@pytest.mark.asyncio 

616async def test_stream_movie_script_no_shot_instruction_when_unset() -> None: 

617 """When max_shots is not set, the LLM message has no exact-count instruction.""" 

618 job = _make_job("test_no_shot_instruction") 

619 mock_gen = job.gen 

620 

621 await job._stream_movie_script("A comedy.", max_shots=-1) 

622 

623 assert mock_gen.last_messages, "No messages were captured by the mock" 

624 user_message = mock_gen.last_messages[-1] 

625 assert "EXACTLY" not in user_message["content"] 

626 

627 await job.close()