Coverage for tests/streamwise_app/test_streamlecture.py: 94%

189 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 StreamLecture. 

4""" 

5 

6import os 

7import sys 

8import pytest 

9 

10from http import HTTPStatus 

11 

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

13 

14from quart import Quart 

15 

16from unittest.mock import patch 

17from unittest.mock import MagicMock 

18 

19# Add current path 

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

21 

22from file_utils import read_file_base64 

23 

24from tests.test_utils import temp_sys_path 

25from tests.torch_mock import TorchMock 

26from tests.streamwise_app.app_test_helpers import check_app_root 

27from tests.streamwise_app.app_test_helpers import check_health 

28from tests.streamwise_app.app_test_helpers import check_files 

29from tests.streamwise_app.app_test_helpers import check_unknown_route 

30from tests.streamwise_app.app_test_helpers import check_job_submit_page 

31from tests.streamwise_app.app_test_helpers import check_job_status_page 

32from tests.streamwise_app.app_test_helpers import check_api_job_status 

33from tests.streamwise_app.app_test_helpers import check_api_job_requests 

34 

35mock_torch = TorchMock() 

36 

37mock_modules = {} 

38mock_modules.update(mock_torch.get_sub_modules()) 

39 

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

41 with temp_sys_path("apps", "apps/streamlecture"): 

42 from apps.streamlecture.streamlecture import StreamLectureApp 

43 from apps.streamlecture.streamlecture_job import StreamLectureJob 

44 from apps.streamlecture.streamlecture_job import JobStatus 

45 from apps.streamlecture.lecture_prompts import IMG_PROMPT 

46 from apps.streamlecture.lecture_prompts import VIDEO_PROMPT 

47 from tests.streamwise_app.lmm_generator_mock import LMMGeneratorMock 

48 

49 

50streamlecture_app = StreamLectureApp() 

51 

52 

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

54def _test_app() -> Quart: 

55 return streamlecture_app.app 

56 

57 

58@pytest.mark.asyncio 

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

60 """Check that GET / returns 200.""" 

61 await check_app_root(test_app, "StreamLecture") 

62 

63 

64@pytest.mark.asyncio 

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

66 """Check /health.""" 

67 await check_health(test_app) 

68 

69 

70@pytest.mark.asyncio 

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

72 """Check /files endpoint.""" 

73 await check_files(test_app, "streamlecture") 

74 

75 

76@pytest.mark.asyncio 

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

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

79 await check_unknown_route(test_app) 

80 

81 

82@pytest.mark.asyncio 

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

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

85 await check_job_submit_page(test_app) 

86 

87 

88@pytest.mark.asyncio 

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

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

91 await check_job_status_page(test_app) 

92 

93 

94@pytest.mark.asyncio 

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

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

97 await check_api_job_status(test_app) 

98 

99 

100@pytest.mark.asyncio 

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

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

103 await check_api_job_requests(test_app) 

104 

105 

106@pytest.mark.asyncio 

107async def test_submit_job(test_app: Quart) -> None: 

108 """Check the API for job requests.""" 

109 client = test_app.test_client() 

110 

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

112 assert response.status_code == HTTPStatus.BAD_REQUEST 

113 response_json = await response.get_json() 

114 assert "error" in response_json 

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

116 

117 # Mock the service manager 

118 streamlecture_app.service_manager = MagicMock() 

119 streamlecture_app.service_manager.get_service_url = MagicMock( 

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

121 ) 

122 

123 pdf_path = "tests/data/blank.pdf" 

124 pdf_base64 = await read_file_base64(pdf_path) 

125 response = await client.post("/api/job", json={"pdf_base64": pdf_base64}) 

126 # The job is accepted; it may fail later if external services are unavailable. 

127 response_json = await response.get_json() 

128 assert response_json is not None 

129 assert "status" in response_json 

130 # If the job started before any service call fails, we get job_id back 

131 # If a service call fails synchronously (e.g. DNS error), we get an error 

132 if response.status_code == HTTPStatus.OK: 

133 assert "job_id" in response_json 

134 assert response_json["status"] == "success" 

135 else: 

136 assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR 

137 assert "error" in response_json 

138 

139 

140def test_lecture_prompts() -> None: 

141 """Test that lecture prompts are defined and non-empty.""" 

142 assert IMG_PROMPT 

143 assert "classroom" in IMG_PROMPT.lower() or "professor" in IMG_PROMPT.lower() 

144 assert VIDEO_PROMPT 

145 assert len(VIDEO_PROMPT) > 0 

146 

147 

148@pytest.mark.asyncio 

149async def test_gen_lecture_no_pdf() -> None: 

150 """StreamLectureJob.gen_lecture with missing pdf raises ValueError.""" 

151 service_manager = MagicMock() 

152 job = StreamLectureJob( 

153 job_id="test_no_pdf", 

154 service_manager=service_manager, 

155 ) 

156 with pytest.raises(ValueError, match="Missing 'pdf_base64' in request"): 

157 await job.gen_lecture(pdf_base64=None) 

158 job_status = await job.get_status() 

159 assert job_status == JobStatus.FAILED 

160 

161 del job 

162 del service_manager 

163 

164 

165@pytest.mark.asyncio 

166async def test_gen_lecture_invalid_pdf() -> None: 

167 """StreamLectureJob.gen_lecture with invalid base64 raises ValueError.""" 

168 service_manager = MagicMock() 

169 job = StreamLectureJob( 

170 job_id="test_invalid_pdf", 

171 service_manager=service_manager, 

172 ) 

173 with pytest.raises(ValueError, match="Invalid base64-encoded string"): 

174 await job.gen_lecture(pdf_base64="AAAAA") 

175 job_status = await job.get_status() 

176 assert job_status == JobStatus.FAILED 

177 

178 del job 

179 del service_manager 

180 

181 

182@pytest.mark.asyncio 

183async def test_gen_lecture_mock() -> None: 

184 """StreamLectureJob.gen_lecture with mocked services completes or fails gracefully.""" 

185 service_manager = MagicMock() 

186 job = StreamLectureJob( 

187 job_id="test_gen_lecture_mock", 

188 service_manager=service_manager, 

189 ) 

190 job.gen = LMMGeneratorMock() 

191 

192 pdf_path = "tests/data/blank.pdf" 

193 pdf_base64 = await read_file_base64(pdf_path) 

194 try: 

195 await job.gen_lecture(pdf_base64=pdf_base64) 

196 job_status = await job.get_status() 

197 assert job_status == JobStatus.COMPLETED 

198 except (FileNotFoundError, ValueError): 

199 # ffmpeg not available in this environment; job fails gracefully 

200 job_status = await job.get_status() 

201 assert job_status == JobStatus.FAILED 

202 

203 del job 

204 del service_manager 

205 

206 

207@pytest.mark.asyncio 

208async def test_gen_scene_no_image() -> None: 

209 """StreamLectureJob.gen_scene with no image raises ValueError.""" 

210 service_manager = MagicMock() 

211 job = StreamLectureJob( 

212 job_id="test_gen_scene_no_image", 

213 service_manager=service_manager, 

214 ) 

215 job.gen = LMMGeneratorMock() 

216 # With no image set, gen_scene raises ValueError after generating audio 

217 with pytest.raises(ValueError, match="Classroom image not available for video generation"): 

218 await job.gen_scene(scene_id=0, text="Test text.") 

219 

220 del job 

221 del service_manager 

222 

223 

224@pytest.mark.asyncio 

225async def test_gen_scene_audio_only() -> None: 

226 """StreamLectureJob.gen_scene in audio_only mode returns audio path.""" 

227 service_manager = MagicMock() 

228 job = StreamLectureJob( 

229 job_id="test_gen_scene_audio_only", 

230 service_manager=service_manager, 

231 ) 

232 job.gen = LMMGeneratorMock() 

233 job.config["output_mode"] = "audio_only" 

234 # Set a mock classroom image (required even in audio_only mode) 

235 job.image = Image.new("RGB", (640, 400), color="white") 

236 

237 result = await job.gen_scene(scene_id=0, text="Test lecture text.") 

238 assert result.endswith(".wav") 

239 assert os.path.exists(result) 

240 

241 del job 

242 del service_manager 

243 

244 

245@pytest.mark.asyncio 

246async def test_gen_scene_video_audio_synced() -> None: 

247 """StreamLectureJob.gen_scene in video_audio_synced mode with short audio uses gen_video_audio_from_img.""" 

248 service_manager = MagicMock() 

249 job = StreamLectureJob( 

250 job_id="test_gen_scene_synced", 

251 service_manager=service_manager, 

252 ) 

253 job.gen = LMMGeneratorMock() 

254 job.config["output_mode"] = "video_audio_synced" 

255 job.image = Image.new("RGB", (160, 100), color="white") 

256 

257 try: 

258 result = await job.gen_scene(scene_id=0, text="Test synced scene.") 

259 # If ffmpeg is available the result is an mp4 path 

260 assert result.endswith(".mp4") 

261 assert os.path.exists(result) 

262 except FileNotFoundError: 

263 pass # ffmpeg not available in test environment 

264 

265 del job 

266 del service_manager 

267 

268 

269@pytest.mark.asyncio 

270async def test_gen_scene_video_audio_unsynced() -> None: 

271 """StreamLectureJob.gen_scene in video_audio_unsynced mode uses gen_video.""" 

272 service_manager = MagicMock() 

273 job = StreamLectureJob( 

274 job_id="test_gen_scene_unsynced", 

275 service_manager=service_manager, 

276 ) 

277 job.gen = LMMGeneratorMock() 

278 job.config["output_mode"] = "video_audio_unsynced" 

279 job.image = Image.new("RGB", (160, 100), color="white") 

280 

281 try: 

282 result = await job.gen_scene(scene_id=0, text="Test unsynced scene.") 

283 assert result.endswith(".mp4") 

284 assert os.path.exists(result) 

285 except FileNotFoundError: 

286 pass # ffmpeg not available in test environment 

287 

288 del job 

289 del service_manager 

290 

291 

292@pytest.mark.asyncio 

293async def test_gen_lecture_audio_only() -> None: 

294 """StreamLectureJob.gen_lecture in audio_only mode skips video generation.""" 

295 service_manager = MagicMock() 

296 job = StreamLectureJob( 

297 job_id="test_gen_lecture_audio_only", 

298 service_manager=service_manager, 

299 ) 

300 job.gen = LMMGeneratorMock() 

301 job.config["output_mode"] = "audio_only" 

302 

303 pdf_path = "tests/data/blank.pdf" 

304 pdf_base64 = await read_file_base64(pdf_path) 

305 try: 

306 await job.gen_lecture(pdf_base64=pdf_base64) 

307 job_status = await job.get_status() 

308 assert job_status == JobStatus.COMPLETED 

309 except (FileNotFoundError, ValueError): 

310 job_status = await job.get_status() 

311 assert job_status == JobStatus.FAILED 

312 

313 del job 

314 del service_manager