karaoke-gen 0.86.7__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,918 @@
1
+ """
2
+ Unit tests for Pydantic models.
3
+
4
+ Tests validate that our data models work correctly, including:
5
+ - Basic field validation
6
+ - Nested dictionary structures (file_urls, state_data)
7
+ - Serialization/deserialization round-trips (simulating Firestore)
8
+ - Real-world data structures from workers
9
+
10
+ These tests catch type mismatches that would cause runtime errors when
11
+ reading data back from Firestore.
12
+ """
13
+ import pytest
14
+ import json
15
+ from datetime import datetime, UTC
16
+ from pydantic import ValidationError
17
+
18
+ from backend.models.job import (
19
+ Job, JobCreate, JobStatus, TimelineEvent
20
+ )
21
+
22
+
23
+ class TestJobModel:
24
+ """Test Job Pydantic model - the bug we just fixed!"""
25
+
26
+ def test_input_media_gcs_path_field_exists(self):
27
+ """
28
+ Test that Job model has input_media_gcs_path field.
29
+
30
+ This test would have caught the bug where we tried to use
31
+ job.input_media_gcs_path but it wasn't defined in the model!
32
+ """
33
+ job = Job(
34
+ job_id="test123",
35
+ status=JobStatus.PENDING,
36
+ created_at=datetime.now(UTC),
37
+ updated_at=datetime.now(UTC),
38
+ input_media_gcs_path="uploads/test123/file.flac"
39
+ )
40
+
41
+ # This should NOT raise AttributeError
42
+ assert hasattr(job, 'input_media_gcs_path')
43
+ assert job.input_media_gcs_path == "uploads/test123/file.flac"
44
+
45
+ def test_input_media_gcs_path_optional(self):
46
+ """Test that input_media_gcs_path is optional."""
47
+ job = Job(
48
+ job_id="test123",
49
+ status=JobStatus.PENDING,
50
+ created_at=datetime.now(UTC),
51
+ updated_at=datetime.now(UTC)
52
+ )
53
+
54
+ assert job.input_media_gcs_path is None
55
+
56
+ def test_pydantic_includes_input_media_gcs_path_in_dict(self):
57
+ """
58
+ Test that Pydantic includes input_media_gcs_path in serialization.
59
+
60
+ This ensures Pydantic doesn't silently ignore the field.
61
+ """
62
+ job = Job(
63
+ job_id="test123",
64
+ status=JobStatus.PENDING,
65
+ created_at=datetime.now(UTC),
66
+ updated_at=datetime.now(UTC),
67
+ input_media_gcs_path="uploads/test123/file.flac"
68
+ )
69
+
70
+ job_dict = job.model_dump()
71
+
72
+ # Pydantic should include it
73
+ assert "input_media_gcs_path" in job_dict
74
+ assert job_dict["input_media_gcs_path"] == "uploads/test123/file.flac"
75
+
76
+ def test_create_minimal_job(self):
77
+ """Test creating a job with minimal required fields."""
78
+ job = Job(
79
+ job_id="test123",
80
+ status=JobStatus.PENDING,
81
+ created_at=datetime.now(UTC),
82
+ updated_at=datetime.now(UTC)
83
+ )
84
+
85
+ assert job.job_id == "test123"
86
+ assert job.status == JobStatus.PENDING
87
+ assert job.progress == 0
88
+ assert job.url is None
89
+ assert job.input_media_gcs_path is None
90
+
91
+ def test_create_job_with_url(self):
92
+ """Test creating a job with YouTube URL."""
93
+ job = Job(
94
+ job_id="test123",
95
+ status=JobStatus.PENDING,
96
+ created_at=datetime.now(UTC),
97
+ updated_at=datetime.now(UTC),
98
+ url="https://youtube.com/watch?v=test",
99
+ artist="Test Artist",
100
+ title="Test Song"
101
+ )
102
+
103
+ assert job.url == "https://youtube.com/watch?v=test"
104
+ assert job.artist == "Test Artist"
105
+ assert job.title == "Test Song"
106
+
107
+ def test_create_job_with_uploaded_file(self):
108
+ """Test creating a job with uploaded file."""
109
+ job = Job(
110
+ job_id="test123",
111
+ status=JobStatus.PENDING,
112
+ created_at=datetime.now(UTC),
113
+ updated_at=datetime.now(UTC),
114
+ input_media_gcs_path="uploads/test123/file.flac",
115
+ artist="Test Artist",
116
+ title="Test Song"
117
+ )
118
+
119
+ assert job.input_media_gcs_path == "uploads/test123/file.flac"
120
+ assert job.url is None
121
+
122
+
123
+ class TestJobCreate:
124
+ """Test JobCreate validation model."""
125
+
126
+ def test_create_with_url(self):
127
+ """Test creating job with YouTube URL."""
128
+ job_create = JobCreate(
129
+ url="https://youtube.com/watch?v=test",
130
+ artist="Test Artist",
131
+ title="Test Song"
132
+ )
133
+
134
+ assert job_create.url == "https://youtube.com/watch?v=test"
135
+ assert job_create.artist == "Test Artist"
136
+ assert job_create.title == "Test Song"
137
+
138
+ def test_create_minimal(self):
139
+ """Test creating job with minimal fields."""
140
+ job_create = JobCreate()
141
+
142
+ assert job_create.url is None
143
+ assert job_create.artist is None
144
+ assert job_create.title is None
145
+
146
+
147
+ class TestJobStatus:
148
+ """Test JobStatus enum."""
149
+
150
+ def test_critical_statuses_defined(self):
151
+ """Test that critical statuses exist."""
152
+ critical_statuses = [
153
+ "pending", "downloading",
154
+ "separating_stage1", "separating_stage2", "audio_complete",
155
+ "transcribing", "correcting", "lyrics_complete",
156
+ "generating_screens", "applying_padding",
157
+ "awaiting_review", "in_review", "review_complete",
158
+ "awaiting_instrumental_selection", "instrumental_selected",
159
+ "generating_video", "encoding", "packaging",
160
+ "complete", "failed"
161
+ ]
162
+
163
+ actual_statuses = [status.value for status in JobStatus]
164
+
165
+ for status in critical_statuses:
166
+ assert status in actual_statuses, f"Missing critical status: {status}"
167
+
168
+
169
+ class TestTimelineEvent:
170
+ """Test TimelineEvent model."""
171
+
172
+ def test_create_timeline_event(self):
173
+ """Test creating a timeline event."""
174
+ event = TimelineEvent(
175
+ status="pending",
176
+ timestamp="2025-12-01T08:00:00Z",
177
+ progress=0,
178
+ message="Job created"
179
+ )
180
+
181
+ assert event.status == "pending"
182
+ assert event.timestamp == "2025-12-01T08:00:00Z"
183
+ assert event.progress == 0
184
+ assert event.message == "Job created"
185
+
186
+ def test_timeline_event_optional_fields(self):
187
+ """Test that progress and message are optional."""
188
+ event = TimelineEvent(
189
+ status="pending",
190
+ timestamp="2025-12-01T08:00:00Z"
191
+ )
192
+
193
+ assert event.progress is None
194
+ assert event.message is None
195
+
196
+
197
+ class TestModelValidation:
198
+ """Test model validation rules."""
199
+
200
+ def test_invalid_job_status(self):
201
+ """Test that invalid status is rejected."""
202
+ with pytest.raises((ValidationError, ValueError)):
203
+ Job(
204
+ job_id="test123",
205
+ status="invalid_status", # Not a valid JobStatus
206
+ created_at=datetime.now(UTC),
207
+ updated_at=datetime.now(UTC)
208
+ )
209
+
210
+ def test_missing_required_fields(self):
211
+ """Test that missing required fields are rejected."""
212
+ with pytest.raises(ValidationError):
213
+ Job(
214
+ job_id="test123"
215
+ # Missing required fields: status, created_at, updated_at
216
+ )
217
+
218
+
219
+ class TestFileUrlsNestedStructure:
220
+ """
221
+ Test that file_urls field correctly handles nested dictionaries.
222
+
223
+ THIS IS THE TEST THAT WOULD HAVE CAUGHT THE Dict[str, str] vs Dict[str, Any] BUG!
224
+
225
+ Workers store nested structures like:
226
+ {
227
+ "stems": {"instrumental_clean": "gs://...", "vocals": "gs://..."},
228
+ "lyrics": {"corrections": "gs://...", "lrc": "gs://..."},
229
+ ...
230
+ }
231
+
232
+ If file_urls is typed as Dict[str, str], this will fail on deserialization.
233
+ """
234
+
235
+ def test_file_urls_with_nested_stems(self):
236
+ """Test that file_urls accepts nested stems dictionary."""
237
+ job = Job(
238
+ job_id="test123",
239
+ status=JobStatus.AUDIO_COMPLETE,
240
+ created_at=datetime.now(UTC),
241
+ updated_at=datetime.now(UTC),
242
+ file_urls={
243
+ "stems": {
244
+ "instrumental_clean": "gs://bucket/jobs/test123/stems/instrumental_clean.flac",
245
+ "instrumental_with_backing": "gs://bucket/jobs/test123/stems/instrumental_with_backing.flac",
246
+ "vocals_clean": "gs://bucket/jobs/test123/stems/vocals_clean.flac",
247
+ "lead_vocals": "gs://bucket/jobs/test123/stems/lead_vocals.flac",
248
+ "backing_vocals": "gs://bucket/jobs/test123/stems/backing_vocals.flac",
249
+ "bass": "gs://bucket/jobs/test123/stems/bass.flac",
250
+ "drums": "gs://bucket/jobs/test123/stems/drums.flac",
251
+ }
252
+ }
253
+ )
254
+
255
+ assert job.file_urls["stems"]["instrumental_clean"] == "gs://bucket/jobs/test123/stems/instrumental_clean.flac"
256
+ assert len(job.file_urls["stems"]) == 7
257
+
258
+ def test_file_urls_with_nested_lyrics(self):
259
+ """Test that file_urls accepts nested lyrics dictionary."""
260
+ job = Job(
261
+ job_id="test123",
262
+ status=JobStatus.LYRICS_COMPLETE,
263
+ created_at=datetime.now(UTC),
264
+ updated_at=datetime.now(UTC),
265
+ file_urls={
266
+ "lyrics": {
267
+ "corrections": "gs://bucket/jobs/test123/lyrics/corrections.json",
268
+ "audio": "gs://bucket/jobs/test123/lyrics/audio.flac",
269
+ "lrc": "gs://bucket/jobs/test123/lyrics/karaoke.lrc",
270
+ "uncorrected": "gs://bucket/jobs/test123/lyrics/uncorrected.txt",
271
+ }
272
+ }
273
+ )
274
+
275
+ assert job.file_urls["lyrics"]["corrections"] == "gs://bucket/jobs/test123/lyrics/corrections.json"
276
+
277
+ def test_file_urls_full_worker_structure(self):
278
+ """
279
+ Test with the full structure that workers actually create.
280
+
281
+ This simulates what actually gets stored in Firestore after
282
+ all workers have run.
283
+ """
284
+ full_file_urls = {
285
+ "input": {
286
+ "audio": "gs://bucket/jobs/test123/input/waterloo.flac"
287
+ },
288
+ "stems": {
289
+ "instrumental_clean": "gs://bucket/jobs/test123/stems/instrumental_clean.flac",
290
+ "instrumental_with_backing": "gs://bucket/jobs/test123/stems/instrumental_with_backing.flac",
291
+ "vocals_clean": "gs://bucket/jobs/test123/stems/vocals_clean.flac",
292
+ "lead_vocals": "gs://bucket/jobs/test123/stems/lead_vocals.flac",
293
+ "backing_vocals": "gs://bucket/jobs/test123/stems/backing_vocals.flac",
294
+ },
295
+ "lyrics": {
296
+ "corrections": "gs://bucket/jobs/test123/lyrics/corrections.json",
297
+ "lrc": "gs://bucket/jobs/test123/lyrics/karaoke.lrc",
298
+ },
299
+ "screens": {
300
+ "title": "gs://bucket/jobs/test123/screens/title.mov",
301
+ "end": "gs://bucket/jobs/test123/screens/end.mov",
302
+ },
303
+ "videos": {
304
+ "with_vocals": "gs://bucket/jobs/test123/videos/with_vocals.mkv",
305
+ },
306
+ "finals": {
307
+ "lossless_4k_mp4": "gs://bucket/jobs/test123/finals/lossless_4k.mp4",
308
+ "lossy_720p_mp4": "gs://bucket/jobs/test123/finals/lossy_720p.mp4",
309
+ }
310
+ }
311
+
312
+ job = Job(
313
+ job_id="test123",
314
+ status=JobStatus.COMPLETE,
315
+ created_at=datetime.now(UTC),
316
+ updated_at=datetime.now(UTC),
317
+ file_urls=full_file_urls
318
+ )
319
+
320
+ # Verify nested access works
321
+ assert job.file_urls["stems"]["instrumental_clean"].endswith("instrumental_clean.flac")
322
+ assert job.file_urls["lyrics"]["corrections"].endswith("corrections.json")
323
+ assert job.file_urls["finals"]["lossy_720p_mp4"].endswith("lossy_720p.mp4")
324
+
325
+ def test_file_urls_roundtrip_serialization(self):
326
+ """
327
+ Test that file_urls survives serialization/deserialization.
328
+
329
+ This simulates what happens when we:
330
+ 1. Create a Job with nested file_urls
331
+ 2. Serialize to dict (for Firestore)
332
+ 3. Deserialize back to Job (reading from Firestore)
333
+
334
+ THIS IS THE KEY TEST - it would catch the Dict[str, str] bug!
335
+ """
336
+ original_file_urls = {
337
+ "stems": {
338
+ "instrumental_clean": "gs://bucket/stems/clean.flac",
339
+ "vocals": "gs://bucket/stems/vocals.flac",
340
+ },
341
+ "lyrics": {
342
+ "corrections": "gs://bucket/lyrics/corrections.json",
343
+ }
344
+ }
345
+
346
+ # Create job
347
+ job = Job(
348
+ job_id="test123",
349
+ status=JobStatus.PENDING,
350
+ created_at=datetime.now(UTC),
351
+ updated_at=datetime.now(UTC),
352
+ file_urls=original_file_urls
353
+ )
354
+
355
+ # Serialize to dict (simulates writing to Firestore)
356
+ job_dict = job.model_dump()
357
+
358
+ # Convert to JSON and back (simulates Firestore storage)
359
+ json_str = json.dumps(job_dict, default=str)
360
+ restored_dict = json.loads(json_str)
361
+
362
+ # Deserialize back to Job (simulates reading from Firestore)
363
+ restored_job = Job(**restored_dict)
364
+
365
+ # Verify nested structure survived
366
+ assert restored_job.file_urls["stems"]["instrumental_clean"] == "gs://bucket/stems/clean.flac"
367
+ assert restored_job.file_urls["stems"]["vocals"] == "gs://bucket/stems/vocals.flac"
368
+ assert restored_job.file_urls["lyrics"]["corrections"] == "gs://bucket/lyrics/corrections.json"
369
+
370
+
371
+ class TestStateDataNestedStructure:
372
+ """Test that state_data field correctly handles nested dictionaries."""
373
+
374
+ def test_state_data_with_instrumental_options(self):
375
+ """Test state_data with instrumental options structure."""
376
+ job = Job(
377
+ job_id="test123",
378
+ status=JobStatus.AWAITING_INSTRUMENTAL_SELECTION,
379
+ created_at=datetime.now(UTC),
380
+ updated_at=datetime.now(UTC),
381
+ state_data={
382
+ "instrumental_options": {
383
+ "clean": "gs://bucket/jobs/test123/stems/instrumental_clean.flac",
384
+ "with_backing": "gs://bucket/jobs/test123/stems/instrumental_with_backing.flac",
385
+ },
386
+ "audio_complete": True,
387
+ "lyrics_complete": True,
388
+ }
389
+ )
390
+
391
+ assert job.state_data["instrumental_options"]["clean"].endswith("clean.flac")
392
+ assert job.state_data["audio_complete"] is True
393
+
394
+ def test_state_data_with_lyrics_metadata(self):
395
+ """Test state_data with lyrics metadata structure."""
396
+ job = Job(
397
+ job_id="test123",
398
+ status=JobStatus.LYRICS_COMPLETE,
399
+ created_at=datetime.now(UTC),
400
+ updated_at=datetime.now(UTC),
401
+ state_data={
402
+ "lyrics_metadata": {
403
+ "line_count": 42,
404
+ "has_corrections": True,
405
+ "ready_for_review": True,
406
+ },
407
+ "corrected_lyrics": {
408
+ "lines": [
409
+ {"text": "Hello world", "start_time": 1.0, "end_time": 2.0}
410
+ ],
411
+ "metadata": {"song": "Test Song"}
412
+ }
413
+ }
414
+ )
415
+
416
+ assert job.state_data["lyrics_metadata"]["line_count"] == 42
417
+ assert len(job.state_data["corrected_lyrics"]["lines"]) == 1
418
+
419
+ def test_state_data_roundtrip_serialization(self):
420
+ """Test that state_data survives serialization/deserialization."""
421
+ original_state_data = {
422
+ "instrumental_selection": "clean",
423
+ "review_notes": "Looks good!",
424
+ "complex_nested": {
425
+ "level1": {
426
+ "level2": {
427
+ "value": 123
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ job = Job(
434
+ job_id="test123",
435
+ status=JobStatus.PENDING,
436
+ created_at=datetime.now(UTC),
437
+ updated_at=datetime.now(UTC),
438
+ state_data=original_state_data
439
+ )
440
+
441
+ # Serialize and deserialize
442
+ job_dict = job.model_dump()
443
+ json_str = json.dumps(job_dict, default=str)
444
+ restored_dict = json.loads(json_str)
445
+ restored_job = Job(**restored_dict)
446
+
447
+ # Verify nested structure survived
448
+ assert restored_job.state_data["complex_nested"]["level1"]["level2"]["value"] == 123
449
+
450
+
451
+ class TestWorkerIdsNestedStructure:
452
+ """Test that worker_ids field correctly handles nested dictionaries."""
453
+
454
+ def test_worker_ids_with_all_workers(self):
455
+ """Test worker_ids with all worker types."""
456
+ job = Job(
457
+ job_id="test123",
458
+ status=JobStatus.GENERATING_VIDEO,
459
+ created_at=datetime.now(UTC),
460
+ updated_at=datetime.now(UTC),
461
+ worker_ids={
462
+ "audio_worker": "cloud-run-request-12345",
463
+ "lyrics_worker": "cloud-run-request-67890",
464
+ "screens_worker": "cloud-run-request-11111",
465
+ "video_worker": "cloud-run-request-22222",
466
+ }
467
+ )
468
+
469
+ assert job.worker_ids["audio_worker"] == "cloud-run-request-12345"
470
+ assert len(job.worker_ids) == 4
471
+
472
+
473
+ class TestJobModelIntegration:
474
+ """
475
+ Integration tests that simulate real Firestore interactions.
476
+
477
+ These tests verify that the Job model works correctly when used
478
+ the same way as our actual services.
479
+ """
480
+
481
+ def test_simulate_job_manager_update_file_url(self):
482
+ """
483
+ Simulate what JobManager.update_file_url does.
484
+
485
+ This is the pattern that caused the bug - updating nested file_urls.
486
+ """
487
+ # Start with a fresh job
488
+ job = Job(
489
+ job_id="test123",
490
+ status=JobStatus.PENDING,
491
+ created_at=datetime.now(UTC),
492
+ updated_at=datetime.now(UTC),
493
+ file_urls={}
494
+ )
495
+
496
+ # Simulate update_file_url('test123', 'stems', 'instrumental_clean', 'gs://...')
497
+ # This creates nested structure: {"stems": {"instrumental_clean": "gs://..."}}
498
+ if "stems" not in job.file_urls:
499
+ job.file_urls["stems"] = {}
500
+ job.file_urls["stems"]["instrumental_clean"] = "gs://bucket/stems/clean.flac"
501
+
502
+ # Simulate Firestore roundtrip
503
+ job_dict = job.model_dump()
504
+ restored_job = Job(**job_dict)
505
+
506
+ # This is what was failing with Dict[str, str]!
507
+ assert restored_job.file_urls["stems"]["instrumental_clean"] == "gs://bucket/stems/clean.flac"
508
+
509
+ def test_simulate_audio_worker_completion(self):
510
+ """Simulate the data structure created when audio worker completes."""
511
+ # This is the actual structure created by audio_worker.py
512
+ file_urls = {
513
+ "stems": {
514
+ "instrumental_clean": "gs://bucket/jobs/abc123/stems/instrumental_clean.flac",
515
+ "vocals_clean": "gs://bucket/jobs/abc123/stems/vocals_clean.flac",
516
+ "lead_vocals": "gs://bucket/jobs/abc123/stems/lead_vocals.flac",
517
+ "backing_vocals": "gs://bucket/jobs/abc123/stems/backing_vocals.flac",
518
+ "instrumental_with_backing": "gs://bucket/jobs/abc123/stems/instrumental_with_backing.flac",
519
+ }
520
+ }
521
+
522
+ state_data = {
523
+ "instrumental_options": {
524
+ "clean": "jobs/abc123/stems/instrumental_clean.flac",
525
+ "with_backing": "jobs/abc123/stems/instrumental_with_backing.flac",
526
+ }
527
+ }
528
+
529
+ job = Job(
530
+ job_id="abc123",
531
+ status=JobStatus.AUDIO_COMPLETE,
532
+ created_at=datetime.now(UTC),
533
+ updated_at=datetime.now(UTC),
534
+ artist="ABBA",
535
+ title="Waterloo",
536
+ file_urls=file_urls,
537
+ state_data=state_data
538
+ )
539
+
540
+ # Roundtrip
541
+ restored_job = Job(**job.model_dump())
542
+
543
+ assert len(restored_job.file_urls["stems"]) == 5
544
+ assert "instrumental_options" in restored_job.state_data
545
+
546
+ def test_simulate_lyrics_worker_completion(self):
547
+ """Simulate the data structure created when lyrics worker completes."""
548
+ file_urls = {
549
+ "lyrics": {
550
+ "corrections": "gs://bucket/jobs/abc123/lyrics/corrections.json",
551
+ "lrc": "gs://bucket/jobs/abc123/lyrics/karaoke.lrc",
552
+ "uncorrected": "gs://bucket/jobs/abc123/lyrics/uncorrected.txt",
553
+ }
554
+ }
555
+
556
+ state_data = {
557
+ "lyrics_metadata": {
558
+ "line_count": 50,
559
+ "has_corrections": True,
560
+ "ready_for_review": True,
561
+ }
562
+ }
563
+
564
+ job = Job(
565
+ job_id="abc123",
566
+ status=JobStatus.LYRICS_COMPLETE,
567
+ created_at=datetime.now(UTC),
568
+ updated_at=datetime.now(UTC),
569
+ file_urls=file_urls,
570
+ state_data=state_data
571
+ )
572
+
573
+ # Roundtrip
574
+ restored_job = Job(**job.model_dump())
575
+
576
+ assert restored_job.file_urls["lyrics"]["corrections"].endswith("corrections.json")
577
+ assert restored_job.state_data["lyrics_metadata"]["line_count"] == 50
578
+
579
+
580
+ class TestExistingInstrumentalModelFields:
581
+ """Test existing instrumental field in Job and JobCreate models (Batch 3)."""
582
+
583
+ def test_job_has_existing_instrumental_gcs_path(self):
584
+ """Test that Job model has existing_instrumental_gcs_path field."""
585
+ job = Job(
586
+ job_id="test123",
587
+ status=JobStatus.PENDING,
588
+ created_at=datetime.now(UTC),
589
+ updated_at=datetime.now(UTC),
590
+ existing_instrumental_gcs_path="uploads/test123/audio/existing_instrumental.flac"
591
+ )
592
+
593
+ assert hasattr(job, 'existing_instrumental_gcs_path')
594
+ assert job.existing_instrumental_gcs_path == "uploads/test123/audio/existing_instrumental.flac"
595
+
596
+ def test_job_existing_instrumental_defaults_to_none(self):
597
+ """Test that existing_instrumental_gcs_path defaults to None."""
598
+ job = Job(
599
+ job_id="test123",
600
+ status=JobStatus.PENDING,
601
+ created_at=datetime.now(UTC),
602
+ updated_at=datetime.now(UTC)
603
+ )
604
+
605
+ assert job.existing_instrumental_gcs_path is None
606
+
607
+ def test_job_create_has_existing_instrumental_gcs_path(self):
608
+ """Test that JobCreate model has existing_instrumental_gcs_path field."""
609
+ from backend.models.job import JobCreate
610
+
611
+ job_create = JobCreate(
612
+ artist="Artist",
613
+ title="Title",
614
+ existing_instrumental_gcs_path="uploads/test/audio/existing_instrumental.mp3"
615
+ )
616
+
617
+ assert job_create.existing_instrumental_gcs_path == "uploads/test/audio/existing_instrumental.mp3"
618
+
619
+ def test_job_create_existing_instrumental_optional(self):
620
+ """Test that existing_instrumental_gcs_path is optional in JobCreate."""
621
+ from backend.models.job import JobCreate
622
+
623
+ job_create = JobCreate(
624
+ artist="Artist",
625
+ title="Title"
626
+ )
627
+
628
+ assert job_create.existing_instrumental_gcs_path is None
629
+
630
+ def test_existing_instrumental_roundtrip_serialization(self):
631
+ """Test that existing_instrumental_gcs_path survives serialization/deserialization."""
632
+ job = Job(
633
+ job_id="test123",
634
+ status=JobStatus.PENDING,
635
+ created_at=datetime.now(UTC),
636
+ updated_at=datetime.now(UTC),
637
+ existing_instrumental_gcs_path="uploads/test123/audio/existing_instrumental.wav"
638
+ )
639
+
640
+ # Serialize to dict (simulates writing to Firestore)
641
+ job_dict = job.model_dump()
642
+
643
+ # Convert to JSON and back (simulates Firestore storage)
644
+ json_str = json.dumps(job_dict, default=str)
645
+ restored_dict = json.loads(json_str)
646
+
647
+ # Deserialize back to Job (simulates reading from Firestore)
648
+ restored_job = Job(**restored_dict)
649
+
650
+ assert restored_job.existing_instrumental_gcs_path == "uploads/test123/audio/existing_instrumental.wav"
651
+
652
+ def test_job_with_full_audio_config(self):
653
+ """Test Job with complete audio configuration including existing instrumental."""
654
+ job = Job(
655
+ job_id="test123",
656
+ status=JobStatus.AUDIO_COMPLETE,
657
+ created_at=datetime.now(UTC),
658
+ updated_at=datetime.now(UTC),
659
+ artist="Test Artist",
660
+ title="Test Song",
661
+ # Audio model configuration
662
+ clean_instrumental_model="model_bs_roformer_ep_317_sdr_12.9755.ckpt",
663
+ backing_vocals_models=["mel_band_roformer_karaoke.ckpt"],
664
+ other_stems_models=["htdemucs_6s.yaml"],
665
+ # Existing instrumental
666
+ existing_instrumental_gcs_path="uploads/test123/audio/my_instrumental.flac",
667
+ )
668
+
669
+ assert job.clean_instrumental_model == "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
670
+ assert job.existing_instrumental_gcs_path == "uploads/test123/audio/my_instrumental.flac"
671
+
672
+
673
+ class TestTwoPhaseWorkflowFields:
674
+ """Test two-phase workflow fields in Job and JobCreate models (Batch 6)."""
675
+
676
+ def test_job_has_prep_only_field(self):
677
+ """Test that Job model has prep_only field."""
678
+ job = Job(
679
+ job_id="test123",
680
+ status=JobStatus.PENDING,
681
+ created_at=datetime.now(UTC),
682
+ updated_at=datetime.now(UTC),
683
+ prep_only=True
684
+ )
685
+
686
+ assert hasattr(job, 'prep_only')
687
+ assert job.prep_only is True
688
+
689
+ def test_job_prep_only_defaults_to_false(self):
690
+ """Test that prep_only defaults to False."""
691
+ job = Job(
692
+ job_id="test123",
693
+ status=JobStatus.PENDING,
694
+ created_at=datetime.now(UTC),
695
+ updated_at=datetime.now(UTC)
696
+ )
697
+
698
+ assert job.prep_only is False
699
+
700
+ def test_job_has_finalise_only_field(self):
701
+ """Test that Job model has finalise_only field."""
702
+ job = Job(
703
+ job_id="test123",
704
+ status=JobStatus.PENDING,
705
+ created_at=datetime.now(UTC),
706
+ updated_at=datetime.now(UTC),
707
+ finalise_only=True
708
+ )
709
+
710
+ assert hasattr(job, 'finalise_only')
711
+ assert job.finalise_only is True
712
+
713
+ def test_job_finalise_only_defaults_to_false(self):
714
+ """Test that finalise_only defaults to False."""
715
+ job = Job(
716
+ job_id="test123",
717
+ status=JobStatus.PENDING,
718
+ created_at=datetime.now(UTC),
719
+ updated_at=datetime.now(UTC)
720
+ )
721
+
722
+ assert job.finalise_only is False
723
+
724
+ def test_job_has_keep_brand_code_field(self):
725
+ """Test that Job model has keep_brand_code field."""
726
+ job = Job(
727
+ job_id="test123",
728
+ status=JobStatus.PENDING,
729
+ created_at=datetime.now(UTC),
730
+ updated_at=datetime.now(UTC),
731
+ keep_brand_code="NOMAD-1234"
732
+ )
733
+
734
+ assert hasattr(job, 'keep_brand_code')
735
+ assert job.keep_brand_code == "NOMAD-1234"
736
+
737
+ def test_job_keep_brand_code_defaults_to_none(self):
738
+ """Test that keep_brand_code defaults to None."""
739
+ job = Job(
740
+ job_id="test123",
741
+ status=JobStatus.PENDING,
742
+ created_at=datetime.now(UTC),
743
+ updated_at=datetime.now(UTC)
744
+ )
745
+
746
+ assert job.keep_brand_code is None
747
+
748
+ def test_job_create_has_two_phase_workflow_fields(self):
749
+ """Test that JobCreate model has two-phase workflow fields."""
750
+ job_create = JobCreate(
751
+ artist="Artist",
752
+ title="Title",
753
+ prep_only=True,
754
+ finalise_only=False,
755
+ keep_brand_code="BRAND-0001"
756
+ )
757
+
758
+ assert job_create.prep_only is True
759
+ assert job_create.finalise_only is False
760
+ assert job_create.keep_brand_code == "BRAND-0001"
761
+
762
+ def test_two_phase_workflow_roundtrip_serialization(self):
763
+ """Test that two-phase workflow fields survive serialization/deserialization."""
764
+ job = Job(
765
+ job_id="test123",
766
+ status=JobStatus.PENDING,
767
+ created_at=datetime.now(UTC),
768
+ updated_at=datetime.now(UTC),
769
+ prep_only=True,
770
+ finalise_only=False,
771
+ keep_brand_code="NOMAD-5678"
772
+ )
773
+
774
+ # Serialize to dict (simulates writing to Firestore)
775
+ job_dict = job.model_dump()
776
+
777
+ # Convert to JSON and back (simulates Firestore storage)
778
+ json_str = json.dumps(job_dict, default=str)
779
+ restored_dict = json.loads(json_str)
780
+
781
+ # Deserialize back to Job (simulates reading from Firestore)
782
+ restored_job = Job(**restored_dict)
783
+
784
+ assert restored_job.prep_only is True
785
+ assert restored_job.finalise_only is False
786
+ assert restored_job.keep_brand_code == "NOMAD-5678"
787
+
788
+
789
+ class TestPrepCompleteStatus:
790
+ """Test PREP_COMPLETE status and state transitions (Batch 6)."""
791
+
792
+ def test_prep_complete_status_exists(self):
793
+ """Test that PREP_COMPLETE status exists in JobStatus enum."""
794
+ assert hasattr(JobStatus, 'PREP_COMPLETE')
795
+ assert JobStatus.PREP_COMPLETE.value == "prep_complete"
796
+
797
+ def test_job_can_have_prep_complete_status(self):
798
+ """Test that Job can be created with PREP_COMPLETE status."""
799
+ job = Job(
800
+ job_id="test123",
801
+ status=JobStatus.PREP_COMPLETE,
802
+ created_at=datetime.now(UTC),
803
+ updated_at=datetime.now(UTC)
804
+ )
805
+
806
+ assert job.status == JobStatus.PREP_COMPLETE
807
+
808
+ def test_state_transitions_review_complete_to_prep_complete(self):
809
+ """Test that REVIEW_COMPLETE can transition to PREP_COMPLETE."""
810
+ from backend.models.job import STATE_TRANSITIONS
811
+
812
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.REVIEW_COMPLETE, [])
813
+ assert JobStatus.PREP_COMPLETE in valid_transitions
814
+
815
+ def test_state_transitions_prep_complete_to_awaiting_instrumental(self):
816
+ """Test that PREP_COMPLETE can transition to AWAITING_INSTRUMENTAL_SELECTION."""
817
+ from backend.models.job import STATE_TRANSITIONS
818
+
819
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.PREP_COMPLETE, [])
820
+ assert JobStatus.AWAITING_INSTRUMENTAL_SELECTION in valid_transitions
821
+
822
+ def test_state_transitions_pending_to_awaiting_instrumental(self):
823
+ """Test that PENDING can transition to AWAITING_INSTRUMENTAL_SELECTION (for finalise-only)."""
824
+ from backend.models.job import STATE_TRANSITIONS
825
+
826
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.PENDING, [])
827
+ assert JobStatus.AWAITING_INSTRUMENTAL_SELECTION in valid_transitions
828
+
829
+ def test_critical_statuses_include_prep_complete(self):
830
+ """Test that PREP_COMPLETE is in the list of job statuses."""
831
+ all_statuses = [status.value for status in JobStatus]
832
+ assert "prep_complete" in all_statuses
833
+
834
+
835
+ class TestJobWithFullTwoPhaseConfig:
836
+ """Integration tests for two-phase workflow job configuration."""
837
+
838
+ def test_prep_only_job_full_config(self):
839
+ """Test creating a prep-only job with full configuration."""
840
+ job = Job(
841
+ job_id="prep123",
842
+ status=JobStatus.PENDING,
843
+ created_at=datetime.now(UTC),
844
+ updated_at=datetime.now(UTC),
845
+ artist="Test Artist",
846
+ title="Test Song",
847
+ prep_only=True,
848
+ enable_cdg=True,
849
+ enable_txt=True,
850
+ brand_prefix="NOMAD",
851
+ )
852
+
853
+ assert job.prep_only is True
854
+ assert job.finalise_only is False
855
+ assert job.brand_prefix == "NOMAD"
856
+
857
+ def test_finalise_only_job_full_config(self):
858
+ """Test creating a finalise-only job with full configuration."""
859
+ job = Job(
860
+ job_id="final123",
861
+ status=JobStatus.AWAITING_INSTRUMENTAL_SELECTION,
862
+ created_at=datetime.now(UTC),
863
+ updated_at=datetime.now(UTC),
864
+ artist="Test Artist",
865
+ title="Test Song",
866
+ finalise_only=True,
867
+ keep_brand_code="NOMAD-1234",
868
+ enable_youtube_upload=True,
869
+ dropbox_path="/Karaoke/Tracks",
870
+ state_data={
871
+ "audio_complete": True,
872
+ "lyrics_complete": True,
873
+ "finalise_only": True,
874
+ }
875
+ )
876
+
877
+ assert job.finalise_only is True
878
+ assert job.keep_brand_code == "NOMAD-1234"
879
+ assert job.state_data["finalise_only"] is True
880
+
881
+ def test_prep_complete_job_with_file_urls(self):
882
+ """Test a job that has reached PREP_COMPLETE status with all prep outputs."""
883
+ job = Job(
884
+ job_id="prepcomp123",
885
+ status=JobStatus.PREP_COMPLETE,
886
+ created_at=datetime.now(UTC),
887
+ updated_at=datetime.now(UTC),
888
+ artist="ABBA",
889
+ title="Waterloo",
890
+ prep_only=True,
891
+ file_urls={
892
+ "stems": {
893
+ "instrumental_clean": "gs://bucket/jobs/prepcomp123/stems/instrumental_clean.flac",
894
+ "instrumental_with_backing": "gs://bucket/jobs/prepcomp123/stems/instrumental_with_backing.flac",
895
+ },
896
+ "videos": {
897
+ "with_vocals": "gs://bucket/jobs/prepcomp123/videos/with_vocals.mkv",
898
+ },
899
+ "screens": {
900
+ "title": "gs://bucket/jobs/prepcomp123/screens/title.mov",
901
+ "end": "gs://bucket/jobs/prepcomp123/screens/end.mov",
902
+ },
903
+ "lyrics": {
904
+ "lrc": "gs://bucket/jobs/prepcomp123/lyrics/karaoke.lrc",
905
+ "ass": "gs://bucket/jobs/prepcomp123/lyrics/karaoke.ass",
906
+ },
907
+ }
908
+ )
909
+
910
+ assert job.status == JobStatus.PREP_COMPLETE
911
+ assert job.prep_only is True
912
+ assert "with_vocals" in job.file_urls["videos"]
913
+ assert "title" in job.file_urls["screens"]
914
+
915
+
916
+ if __name__ == "__main__":
917
+ pytest.main([__file__, "-v"])
918
+