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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/style_loader.py +3 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the theme service and API endpoints.
|
|
3
|
+
|
|
4
|
+
Tests theme listing, retrieval, color override application, and job style preparation.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import pytest
|
|
8
|
+
from unittest.mock import MagicMock, patch, Mock
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from backend.models.theme import (
|
|
12
|
+
ThemeSummary,
|
|
13
|
+
ThemeDetail,
|
|
14
|
+
ThemeMetadata,
|
|
15
|
+
ThemeRegistry,
|
|
16
|
+
ColorOverrides,
|
|
17
|
+
hex_to_rgba,
|
|
18
|
+
rgba_to_hex,
|
|
19
|
+
)
|
|
20
|
+
from backend.services.theme_service import ThemeService, get_theme_service
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# =============================================================================
|
|
24
|
+
# Color Conversion Tests
|
|
25
|
+
# =============================================================================
|
|
26
|
+
|
|
27
|
+
class TestColorConversion:
|
|
28
|
+
"""Tests for hex/rgba color conversion utilities."""
|
|
29
|
+
|
|
30
|
+
def test_hex_to_rgba_basic(self):
|
|
31
|
+
"""Test basic hex to RGBA conversion."""
|
|
32
|
+
result = hex_to_rgba("#7070F7")
|
|
33
|
+
assert result == "112, 112, 247, 255"
|
|
34
|
+
|
|
35
|
+
def test_hex_to_rgba_with_custom_alpha(self):
|
|
36
|
+
"""Test hex to RGBA with custom alpha."""
|
|
37
|
+
result = hex_to_rgba("#FF0000", alpha=128)
|
|
38
|
+
assert result == "255, 0, 0, 128"
|
|
39
|
+
|
|
40
|
+
def test_hex_to_rgba_lowercase(self):
|
|
41
|
+
"""Test hex with lowercase letters."""
|
|
42
|
+
result = hex_to_rgba("#abcdef")
|
|
43
|
+
assert result == "171, 205, 239, 255"
|
|
44
|
+
|
|
45
|
+
def test_hex_to_rgba_without_hash(self):
|
|
46
|
+
"""Test hex without leading hash."""
|
|
47
|
+
result = hex_to_rgba("FFFFFF")
|
|
48
|
+
assert result == "255, 255, 255, 255"
|
|
49
|
+
|
|
50
|
+
def test_rgba_to_hex_basic(self):
|
|
51
|
+
"""Test basic RGBA to hex conversion."""
|
|
52
|
+
result = rgba_to_hex("112, 112, 247, 255")
|
|
53
|
+
assert result == "#7070f7"
|
|
54
|
+
|
|
55
|
+
def test_rgba_to_hex_ignores_alpha(self):
|
|
56
|
+
"""Test that alpha is ignored in conversion."""
|
|
57
|
+
result = rgba_to_hex("255, 0, 0, 128")
|
|
58
|
+
assert result == "#ff0000"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# ColorOverrides Model Tests
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
class TestColorOverrides:
|
|
66
|
+
"""Tests for ColorOverrides model."""
|
|
67
|
+
|
|
68
|
+
def test_has_overrides_empty(self):
|
|
69
|
+
"""Test has_overrides with no overrides set."""
|
|
70
|
+
overrides = ColorOverrides()
|
|
71
|
+
assert overrides.has_overrides() is False
|
|
72
|
+
|
|
73
|
+
def test_has_overrides_with_artist_color(self):
|
|
74
|
+
"""Test has_overrides with artist_color set."""
|
|
75
|
+
overrides = ColorOverrides(artist_color="#FF0000")
|
|
76
|
+
assert overrides.has_overrides() is True
|
|
77
|
+
|
|
78
|
+
def test_has_overrides_with_all_colors(self):
|
|
79
|
+
"""Test has_overrides with all colors set."""
|
|
80
|
+
overrides = ColorOverrides(
|
|
81
|
+
artist_color="#FF0000",
|
|
82
|
+
title_color="#00FF00",
|
|
83
|
+
sung_lyrics_color="#0000FF",
|
|
84
|
+
unsung_lyrics_color="#FFFF00",
|
|
85
|
+
)
|
|
86
|
+
assert overrides.has_overrides() is True
|
|
87
|
+
|
|
88
|
+
def test_to_dict_excludes_none(self):
|
|
89
|
+
"""Test to_dict excludes None values."""
|
|
90
|
+
overrides = ColorOverrides(artist_color="#FF0000")
|
|
91
|
+
result = overrides.to_dict()
|
|
92
|
+
assert result == {"artist_color": "#FF0000"}
|
|
93
|
+
assert "title_color" not in result
|
|
94
|
+
|
|
95
|
+
def test_color_validation_valid(self):
|
|
96
|
+
"""Test color validation accepts valid hex colors."""
|
|
97
|
+
overrides = ColorOverrides(artist_color="#AbCdEf")
|
|
98
|
+
assert overrides.artist_color == "#AbCdEf"
|
|
99
|
+
|
|
100
|
+
def test_color_validation_invalid(self):
|
|
101
|
+
"""Test color validation rejects invalid colors."""
|
|
102
|
+
with pytest.raises(ValueError):
|
|
103
|
+
ColorOverrides(artist_color="red")
|
|
104
|
+
|
|
105
|
+
with pytest.raises(ValueError):
|
|
106
|
+
ColorOverrides(artist_color="#GGG")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# =============================================================================
|
|
110
|
+
# ThemeService Tests
|
|
111
|
+
# =============================================================================
|
|
112
|
+
|
|
113
|
+
class TestThemeService:
|
|
114
|
+
"""Tests for ThemeService."""
|
|
115
|
+
|
|
116
|
+
@pytest.fixture
|
|
117
|
+
def mock_storage(self):
|
|
118
|
+
"""Create a mock storage service."""
|
|
119
|
+
storage = MagicMock()
|
|
120
|
+
return storage
|
|
121
|
+
|
|
122
|
+
@pytest.fixture
|
|
123
|
+
def sample_metadata(self):
|
|
124
|
+
"""Sample theme metadata for testing."""
|
|
125
|
+
return {
|
|
126
|
+
"version": 1,
|
|
127
|
+
"themes": [
|
|
128
|
+
{
|
|
129
|
+
"id": "nomad",
|
|
130
|
+
"name": "Nomad Karaoke",
|
|
131
|
+
"description": "Golden artist text, professional look",
|
|
132
|
+
"is_default": True,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"id": "default",
|
|
136
|
+
"name": "Default",
|
|
137
|
+
"description": "Clean white text on black background",
|
|
138
|
+
"is_default": False,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@pytest.fixture
|
|
144
|
+
def sample_style_params(self):
|
|
145
|
+
"""Sample style params for testing."""
|
|
146
|
+
return {
|
|
147
|
+
"intro": {
|
|
148
|
+
"artist_color": "#ffdf6b",
|
|
149
|
+
"title_color": "#ffffff",
|
|
150
|
+
"background_image": "intro_bg.png",
|
|
151
|
+
"font": "Montserrat-Bold.ttf",
|
|
152
|
+
},
|
|
153
|
+
"end": {
|
|
154
|
+
"artist_color": "#ffdf6b",
|
|
155
|
+
"title_color": "#ffffff",
|
|
156
|
+
"background_image": "end_bg.png",
|
|
157
|
+
"font": "Montserrat-Bold.ttf",
|
|
158
|
+
},
|
|
159
|
+
"karaoke": {
|
|
160
|
+
"primary_color": "112, 112, 247, 255",
|
|
161
|
+
"secondary_color": "255, 255, 255, 255",
|
|
162
|
+
"background_image": "karaoke_bg.png",
|
|
163
|
+
"font_path": "Montserrat-Bold.ttf",
|
|
164
|
+
},
|
|
165
|
+
"cdg": {
|
|
166
|
+
"active_fill": "#7070F7",
|
|
167
|
+
"inactive_fill": "#FFFFFF",
|
|
168
|
+
"font_path": "Montserrat-Bold.ttf",
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
def test_list_themes_returns_summaries(self, mock_storage, sample_metadata):
|
|
173
|
+
"""Test list_themes returns ThemeSummary objects."""
|
|
174
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
175
|
+
mock_storage.file_exists.return_value = True
|
|
176
|
+
mock_storage.generate_signed_url.return_value = "https://signed-url.com/preview.png"
|
|
177
|
+
|
|
178
|
+
service = ThemeService(storage=mock_storage)
|
|
179
|
+
themes = service.list_themes()
|
|
180
|
+
|
|
181
|
+
assert len(themes) == 2
|
|
182
|
+
assert themes[0].id == "nomad"
|
|
183
|
+
assert themes[0].name == "Nomad Karaoke"
|
|
184
|
+
assert themes[0].is_default is True
|
|
185
|
+
assert themes[0].preview_url is not None
|
|
186
|
+
|
|
187
|
+
def test_list_themes_handles_missing_preview(self, mock_storage, sample_metadata):
|
|
188
|
+
"""Test list_themes handles missing preview images gracefully."""
|
|
189
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
190
|
+
mock_storage.file_exists.return_value = False
|
|
191
|
+
|
|
192
|
+
service = ThemeService(storage=mock_storage)
|
|
193
|
+
themes = service.list_themes()
|
|
194
|
+
|
|
195
|
+
assert len(themes) == 2
|
|
196
|
+
assert themes[0].preview_url is None
|
|
197
|
+
|
|
198
|
+
def test_get_theme_returns_detail(self, mock_storage, sample_metadata, sample_style_params):
|
|
199
|
+
"""Test get_theme returns ThemeDetail with style params."""
|
|
200
|
+
mock_storage.download_json.side_effect = [sample_metadata, sample_style_params]
|
|
201
|
+
mock_storage.file_exists.return_value = True
|
|
202
|
+
mock_storage.generate_signed_url.return_value = "https://signed-url.com/preview.png"
|
|
203
|
+
|
|
204
|
+
service = ThemeService(storage=mock_storage)
|
|
205
|
+
theme = service.get_theme("nomad")
|
|
206
|
+
|
|
207
|
+
assert theme is not None
|
|
208
|
+
assert theme.id == "nomad"
|
|
209
|
+
assert theme.name == "Nomad Karaoke"
|
|
210
|
+
assert "intro" in theme.style_params
|
|
211
|
+
assert theme.style_params["intro"]["artist_color"] == "#ffdf6b"
|
|
212
|
+
|
|
213
|
+
def test_get_theme_returns_none_for_unknown(self, mock_storage, sample_metadata):
|
|
214
|
+
"""Test get_theme returns None for unknown theme ID."""
|
|
215
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
216
|
+
|
|
217
|
+
service = ThemeService(storage=mock_storage)
|
|
218
|
+
theme = service.get_theme("unknown-theme")
|
|
219
|
+
|
|
220
|
+
assert theme is None
|
|
221
|
+
|
|
222
|
+
def test_theme_exists_true(self, mock_storage, sample_metadata):
|
|
223
|
+
"""Test theme_exists returns True for existing theme."""
|
|
224
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
225
|
+
|
|
226
|
+
service = ThemeService(storage=mock_storage)
|
|
227
|
+
assert service.theme_exists("nomad") is True
|
|
228
|
+
|
|
229
|
+
def test_theme_exists_false(self, mock_storage, sample_metadata):
|
|
230
|
+
"""Test theme_exists returns False for unknown theme."""
|
|
231
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
232
|
+
|
|
233
|
+
service = ThemeService(storage=mock_storage)
|
|
234
|
+
assert service.theme_exists("unknown") is False
|
|
235
|
+
|
|
236
|
+
def test_get_default_theme_id(self, mock_storage, sample_metadata):
|
|
237
|
+
"""Test get_default_theme_id returns the default theme."""
|
|
238
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
239
|
+
|
|
240
|
+
service = ThemeService(storage=mock_storage)
|
|
241
|
+
assert service.get_default_theme_id() == "nomad"
|
|
242
|
+
|
|
243
|
+
def test_get_default_theme_id_no_default(self, mock_storage):
|
|
244
|
+
"""Test get_default_theme_id returns None when no default."""
|
|
245
|
+
mock_storage.download_json.return_value = {
|
|
246
|
+
"version": 1,
|
|
247
|
+
"themes": [{"id": "test", "name": "Test", "description": "Test", "is_default": False}],
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
service = ThemeService(storage=mock_storage)
|
|
251
|
+
assert service.get_default_theme_id() is None
|
|
252
|
+
|
|
253
|
+
def test_apply_color_overrides_no_changes(self, sample_style_params):
|
|
254
|
+
"""Test apply_color_overrides with empty overrides."""
|
|
255
|
+
service = ThemeService()
|
|
256
|
+
overrides = ColorOverrides()
|
|
257
|
+
|
|
258
|
+
result = service.apply_color_overrides(sample_style_params, overrides)
|
|
259
|
+
|
|
260
|
+
# Should return original (no deep copy needed when no changes)
|
|
261
|
+
assert result == sample_style_params
|
|
262
|
+
|
|
263
|
+
def test_apply_color_overrides_artist_color(self, sample_style_params):
|
|
264
|
+
"""Test apply_color_overrides applies artist_color."""
|
|
265
|
+
service = ThemeService()
|
|
266
|
+
overrides = ColorOverrides(artist_color="#FF0000")
|
|
267
|
+
|
|
268
|
+
result = service.apply_color_overrides(sample_style_params, overrides)
|
|
269
|
+
|
|
270
|
+
assert result["intro"]["artist_color"] == "#FF0000"
|
|
271
|
+
assert result["end"]["artist_color"] == "#FF0000"
|
|
272
|
+
assert result["cdg"]["artist_color"] == "#FF0000"
|
|
273
|
+
# Original should be unchanged
|
|
274
|
+
assert sample_style_params["intro"]["artist_color"] == "#ffdf6b"
|
|
275
|
+
|
|
276
|
+
def test_apply_color_overrides_sung_lyrics_color(self, sample_style_params):
|
|
277
|
+
"""Test apply_color_overrides applies sung_lyrics_color with conversion."""
|
|
278
|
+
service = ThemeService()
|
|
279
|
+
overrides = ColorOverrides(sung_lyrics_color="#FF0000")
|
|
280
|
+
|
|
281
|
+
result = service.apply_color_overrides(sample_style_params, overrides)
|
|
282
|
+
|
|
283
|
+
# Karaoke uses RGBA format
|
|
284
|
+
assert result["karaoke"]["primary_color"] == "255, 0, 0, 255"
|
|
285
|
+
# CDG uses hex
|
|
286
|
+
assert result["cdg"]["active_fill"] == "#FF0000"
|
|
287
|
+
|
|
288
|
+
def test_apply_color_overrides_unsung_lyrics_color(self, sample_style_params):
|
|
289
|
+
"""Test apply_color_overrides applies unsung_lyrics_color."""
|
|
290
|
+
service = ThemeService()
|
|
291
|
+
overrides = ColorOverrides(unsung_lyrics_color="#00FF00")
|
|
292
|
+
|
|
293
|
+
result = service.apply_color_overrides(sample_style_params, overrides)
|
|
294
|
+
|
|
295
|
+
assert result["karaoke"]["secondary_color"] == "0, 255, 0, 255"
|
|
296
|
+
assert result["cdg"]["inactive_fill"] == "#00FF00"
|
|
297
|
+
|
|
298
|
+
def test_metadata_cache(self, mock_storage, sample_metadata):
|
|
299
|
+
"""Test metadata caching works."""
|
|
300
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
301
|
+
|
|
302
|
+
service = ThemeService(storage=mock_storage)
|
|
303
|
+
|
|
304
|
+
# First call loads from GCS
|
|
305
|
+
service.list_themes()
|
|
306
|
+
assert mock_storage.download_json.call_count == 1
|
|
307
|
+
|
|
308
|
+
# Second call uses cache
|
|
309
|
+
service.list_themes()
|
|
310
|
+
assert mock_storage.download_json.call_count == 1
|
|
311
|
+
|
|
312
|
+
def test_invalidate_cache(self, mock_storage, sample_metadata):
|
|
313
|
+
"""Test cache invalidation."""
|
|
314
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
315
|
+
|
|
316
|
+
service = ThemeService(storage=mock_storage)
|
|
317
|
+
|
|
318
|
+
# Load metadata
|
|
319
|
+
service.list_themes()
|
|
320
|
+
assert mock_storage.download_json.call_count == 1
|
|
321
|
+
|
|
322
|
+
# Invalidate cache
|
|
323
|
+
service.invalidate_cache()
|
|
324
|
+
|
|
325
|
+
# Next call should reload
|
|
326
|
+
service.list_themes()
|
|
327
|
+
assert mock_storage.download_json.call_count == 2
|
|
328
|
+
|
|
329
|
+
def test_prepare_job_style(self, mock_storage, sample_metadata, sample_style_params):
|
|
330
|
+
"""Test prepare_job_style creates job style from theme."""
|
|
331
|
+
mock_storage.download_json.side_effect = [sample_metadata, sample_style_params]
|
|
332
|
+
mock_storage.file_exists.return_value = True
|
|
333
|
+
|
|
334
|
+
service = ThemeService(storage=mock_storage)
|
|
335
|
+
style_path, style_assets = service.prepare_job_style("job123", "nomad")
|
|
336
|
+
|
|
337
|
+
# Should upload modified style_params.json
|
|
338
|
+
mock_storage.upload_json.assert_called_once()
|
|
339
|
+
assert style_path == "uploads/job123/style/style_params.json"
|
|
340
|
+
|
|
341
|
+
def test_prepare_job_style_with_overrides(self, mock_storage, sample_metadata, sample_style_params):
|
|
342
|
+
"""Test prepare_job_style applies color overrides."""
|
|
343
|
+
# prepare_job_style calls get_theme_style_params which directly downloads style_params.json
|
|
344
|
+
# No metadata lookup happens unless we've cached it previously
|
|
345
|
+
mock_storage.download_json.return_value = sample_style_params
|
|
346
|
+
mock_storage.file_exists.return_value = True
|
|
347
|
+
|
|
348
|
+
service = ThemeService(storage=mock_storage)
|
|
349
|
+
overrides = ColorOverrides(artist_color="#FF0000")
|
|
350
|
+
|
|
351
|
+
style_path, style_assets = service.prepare_job_style("job123", "nomad", overrides)
|
|
352
|
+
|
|
353
|
+
# Check that upload_json was called with modified style params
|
|
354
|
+
call_args = mock_storage.upload_json.call_args
|
|
355
|
+
uploaded_path = call_args[0][0] # First positional arg is the path
|
|
356
|
+
uploaded_style = call_args[0][1] # Second positional arg is the data
|
|
357
|
+
|
|
358
|
+
assert uploaded_path == "uploads/job123/style/style_params.json"
|
|
359
|
+
# Check that artist_color override was applied
|
|
360
|
+
assert uploaded_style["intro"]["artist_color"] == "#FF0000"
|
|
361
|
+
assert uploaded_style["end"]["artist_color"] == "#FF0000"
|
|
362
|
+
|
|
363
|
+
def test_prepare_job_style_unknown_theme_raises(self, mock_storage, sample_metadata):
|
|
364
|
+
"""Test prepare_job_style raises for unknown theme."""
|
|
365
|
+
# First download_json returns metadata (no unknown theme)
|
|
366
|
+
# Second download_json for style_params.json should fail
|
|
367
|
+
def download_json_side_effect(path):
|
|
368
|
+
if "_metadata.json" in path:
|
|
369
|
+
return sample_metadata
|
|
370
|
+
# Unknown theme path - raise exception
|
|
371
|
+
raise Exception(f"File not found: {path}")
|
|
372
|
+
|
|
373
|
+
mock_storage.download_json.side_effect = download_json_side_effect
|
|
374
|
+
|
|
375
|
+
service = ThemeService(storage=mock_storage)
|
|
376
|
+
|
|
377
|
+
with pytest.raises(ValueError, match="Theme not found"):
|
|
378
|
+
service.prepare_job_style("job123", "unknown-theme")
|
|
379
|
+
|
|
380
|
+
def test_get_youtube_description(self, mock_storage, sample_metadata):
|
|
381
|
+
"""Test get_youtube_description returns template text."""
|
|
382
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
383
|
+
mock_storage.file_exists.return_value = True
|
|
384
|
+
|
|
385
|
+
# Mock the download_file to write content to temp file
|
|
386
|
+
def mock_download(gcs_path, local_path):
|
|
387
|
+
with open(local_path, "w") as f:
|
|
388
|
+
f.write("Thank you for watching!")
|
|
389
|
+
|
|
390
|
+
mock_storage.download_file.side_effect = mock_download
|
|
391
|
+
|
|
392
|
+
service = ThemeService(storage=mock_storage)
|
|
393
|
+
desc = service.get_youtube_description("nomad")
|
|
394
|
+
|
|
395
|
+
assert desc == "Thank you for watching!"
|
|
396
|
+
|
|
397
|
+
def test_get_youtube_description_not_found(self, mock_storage, sample_metadata):
|
|
398
|
+
"""Test get_youtube_description returns None when no template."""
|
|
399
|
+
mock_storage.download_json.return_value = sample_metadata
|
|
400
|
+
mock_storage.file_exists.return_value = False
|
|
401
|
+
|
|
402
|
+
service = ThemeService(storage=mock_storage)
|
|
403
|
+
desc = service.get_youtube_description("nomad")
|
|
404
|
+
|
|
405
|
+
assert desc is None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# =============================================================================
|
|
409
|
+
# API Endpoint Tests
|
|
410
|
+
# =============================================================================
|
|
411
|
+
|
|
412
|
+
class TestThemeAPI:
|
|
413
|
+
"""Tests for theme API endpoints."""
|
|
414
|
+
|
|
415
|
+
@pytest.fixture
|
|
416
|
+
def sample_metadata(self):
|
|
417
|
+
"""Sample theme metadata."""
|
|
418
|
+
return {
|
|
419
|
+
"version": 1,
|
|
420
|
+
"themes": [
|
|
421
|
+
{
|
|
422
|
+
"id": "nomad",
|
|
423
|
+
"name": "Nomad Karaoke",
|
|
424
|
+
"description": "Golden artist text",
|
|
425
|
+
"is_default": True,
|
|
426
|
+
}
|
|
427
|
+
],
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
def test_list_themes_endpoint(self, test_client, sample_metadata):
|
|
431
|
+
"""Test GET /api/themes returns theme list."""
|
|
432
|
+
with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
|
|
433
|
+
mock_service = MagicMock()
|
|
434
|
+
mock_service.list_themes.return_value = [
|
|
435
|
+
ThemeSummary(
|
|
436
|
+
id="nomad",
|
|
437
|
+
name="Nomad Karaoke",
|
|
438
|
+
description="Golden artist text",
|
|
439
|
+
preview_url="https://example.com/preview.png",
|
|
440
|
+
is_default=True,
|
|
441
|
+
)
|
|
442
|
+
]
|
|
443
|
+
mock_get_service.return_value = mock_service
|
|
444
|
+
|
|
445
|
+
response = test_client.get("/api/themes")
|
|
446
|
+
|
|
447
|
+
assert response.status_code == 200
|
|
448
|
+
data = response.json()
|
|
449
|
+
assert len(data["themes"]) == 1
|
|
450
|
+
assert data["themes"][0]["id"] == "nomad"
|
|
451
|
+
assert data["themes"][0]["is_default"] is True
|
|
452
|
+
|
|
453
|
+
def test_get_theme_endpoint(self, test_client):
|
|
454
|
+
"""Test GET /api/themes/{theme_id} returns theme detail."""
|
|
455
|
+
with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
|
|
456
|
+
mock_service = MagicMock()
|
|
457
|
+
mock_service.get_theme.return_value = ThemeDetail(
|
|
458
|
+
id="nomad",
|
|
459
|
+
name="Nomad Karaoke",
|
|
460
|
+
description="Golden artist text",
|
|
461
|
+
is_default=True,
|
|
462
|
+
style_params={"intro": {"artist_color": "#ffdf6b"}},
|
|
463
|
+
)
|
|
464
|
+
mock_get_service.return_value = mock_service
|
|
465
|
+
|
|
466
|
+
response = test_client.get("/api/themes/nomad")
|
|
467
|
+
|
|
468
|
+
assert response.status_code == 200
|
|
469
|
+
data = response.json()
|
|
470
|
+
assert data["theme"]["id"] == "nomad"
|
|
471
|
+
assert "style_params" in data["theme"]
|
|
472
|
+
|
|
473
|
+
def test_get_theme_not_found(self, test_client):
|
|
474
|
+
"""Test GET /api/themes/{theme_id} returns 404 for unknown theme."""
|
|
475
|
+
with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
|
|
476
|
+
mock_service = MagicMock()
|
|
477
|
+
mock_service.get_theme.return_value = None
|
|
478
|
+
mock_get_service.return_value = mock_service
|
|
479
|
+
|
|
480
|
+
response = test_client.get("/api/themes/unknown")
|
|
481
|
+
|
|
482
|
+
assert response.status_code == 404
|
|
483
|
+
|
|
484
|
+
def test_get_theme_preview_endpoint(self, test_client):
|
|
485
|
+
"""Test GET /api/themes/{theme_id}/preview returns preview URL."""
|
|
486
|
+
with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
|
|
487
|
+
mock_service = MagicMock()
|
|
488
|
+
mock_service.theme_exists.return_value = True
|
|
489
|
+
mock_service.get_theme.return_value = ThemeDetail(
|
|
490
|
+
id="nomad",
|
|
491
|
+
name="Nomad Karaoke",
|
|
492
|
+
description="Golden artist text",
|
|
493
|
+
preview_url="https://example.com/preview.png",
|
|
494
|
+
is_default=True,
|
|
495
|
+
)
|
|
496
|
+
mock_get_service.return_value = mock_service
|
|
497
|
+
|
|
498
|
+
response = test_client.get("/api/themes/nomad/preview")
|
|
499
|
+
|
|
500
|
+
assert response.status_code == 200
|
|
501
|
+
data = response.json()
|
|
502
|
+
assert "preview_url" in data
|
|
503
|
+
|
|
504
|
+
def test_get_youtube_description_endpoint(self, test_client):
|
|
505
|
+
"""Test GET /api/themes/{theme_id}/youtube-description returns description."""
|
|
506
|
+
with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
|
|
507
|
+
mock_service = MagicMock()
|
|
508
|
+
mock_service.theme_exists.return_value = True
|
|
509
|
+
mock_service.get_youtube_description.return_value = "Thank you for watching!"
|
|
510
|
+
mock_get_service.return_value = mock_service
|
|
511
|
+
|
|
512
|
+
response = test_client.get("/api/themes/nomad/youtube-description")
|
|
513
|
+
|
|
514
|
+
assert response.status_code == 200
|
|
515
|
+
data = response.json()
|
|
516
|
+
assert data["description"] == "Thank you for watching!"
|