gabriel / musehub public
test_musehub_ui_analysis_simple_ssr.py python
420 lines 14.5 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """SSR tests for the analysis dashboard and five simple dimension pages.
2
3 Covers the migration from client-side JS data fetching to server-side Jinja2
4 rendering per issue #578 — analysis dashboard + key, tempo, meter, groove, form.
5
6 Tests:
7 - test_analysis_dashboard_renders_dimension_links — GET dashboard, dimension links in HTML
8 - test_analysis_dashboard_no_auth_required — accessible without JWT
9 - test_analysis_dashboard_renders_key_data_server_side — key tonic in HTML without JS fetch
10 - test_key_analysis_renders_tonic_server_side — tonic rendered in HTML by Jinja2
11 - test_key_analysis_renders_distribution_bars — confidence bar present as CSS
12 - test_key_analysis_htmx_fragment_path — HX-Request:true returns fragment (no extends)
13 - test_tempo_analysis_renders_bpm_server_side — BPM value in HTML
14 - test_tempo_analysis_renders_stability_bar — stability bar present as CSS
15 - test_meter_analysis_renders_time_signature — time signature in HTML
16 - test_meter_analysis_renders_beat_strength_bars — beat strength bars in HTML
17 - test_groove_analysis_renders_pattern — groove style in HTML
18 - test_groove_analysis_renders_score_gauge — groove score gauge present
19 - test_form_analysis_renders_sections — section names in HTML
20 - test_form_analysis_renders_timeline — form timeline present
21 - test_analysis_pages_no_js_chart_lib — no ChartJS or D3 references
22 """
23 from __future__ import annotations
24
25 import pytest
26 from httpx import AsyncClient
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.db.musehub_models import MusehubRepo
30
31
32 # ---------------------------------------------------------------------------
33 # Helpers
34 # ---------------------------------------------------------------------------
35
36
37 async def _make_repo(db_session: AsyncSession) -> str:
38 """Seed a minimal repo and return its repo_id."""
39 repo = MusehubRepo(
40 name="analysis-ssr-beats",
41 owner="analysisuser",
42 slug="analysis-ssr-beats",
43 visibility="private",
44 owner_user_id="analysis-owner",
45 )
46 db_session.add(repo)
47 await db_session.commit()
48 await db_session.refresh(repo)
49 return str(repo.repo_id)
50
51
52 _REF = "abc1234def5678"
53 _BASE = "/musehub/ui/analysisuser/analysis-ssr-beats"
54 _DASHBOARD_URL = f"{_BASE}/analysis/{_REF}"
55 _KEY_URL = f"{_BASE}/analysis/{_REF}/key"
56 _TEMPO_URL = f"{_BASE}/analysis/{_REF}/tempo"
57 _METER_URL = f"{_BASE}/analysis/{_REF}/meter"
58 _GROOVE_URL = f"{_BASE}/analysis/{_REF}/groove"
59 _FORM_URL = f"{_BASE}/analysis/{_REF}/form"
60
61
62 # ---------------------------------------------------------------------------
63 # Dashboard tests
64 # ---------------------------------------------------------------------------
65
66
67 @pytest.mark.anyio
68 async def test_analysis_dashboard_renders_dimension_links(
69 client: AsyncClient,
70 db_session: AsyncSession,
71 ) -> None:
72 """GET analysis dashboard returns HTML with links to all dimension pages."""
73 await _make_repo(db_session)
74 response = await client.get(_DASHBOARD_URL)
75 assert response.status_code == 200
76 assert "text/html" in response.headers["content-type"]
77 body = response.text
78 assert "Key" in body
79 assert "Tempo" in body
80 assert "Meter" in body
81 assert "Groove" in body
82 assert "Form" in body
83 assert f"/analysis/{_REF}/key" in body
84
85
86 @pytest.mark.anyio
87 async def test_analysis_dashboard_no_auth_required(
88 client: AsyncClient,
89 db_session: AsyncSession,
90 ) -> None:
91 """Analysis dashboard is accessible without a JWT."""
92 await _make_repo(db_session)
93 response = await client.get(_DASHBOARD_URL)
94 assert response.status_code == 200
95
96
97 @pytest.mark.anyio
98 async def test_analysis_dashboard_renders_key_data_server_side(
99 client: AsyncClient,
100 db_session: AsyncSession,
101 ) -> None:
102 """Dashboard renders key tonic data server-side — no client-side API fetch needed."""
103 await _make_repo(db_session)
104 response = await client.get(_DASHBOARD_URL)
105 assert response.status_code == 200
106 body = response.text
107 # Key card shows tonic + mode directly in HTML (server-rendered, not 'loading...')
108 # The stub always returns a tonic from the fixed list; just verify some key data is there
109 assert "major" in body.lower() or "minor" in body.lower() or "BPM" in body
110
111
112 @pytest.mark.anyio
113 async def test_analysis_dashboard_htmx_fragment_path(
114 client: AsyncClient,
115 db_session: AsyncSession,
116 ) -> None:
117 """GET dashboard with HX-Request: true returns fragment (no <html> wrapper)."""
118 await _make_repo(db_session)
119 response = await client.get(_DASHBOARD_URL, headers={"HX-Request": "true"})
120 assert response.status_code == 200
121 body = response.text
122 assert "<html" not in body
123 assert "Back to repo" in body or "Analysis" in body
124
125
126 # ---------------------------------------------------------------------------
127 # Key analysis tests
128 # ---------------------------------------------------------------------------
129
130
131 @pytest.mark.anyio
132 async def test_key_analysis_renders_tonic_server_side(
133 client: AsyncClient,
134 db_session: AsyncSession,
135 ) -> None:
136 """GET key analysis page renders tonic in HTML via Jinja2 (not via JS fetch)."""
137 await _make_repo(db_session)
138 response = await client.get(_KEY_URL)
139 assert response.status_code == 200
140 assert "text/html" in response.headers["content-type"]
141 body = response.text
142 assert "Key Detection" in body
143 # Tonic is one of the standard pitch classes — verify some key content is there
144 for note in ("C", "D", "E", "F", "G", "A", "B"):
145 if note in body:
146 break
147 else:
148 pytest.fail("No pitch class (key tonic) found in server-rendered key page")
149
150
151 @pytest.mark.anyio
152 async def test_key_analysis_renders_distribution_bars(
153 client: AsyncClient,
154 db_session: AsyncSession,
155 ) -> None:
156 """Key page renders confidence bar as inline CSS — no JS chart library required."""
157 await _make_repo(db_session)
158 response = await client.get(_KEY_URL)
159 assert response.status_code == 200
160 body = response.text
161 # Confidence bar uses inline CSS width% — rendered server-side
162 assert "Detection Confidence" in body
163 assert "%" in body
164
165
166 @pytest.mark.anyio
167 async def test_key_analysis_renders_alternate_keys(
168 client: AsyncClient,
169 db_session: AsyncSession,
170 ) -> None:
171 """Key page renders alternate key candidates server-side."""
172 await _make_repo(db_session)
173 response = await client.get(_KEY_URL)
174 assert response.status_code == 200
175 body = response.text
176 # Relative key is always rendered
177 assert "Relative Key" in body
178
179
180 @pytest.mark.anyio
181 async def test_key_analysis_htmx_fragment_path(
182 client: AsyncClient,
183 db_session: AsyncSession,
184 ) -> None:
185 """GET key page with HX-Request: true returns fragment (no <html> wrapper)."""
186 await _make_repo(db_session)
187 response = await client.get(_KEY_URL, headers={"HX-Request": "true"})
188 assert response.status_code == 200
189 body = response.text
190 assert "<html" not in body
191 assert "Key Detection" in body
192
193
194 # ---------------------------------------------------------------------------
195 # Tempo analysis tests
196 # ---------------------------------------------------------------------------
197
198
199 @pytest.mark.anyio
200 async def test_tempo_analysis_renders_bpm_server_side(
201 client: AsyncClient,
202 db_session: AsyncSession,
203 ) -> None:
204 """GET tempo analysis page renders BPM value in HTML (server-side, not JS)."""
205 await _make_repo(db_session)
206 response = await client.get(_TEMPO_URL)
207 assert response.status_code == 200
208 body = response.text
209 assert "Tempo Analysis" in body
210 assert "BPM" in body
211 # BPM is a numeric value — at least one digit must appear
212 assert any(c.isdigit() for c in body)
213
214
215 @pytest.mark.anyio
216 async def test_tempo_analysis_renders_stability_bar(
217 client: AsyncClient,
218 db_session: AsyncSession,
219 ) -> None:
220 """Tempo page renders stability bar as inline CSS — server-side only."""
221 await _make_repo(db_session)
222 response = await client.get(_TEMPO_URL)
223 assert response.status_code == 200
224 body = response.text
225 assert "Stability" in body
226 assert "Time Feel" in body
227 assert "Tempo Changes" in body
228
229
230 @pytest.mark.anyio
231 async def test_tempo_analysis_htmx_fragment_path(
232 client: AsyncClient,
233 db_session: AsyncSession,
234 ) -> None:
235 """GET tempo page with HX-Request: true returns fragment only."""
236 await _make_repo(db_session)
237 response = await client.get(_TEMPO_URL, headers={"HX-Request": "true"})
238 assert response.status_code == 200
239 body = response.text
240 assert "<html" not in body
241 assert "Tempo Analysis" in body
242
243
244 # ---------------------------------------------------------------------------
245 # Meter analysis tests
246 # ---------------------------------------------------------------------------
247
248
249 @pytest.mark.anyio
250 async def test_meter_analysis_renders_time_signature(
251 client: AsyncClient,
252 db_session: AsyncSession,
253 ) -> None:
254 """GET meter analysis page renders time signature in HTML (server-side)."""
255 await _make_repo(db_session)
256 response = await client.get(_METER_URL)
257 assert response.status_code == 200
258 body = response.text
259 assert "Meter Analysis" in body
260 assert "Time Signature" in body
261 # Time signature contains a slash like 4/4 or 3/4
262 assert "/" in body
263
264
265 @pytest.mark.anyio
266 async def test_meter_analysis_renders_beat_strength_bars(
267 client: AsyncClient,
268 db_session: AsyncSession,
269 ) -> None:
270 """Meter page renders beat strength profile as CSS bars — server-side only."""
271 await _make_repo(db_session)
272 response = await client.get(_METER_URL)
273 assert response.status_code == 200
274 body = response.text
275 assert "Beat Strength Profile" in body
276 # The meter type badge (compound/simple) is rendered server-side
277 assert "simple" in body or "compound" in body
278
279
280 @pytest.mark.anyio
281 async def test_meter_analysis_htmx_fragment_path(
282 client: AsyncClient,
283 db_session: AsyncSession,
284 ) -> None:
285 """GET meter page with HX-Request: true returns fragment only."""
286 await _make_repo(db_session)
287 response = await client.get(_METER_URL, headers={"HX-Request": "true"})
288 assert response.status_code == 200
289 body = response.text
290 assert "<html" not in body
291 assert "Meter Analysis" in body
292
293
294 # ---------------------------------------------------------------------------
295 # Groove analysis tests
296 # ---------------------------------------------------------------------------
297
298
299 @pytest.mark.anyio
300 async def test_groove_analysis_renders_pattern(
301 client: AsyncClient,
302 db_session: AsyncSession,
303 ) -> None:
304 """GET groove analysis page renders groove style in HTML (server-side)."""
305 await _make_repo(db_session)
306 response = await client.get(_GROOVE_URL)
307 assert response.status_code == 200
308 body = response.text
309 assert "Groove Analysis" in body
310 assert "Style" in body
311 # One of the known groove styles should appear
312 groove_styles = {"straight", "swing", "shuffled", "latin", "funk"}
313 assert any(style in body.lower() for style in groove_styles), (
314 "Expected a groove style name (straight/swing/shuffled/latin/funk) in response"
315 )
316
317
318 @pytest.mark.anyio
319 async def test_groove_analysis_renders_score_gauge(
320 client: AsyncClient,
321 db_session: AsyncSession,
322 ) -> None:
323 """Groove page renders score gauge and swing factor as CSS bars — server-side."""
324 await _make_repo(db_session)
325 response = await client.get(_GROOVE_URL)
326 assert response.status_code == 200
327 body = response.text
328 assert "Groove Score" in body
329 assert "Swing Factor" in body
330 assert "BPM" in body
331 assert "Onset Deviation" in body
332
333
334 @pytest.mark.anyio
335 async def test_groove_analysis_htmx_fragment_path(
336 client: AsyncClient,
337 db_session: AsyncSession,
338 ) -> None:
339 """GET groove page with HX-Request: true returns fragment only."""
340 await _make_repo(db_session)
341 response = await client.get(_GROOVE_URL, headers={"HX-Request": "true"})
342 assert response.status_code == 200
343 body = response.text
344 assert "<html" not in body
345 assert "Groove Analysis" in body
346
347
348 # ---------------------------------------------------------------------------
349 # Form analysis tests
350 # ---------------------------------------------------------------------------
351
352
353 @pytest.mark.anyio
354 async def test_form_analysis_renders_sections(
355 client: AsyncClient,
356 db_session: AsyncSession,
357 ) -> None:
358 """GET form analysis page renders section labels in HTML (server-side)."""
359 await _make_repo(db_session)
360 response = await client.get(_FORM_URL)
361 assert response.status_code == 200
362 body = response.text
363 assert "Form Analysis" in body
364 assert "Form" in body
365 # Sections table is always rendered; at least one section label must appear
366 section_labels = {"intro", "verse", "chorus", "bridge", "outro"}
367 assert any(label in body.lower() for label in section_labels), (
368 "Expected a section label (intro/verse/chorus/bridge/outro) in response"
369 )
370
371
372 @pytest.mark.anyio
373 async def test_form_analysis_renders_timeline(
374 client: AsyncClient,
375 db_session: AsyncSession,
376 ) -> None:
377 """Form page renders section timeline as CSS bars — server-side only."""
378 await _make_repo(db_session)
379 response = await client.get(_FORM_URL)
380 assert response.status_code == 200
381 body = response.text
382 assert "Form Timeline" in body
383 assert "Sections" in body
384 assert "Total Beats" in body
385
386
387 @pytest.mark.anyio
388 async def test_form_analysis_htmx_fragment_path(
389 client: AsyncClient,
390 db_session: AsyncSession,
391 ) -> None:
392 """GET form page with HX-Request: true returns fragment only."""
393 await _make_repo(db_session)
394 response = await client.get(_FORM_URL, headers={"HX-Request": "true"})
395 assert response.status_code == 200
396 body = response.text
397 assert "<html" not in body
398 assert "Form Analysis" in body
399
400
401 # ---------------------------------------------------------------------------
402 # No JS chart library tests (all pages)
403 # ---------------------------------------------------------------------------
404
405
406 @pytest.mark.anyio
407 async def test_analysis_pages_no_js_chart_lib(
408 client: AsyncClient,
409 db_session: AsyncSession,
410 ) -> None:
411 """None of the analysis SSR pages reference ChartJS or D3 chart libraries."""
412 await _make_repo(db_session)
413 urls = [_DASHBOARD_URL, _KEY_URL, _TEMPO_URL, _METER_URL, _GROOVE_URL, _FORM_URL]
414 for url in urls:
415 response = await client.get(url)
416 assert response.status_code == 200, f"Expected 200 for {url}"
417 body = response.text.lower()
418 assert "chart.js" not in body, f"ChartJS found in {url}"
419 assert "d3.js" not in body, f"D3 found in {url}"
420 assert "cdn.jsdelivr.net/npm/chart" not in body, f"ChartJS CDN found in {url}"