"""Tests for MuseHub web UI endpoints. Covers (compare view): - test_compare_page_renders — GET /{owner}/{slug}/compare/{base}...{head} returns 200 - test_compare_page_no_auth_required — compare page accessible without JWT - test_compare_page_invalid_ref_404 — refs without ... separator return 404 - test_compare_page_unknown_owner_404 — unknown owner/slug returns 404 - test_compare_page_includes_radar — SSR: all five dimension names present in HTML (replaces JS radar) - test_compare_page_includes_piano_roll — SSR: dimension table header columns in HTML (replaces piano roll JS) - test_compare_page_includes_emotion_diff — SSR: Change delta column present (replaces emotion diff JS) - test_compare_page_includes_commit_list — SSR: all dimension rows present (replaces commit list JS) - test_compare_page_includes_create_pr_button — SSR: both ref names in heading (replaces PR button CTA) - test_compare_json_response — SSR: response is text/html with dimension data (no JSON negotiation) - test_compare_unknown_ref_404 — unknown ref returns 404 Covers acceptance criteria (commit list page): - test_commits_list_page_returns_200 — GET /{owner}/{repo}/commits returns HTML - test_commits_list_page_shows_commit_sha — SHA of seeded commit appears in page - test_commits_list_page_shows_commit_message — message appears in page - test_commits_list_page_dag_indicator — DAG node element present - test_commits_list_page_pagination_links — Older/Newer nav links present when multi-page - test_commits_list_page_branch_selector — branch dropdown is present when the repo has branches.""" await _seed_commit_list_repo(db_session) resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits") assert resp.status_code == 200 # Select element with branch options assert "branch-sel" in resp.text assert "main" in resp.text assert "feat/drums" in resp.text @pytest.mark.anyio async def test_commits_list_page_graph_link( client: AsyncClient, db_session: AsyncSession, ) -> None: """Link to the DAG graph page is present.""" await _seed_commit_list_repo(db_session) resp = await client.get(f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits") assert resp.status_code == 200 assert "/graph" in resp.text @pytest.mark.anyio async def test_commits_list_page_pagination_links( client: AsyncClient, db_session: AsyncSession, ) -> None: """Pagination nav links appear when total exceeds per_page.""" await _seed_commit_list_repo(db_session) # Request per_page=2 so 4 commits produce 2 pages resp = await client.get( f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=1" ) assert resp.status_code == 200 body = resp.text # "Older" link should be active (page 1 has no "Newer") assert "Older" in body # "Newer" should be disabled on page 1 assert "Newer" in body assert "page=2" in body @pytest.mark.anyio async def test_commits_list_page_pagination_page2( client: AsyncClient, db_session: AsyncSession, ) -> None: """Page 2 renders with Newer navigation active.""" await _seed_commit_list_repo(db_session) resp = await client.get( f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?per_page=2&page=2" ) assert resp.status_code == 200 body = resp.text assert "page=1" in body # "Newer" link points back to page 1 @pytest.mark.anyio async def test_commits_list_page_branch_filter_html( client: AsyncClient, db_session: AsyncSession, ) -> None: """?branch=main returns only main-branch commits in HTML.""" await _seed_commit_list_repo(db_session) resp = await client.get( f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?branch=main" ) assert resp.status_code == 200 body = resp.text # main commits appear assert _SHA_MAIN_1[:8] in body assert _SHA_MAIN_2[:8] in body assert _SHA_MAIN_MERGE[:8] in body # feat/drums commit should NOT appear when filtered to main assert _SHA_FEAT[:8] not in body @pytest.mark.anyio async def test_commits_list_page_json_content_negotiation( client: AsyncClient, db_session: AsyncSession, ) -> None: """?format=json returns CommitListResponse JSON with commits and total.""" await _seed_commit_list_repo(db_session) resp = await client.get( f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits?format=json" ) assert resp.status_code == 200 assert "application/json" in resp.headers["content-type"] body = resp.json() assert "commits" in body assert "total" in body assert body["total"] == 4 assert len(body["commits"]) == 4 # Commits are newest first; merge commit has timestamp now-1h (most recent) commit_ids = [c["commitId"] for c in body["commits"]] assert commit_ids[0] == _SHA_MAIN_MERGE @pytest.mark.anyio async def test_commits_list_page_json_pagination( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON with per_page=1&page=2 returns the second commit.""" await _seed_commit_list_repo(db_session) resp = await client.get( f"/{_COMMIT_LIST_OWNER}/{_COMMIT_LIST_SLUG}/commits" "?format=json&per_page=1&page=2" ) assert resp.status_code == 200 body = resp.json() assert body["total"] == 4 assert len(body["commits"]) == 1 # Page 2 (newest-first) is the second most-recent commit. # Newest: _SHA_MAIN_MERGE (now-1h), then _SHA_MAIN_2 (now-2h) assert body["commits"][0]["commitId"] == _SHA_MAIN_2 @pytest.mark.anyio async def test_commits_list_page_empty_state( client: AsyncClient, db_session: AsyncSession, ) -> None: """A repo with no commits shows the empty state message.""" repo = MusehubRepo( name="empty-repo", owner="emptyowner", slug="empty-repo", visibility="public", owner_user_id="empty-owner-uid", ) db_session.add(repo) await db_session.commit() resp = await client.get("/emptyowner/empty-repo/commits") assert resp.status_code == 200 assert "No commits yet" in resp.text or "muse push" in resp.text # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Commit detail enhancements — # --------------------------------------------------------------------------- async def _seed_commit_detail_fixtures( db_session: AsyncSession, ) -> tuple[str, str, str]: """Seed a public repo with a parent commit and a child commit. Returns (repo_id, parent_commit_id, child_commit_id). """ repo = MusehubRepo( name="commit-detail-test", owner="testuser", slug="commit-detail-test", visibility="public", owner_user_id="test-owner", ) db_session.add(repo) await db_session.flush() repo_id = str(repo.repo_id) branch = MusehubBranch( repo_id=repo_id, name="main", head_commit_id=None, ) db_session.add(branch) parent_commit_id = "aaaa0000111122223333444455556666aaaabbbb" child_commit_id = "bbbb1111222233334444555566667777bbbbcccc" parent_commit = MusehubCommit( repo_id=repo_id, commit_id=parent_commit_id, branch="main", parent_ids=[], message="init: establish harmonic foundation in C major\n\nKey: C major\nBPM: 120\nMeter: 4/4", author="testuser", timestamp=datetime.now(UTC) - timedelta(hours=2), snapshot_id=None, ) child_commit = MusehubCommit( repo_id=repo_id, commit_id=child_commit_id, branch="main", parent_ids=[parent_commit_id], message="feat(keys): add melodic piano phrase in D minor\n\nKey: D minor\nBPM: 132\nMeter: 3/4\nSection: verse", author="testuser", timestamp=datetime.now(UTC) - timedelta(hours=1), snapshot_id=None, ) db_session.add(parent_commit) db_session.add(child_commit) await db_session.commit() return repo_id, parent_commit_id, child_commit_id @pytest.mark.anyio async def test_commit_detail_page_renders_enhanced_metadata( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page SSR renders commit header fields (SHA, author, branch, parent link).""" await _seed_commit_detail_fixtures(db_session) sha = "bbbb1111222233334444555566667777bbbbcccc" response = await client.get(f"/testuser/commit-detail-test/commits/{sha}") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] body = response.text # SSR commit header — short SHA present assert "bbbb1111" in body # Author field rendered server-side assert "testuser" in body # Parent SHA navigation link present assert "aaaa0000" in body @pytest.mark.anyio async def test_commit_detail_audio_shell_with_snapshot_id( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit with snapshot_id gets a WaveSurfer shell rendered by the server.""" from datetime import datetime, timezone _repo_id, _parent_id, _child_id = await _seed_commit_detail_fixtures(db_session) repo = MusehubRepo( name="audio-test-repo", owner="testuser", slug="audio-test-repo", visibility="public", owner_user_id="test-owner", ) db_session.add(repo) await db_session.flush() snap_id = "sha256:deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678" commit_with_audio = MusehubCommit( commit_id="cccc2222333344445555666677778888ccccdddd", repo_id=str(repo.repo_id), branch="main", parent_ids=[], message="Commit with audio snapshot", author="testuser", timestamp=datetime.now(tz=timezone.utc), snapshot_id=snap_id, ) db_session.add(commit_with_audio) await db_session.commit() response = await client.get( f"/testuser/audio-test-repo/commits/cccc2222333344445555666677778888ccccdddd" ) assert response.status_code == 200 body = response.text # Commit page renders successfully; audio shell shown only for piano_roll domain repos assert "audio-test-repo" in body assert "Commit with audio snapshot" in body @pytest.mark.anyio async def test_commit_detail_ssr_message_present_in_body( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit message text is rendered in the SSR page body (replaces JS renderCommitBody).""" await _seed_commit_detail_fixtures(db_session) sha = "bbbb1111222233334444555566667777bbbbcccc" response = await client.get(f"/testuser/commit-detail-test/commits/{sha}") assert response.status_code == 200 body = response.text # SSR renders the commit message directly — no JS renderCommitBody needed assert "feat(keys): add melodic piano phrase in D minor" in body @pytest.mark.anyio async def test_commit_detail_diff_summary_endpoint_returns_five_dimensions( client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str], ) -> None: """GET /api/v1/repos/{repo_id}/commits/{sha}/diff-summary returns 5 dimensions.""" repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session) response = await client.get( f"/api/v1/repos/{repo_id}/commits/{child_id}/diff-summary", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["commitId"] == child_id assert data["parentId"] == _parent_id assert "dimensions" in data assert len(data["dimensions"]) == 5 dim_names = {d["dimension"] for d in data["dimensions"]} assert dim_names == {"harmonic", "rhythmic", "melodic", "structural", "dynamic"} for dim in data["dimensions"]: assert 0.0 <= dim["score"] <= 1.0 assert dim["label"] in {"none", "low", "medium", "high"} assert dim["color"] in {"dim-none", "dim-low", "dim-medium", "dim-high"} assert "overallScore" in data assert 0.0 <= data["overallScore"] <= 1.0 @pytest.mark.anyio async def test_commit_detail_diff_summary_root_commit_scores_one( client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str], ) -> None: """Diff summary for a root commit (no parent) scores all dimensions at 1.0.""" repo_id, parent_id, _child_id = await _seed_commit_detail_fixtures(db_session) response = await client.get( f"/api/v1/repos/{repo_id}/commits/{parent_id}/diff-summary", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["parentId"] is None for dim in data["dimensions"]: assert dim["score"] == 1.0 assert dim["label"] == "high" @pytest.mark.anyio async def test_commit_detail_diff_summary_keyword_detection( client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str], ) -> None: """Diff summary detects melodic keyword in child commit message.""" repo_id, _parent_id, child_id = await _seed_commit_detail_fixtures(db_session) response = await client.get( f"/api/v1/repos/{repo_id}/commits/{child_id}/diff-summary", headers=auth_headers, ) assert response.status_code == 200 data = response.json() melodic_dim = next(d for d in data["dimensions"] if d["dimension"] == "melodic") # child commit message contains "melodic" keyword → non-zero score assert melodic_dim["score"] > 0.0 @pytest.mark.anyio async def test_commit_detail_diff_summary_unknown_commit_404( client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str], ) -> None: """Diff summary for unknown commit ID returns 404.""" repo_id, _p, _c = await _seed_commit_detail_fixtures(db_session) response = await client.get( f"/api/v1/repos/{repo_id}/commits/deadbeefdeadbeefdeadbeef/diff-summary", headers=auth_headers, ) assert response.status_code == 404 # --------------------------------------------------------------------------- # Commit comment threads — # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_commit_page_has_comment_section_html( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page HTML includes the HTMX comment target container.""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text # SSR replaces JS-loaded comment section with a server-rendered HTMX target assert "commit-comments" in body assert "hx-target" in body @pytest.mark.anyio async def test_commit_page_has_htmx_comment_form( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page has an HTMX-driven comment form (replaces old JS comment functions).""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text # HTMX form replaces JS renderComments/submitComment/loadComments assert "hx-post" in body assert "hx-target" in body assert "textarea" in body @pytest.mark.anyio async def test_commit_page_comment_htmx_target_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """HTMX comment target div is present for server-side comment injection.""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text assert 'id="commit-comments"' in body @pytest.mark.anyio async def test_commit_page_comment_htmx_posts_to_comments_endpoint( client: AsyncClient, db_session: AsyncSession, ) -> None: """HTMX form posts to the commit comments endpoint (replaces old JS API fetch).""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text assert "hx-post" in body assert "/comments" in body @pytest.mark.anyio async def test_commit_page_comment_has_ssr_avatar( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit page SSR comment thread renders avatar initials via server-side template.""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text # comment-avatar only rendered when comments exist; check commit page structure assert "commit-detail" in body or "page-data" in body @pytest.mark.anyio async def test_commit_page_comment_has_htmx_form_elements( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit page HTMX comment form has textarea and submit button.""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text # HTMX form replaces old new-comment-form/new-comment-body/comment-submit-btn assert 'name="body"' in body assert "btn-primary" in body assert "Comment" in body @pytest.mark.anyio async def test_commit_page_comment_section_shows_count_heading( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit page SSR comment section shows a count heading (replaces 'Discussion' heading).""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text assert "comment" in body # --------------------------------------------------------------------------- # Commit detail enhancements — ref URL links, DB tags in panel, prose # summary # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_commit_page_ssr_renders_commit_message( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit message is rendered server-side (replaces JS ref-tag / tagPill rendering).""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Unique groove message XYZ", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text # SSR renders commit message directly — no JS tagPill/isRefUrl needed assert "Unique groove message XYZ" in body @pytest.mark.anyio async def test_commit_page_ssr_renders_author_metadata( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit author and branch appear in the SSR metadata grid (replaces JS muse-tags panel).""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="jazzproducer", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text # SSR metadata grid shows author — no JS loadMuseTagsPanel needed assert "jazzproducer" in body @pytest.mark.anyio async def test_commit_page_no_audio_shell_when_no_snapshot( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit page without snapshot_id omits WaveSurfer shell (replaces buildProseSummary check).""" from datetime import datetime, timezone repo_id = await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" commit = MusehubCommit( commit_id=commit_id, repo_id=repo_id, branch="main", parent_ids=[], message="Add chorus section", author="testuser", timestamp=datetime.now(tz=timezone.utc), snapshot_id=None, ) db_session.add(commit) await db_session.commit() response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 200 body = response.text assert "commit-waveform" not in body # --------------------------------------------------------------------------- # Audio player — listen page tests # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_listen_page_renders( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{owner}/{slug}/view/{ref} must return 200 HTML.""" await _make_repo(db_session) ref = "abc1234567890abcdef" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] @pytest.mark.anyio async def test_listen_page_no_auth_required( client: AsyncClient, db_session: AsyncSession, ) -> None: """Listen page must be accessible without an Authorization header.""" await _make_repo(db_session) ref = "deadbeef1234" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code != 401 assert response.status_code == 200 @pytest.mark.anyio async def test_listen_page_contains_waveform_ui( client: AsyncClient, db_session: AsyncSession, ) -> None: """Listen page HTML must contain the page config for listen.ts.""" await _make_repo(db_session) ref = "cafebabe1234" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code == 200 body = response.text assert '"viewerType"' in body @pytest.mark.anyio async def test_listen_page_contains_play_button( client: AsyncClient, db_session: AsyncSession, ) -> None: """Listen page must dispatch the listen.ts module via page config.""" await _make_repo(db_session) ref = "feed1234abcdef" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code == 200 body = response.text assert '"viewerType"' in body @pytest.mark.anyio async def test_listen_page_contains_speed_selector( client: AsyncClient, db_session: AsyncSession, ) -> None: """Listen page must dispatch the listen.ts module (speed selector is client-side).""" await _make_repo(db_session) ref = "1a2b3c4d5e6f7890" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code == 200 body = response.text assert '"viewerType"' in body @pytest.mark.anyio async def test_listen_page_contains_ab_loop_ui( client: AsyncClient, db_session: AsyncSession, ) -> None: """Listen page must dispatch the listen.ts module (A/B loop controls are client-side).""" await _make_repo(db_session) ref = "aabbccddeeff0011" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code == 200 body = response.text assert '"viewerType"' in body @pytest.mark.anyio async def test_listen_page_loads_wavesurfer_vendor( client: AsyncClient, db_session: AsyncSession, ) -> None: """Listen page must dispatch the listen.ts module (WaveSurfer loaded via app.js bundle).""" await _make_repo(db_session) ref = "112233445566778899" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code == 200 body = response.text # WaveSurfer is now bundled/loaded by listen.ts, not as a separate script tag assert '"viewerType"' in body # wavesurfer must NOT be loaded from an external CDN assert "unpkg.com/wavesurfer" not in body assert "cdn.jsdelivr.net/wavesurfer" not in body assert "cdnjs.cloudflare.com/ajax/libs/wavesurfer" not in body @pytest.mark.anyio async def test_listen_page_loads_audio_player_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """Listen page must dispatch the listen.ts module (audio player is now bundled in app.js).""" await _make_repo(db_session) ref = "99aabbccddeeff00" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code == 200 body = response.text assert '"viewerType"' in body @pytest.mark.anyio async def test_listen_track_page_renders( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{owner}/{slug}/view/{ref}/{path} must return 200.""" await _make_repo(db_session) ref = "feedface0011aabb" response = await client.get( f"/testuser/test-beats/view/{ref}/tracks/bass.mp3" ) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] @pytest.mark.anyio async def test_listen_track_page_has_track_path_in_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """Track path is passed via page config JSON to listen.ts, not as a JS variable.""" await _make_repo(db_session) ref = "00aabbccddeeff11" track = "tracks/lead-guitar.mp3" response = await client.get( f"/testuser/test-beats/view/{ref}/{track}" ) assert response.status_code == 200 body = response.text assert '"viewerType"' in body assert "lead-guitar.mp3" in body @pytest.mark.anyio async def test_listen_page_unknown_repo_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET listen page with nonexistent owner/slug must return 404.""" response = await client.get( "/nobody/nonexistent-repo/view/abc123" ) assert response.status_code == 404 @pytest.mark.anyio async def test_listen_page_keyboard_shortcuts_documented( client: AsyncClient, db_session: AsyncSession, ) -> None: """Listen page dispatches listen.ts (keyboard shortcuts handled client-side).""" await _make_repo(db_session) ref = "cafe0011aabb2233" response = await client.get(f"/testuser/test-beats/view/{ref}") assert response.status_code == 200 body = response.text assert '"viewerType"' in body # --------------------------------------------------------------------------- # Compare view # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_compare_page_invalid_ref_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """Compare path without '...' separator returns 404.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/compare/mainfeature") assert response.status_code == 404 @pytest.mark.anyio async def test_compare_page_unknown_owner_404( client: AsyncClient, ) -> None: """Unknown owner/slug combination returns 404 on compare page.""" response = await client.get("/nobody/norepo/compare/main...feature") assert response.status_code == 404 # --------------------------------------------------------------------------- # Issue #208 — Branch list and tag browser tests # --------------------------------------------------------------------------- async def _make_repo_with_branches( db_session: AsyncSession, ) -> tuple[str, str, str]: """Seed a repo with two branches (main + feature) and return (repo_id, owner, slug).""" repo = MusehubRepo( name="branch-test", owner="testuser", slug="branch-test", visibility="private", owner_user_id="test-owner", ) db_session.add(repo) await db_session.flush() repo_id = str(repo.repo_id) main_branch = MusehubBranch(repo_id=repo_id, name="main", head_commit_id="aaa000") feat_branch = MusehubBranch(repo_id=repo_id, name="feat/jazz-bridge", head_commit_id="bbb111") db_session.add_all([main_branch, feat_branch]) # Two commits on main, one unique commit on feat/jazz-bridge now = datetime.now(UTC) c1 = MusehubCommit( commit_id="aaa000", repo_id=repo_id, branch="main", parent_ids=[], message="Initial commit", author="composer@muse.app", timestamp=now, ) c2 = MusehubCommit( commit_id="aaa001", repo_id=repo_id, branch="main", parent_ids=["aaa000"], message="Add bridge", author="composer@muse.app", timestamp=now, ) c3 = MusehubCommit( commit_id="bbb111", repo_id=repo_id, branch="feat/jazz-bridge", parent_ids=["aaa000"], message="Add jazz chord", author="composer@muse.app", timestamp=now, ) db_session.add_all([c1, c2, c3]) await db_session.commit() return repo_id, "testuser", "branch-test" async def _make_repo_with_releases( db_session: AsyncSession, ) -> tuple[str, str, str]: """Seed a repo with namespaced releases used as tags.""" repo = MusehubRepo( name="tag-test", owner="testuser", slug="tag-test", visibility="private", owner_user_id="test-owner", ) db_session.add(repo) await db_session.flush() repo_id = str(repo.repo_id) now = datetime.now(UTC) releases = [ MusehubRelease( repo_id=repo_id, tag="emotion:happy", title="Happy vibes", body="", commit_id="abc001", author="composer", created_at=now, download_urls={}, ), MusehubRelease( repo_id=repo_id, tag="genre:jazz", title="Jazz release", body="", commit_id="abc002", author="composer", created_at=now, download_urls={}, ), MusehubRelease( repo_id=repo_id, tag="v1.0", title="Version 1.0", body="", commit_id="abc003", author="composer", created_at=now, download_urls={}, ), ] db_session.add_all(releases) await db_session.commit() return repo_id, "testuser", "tag-test" @pytest.mark.anyio async def test_branches_page_lists_all( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{owner}/{slug}/branches returns 200 HTML.""" await _make_repo_with_branches(db_session) resp = await client.get("/testuser/branch-test/branches") assert resp.status_code == 200 assert "text/html" in resp.headers["content-type"] body = resp.text assert "MuseHub" in body # Page-specific JS identifiers assert "branch-row" in body or "branches" in body.lower() @pytest.mark.anyio async def test_branches_default_marked( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response marks the default branch with isDefault=true.""" await _make_repo_with_branches(db_session) resp = await client.get( "/testuser/branch-test/branches", headers={"Accept": "application/json"}, ) assert resp.status_code == 200 data = resp.json() assert "branches" in data default_branches = [b for b in data["branches"] if b.get("isDefault")] assert len(default_branches) == 1 assert default_branches[0]["name"] == "main" @pytest.mark.anyio async def test_branches_compare_link( client: AsyncClient, db_session: AsyncSession, ) -> None: """Branches page HTML contains compare link JavaScript.""" await _make_repo_with_branches(db_session) resp = await client.get("/testuser/branch-test/branches") assert resp.status_code == 200 body = resp.text # The JS template must reference the compare URL pattern assert "compare" in body.lower() @pytest.mark.anyio async def test_branches_new_pr_button( client: AsyncClient, db_session: AsyncSession, ) -> None: """Branches page HTML contains New Pull Request link JavaScript.""" await _make_repo_with_branches(db_session) resp = await client.get("/testuser/branch-test/branches") assert resp.status_code == 200 body = resp.text assert "Pull Request" in body @pytest.mark.anyio async def test_branches_json_response( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response includes branches with ahead/behind counts and divergence placeholder.""" await _make_repo_with_branches(db_session) resp = await client.get( "/testuser/branch-test/branches?format=json", ) assert resp.status_code == 200 data = resp.json() assert "branches" in data assert "defaultBranch" in data assert data["defaultBranch"] == "main" branches_by_name = {b["name"]: b for b in data["branches"]} assert "main" in branches_by_name assert "feat/jazz-bridge" in branches_by_name main = branches_by_name["main"] assert main["isDefault"] is True assert main["aheadCount"] == 0 assert main["behindCount"] == 0 feat = branches_by_name["feat/jazz-bridge"] assert feat["isDefault"] is False # feat has 1 unique commit (bbb111); main has 2 commits (aaa000, aaa001) not shared with feat assert feat["aheadCount"] == 1 assert feat["behindCount"] == 2 # Divergence is a placeholder (all None) div = feat["divergence"] assert div["melodic"] is None assert div["harmonic"] is None @pytest.mark.anyio async def test_tags_page_lists_all( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{owner}/{slug}/tags returns 200 HTML.""" await _make_repo_with_releases(db_session) resp = await client.get("/testuser/tag-test/tags") assert resp.status_code == 200 assert "text/html" in resp.headers["content-type"] body = resp.text assert "MuseHub" in body assert "Tags" in body @pytest.mark.anyio async def test_tags_namespace_filter( client: AsyncClient, db_session: AsyncSession, ) -> None: """Tags page HTML includes namespace filter dropdown JavaScript.""" await _make_repo_with_releases(db_session) resp = await client.get("/testuser/tag-test/tags") assert resp.status_code == 200 body = resp.text # Namespace filter select element is rendered by JS assert "ns-filter" in body or "namespace" in body.lower() # Namespace icons present assert "🌘" in body or "emotion" in body @pytest.mark.anyio async def test_tags_json_response( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response returns TagListResponse with namespace grouping.""" await _make_repo_with_releases(db_session) resp = await client.get( "/testuser/tag-test/tags?format=json", ) assert resp.status_code == 200 data = resp.json() assert "tags" in data assert "namespaces" in data # All three releases become tags assert len(data["tags"]) == 3 tags_by_name = {t["tag"]: t for t in data["tags"]} assert "emotion:happy" in tags_by_name assert "genre:jazz" in tags_by_name assert "v1.0" in tags_by_name assert tags_by_name["emotion:happy"]["namespace"] == "emotion" assert tags_by_name["genre:jazz"]["namespace"] == "genre" assert tags_by_name["v1.0"]["namespace"] == "version" # Namespaces are sorted assert sorted(data["namespaces"]) == data["namespaces"] assert "emotion" in data["namespaces"] assert "genre" in data["namespaces"] assert "version" in data["namespaces"] # --------------------------------------------------------------------------- # Arrangement matrix page — # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Piano roll page tests — # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_arrange_page_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{owner}/{slug}/view/{ref} returns 200 HTML without a JWT.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/HEAD") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] @pytest.mark.anyio async def test_piano_roll_page_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{owner}/{slug}/view/{ref} returns 200 HTML.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/main") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] @pytest.mark.anyio async def test_arrange_page_no_auth_required( client: AsyncClient, db_session: AsyncSession, ) -> None: """Arrangement matrix page is accessible without a JWT (auth handled client-side).""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/HEAD") assert response.status_code == 200 assert response.status_code != 401 @pytest.mark.anyio async def test_arrange_page_contains_musehub( client: AsyncClient, db_session: AsyncSession, ) -> None: """Arrangement matrix page HTML shell contains 'MuseHub' branding.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/abc1234") assert response.status_code == 200 assert "MuseHub" in response.text @pytest.mark.anyio async def test_arrange_page_contains_grid_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """Insights page (aliases /view) renders the domain viewer container.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/HEAD") assert response.status_code == 200 body = response.text assert "ins-page" in body @pytest.mark.anyio async def test_arrange_page_contains_density_logic( client: AsyncClient, db_session: AsyncSession, ) -> None: """Domain view page embeds the viewerType in page config JSON.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/HEAD") assert response.status_code == 200 body = response.text assert "viewerType" in body @pytest.mark.anyio async def test_arrange_page_contains_token_form( client: AsyncClient, db_session: AsyncSession, ) -> None: """Insights page (aliases /view) renders successfully with MuseHub branding.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/HEAD") assert response.status_code == 200 body = response.text assert "MuseHub" in body assert "ins-page" in body @pytest.mark.anyio async def test_arrange_page_unknown_repo_returns_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{unknown}/{slug}/view/{ref} returns 404 for unknown repos.""" response = await client.get("/unknown-user/no-such-repo/view/HEAD") assert response.status_code == 404 @pytest.mark.anyio async def test_commit_detail_unknown_format_param_returns_html( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET commit detail page ignores ?format=json — SSR always returns HTML.""" await _seed_commit_detail_fixtures(db_session) sha = "bbbb1111222233334444555566667777bbbbcccc" response = await client.get( f"/testuser/commit-detail-test/commits/{sha}?format=json" ) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] # SSR commit page — commit message appears in body assert "feat(keys)" in response.text @pytest.mark.anyio async def test_commit_detail_wavesurfer_js_conditional_on_audio_url( client: AsyncClient, db_session: AsyncSession, ) -> None: """WaveSurfer JS block is only present when audio_url is set (replaces musicalMeta JS checks).""" await _seed_commit_detail_fixtures(db_session) sha = "bbbb1111222233334444555566667777bbbbcccc" response = await client.get(f"/testuser/commit-detail-test/commits/{sha}") assert response.status_code == 200 body = response.text # The child commit has no snapshot_id in _seed_commit_detail_fixtures → no WaveSurfer assert "commit-waveform" not in body # WaveSurfer script only loaded when audio is present — not here assert "wavesurfer.min.js" not in body @pytest.mark.anyio async def test_commit_detail_nav_has_parent_link( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page navigation includes the parent commit link (SSR).""" await _seed_commit_detail_fixtures(db_session) sha = "bbbb1111222233334444555566667777bbbbcccc" response = await client.get(f"/testuser/commit-detail-test/commits/{sha}") assert response.status_code == 200 body = response.text # SSR renders parent commit link when parent_ids is non-empty assert "Parent" in body # Parent SHA abbreviated to 8 chars in href assert "aaaa0000" in body @pytest.mark.anyio async def test_piano_roll_page_no_auth_required( client: AsyncClient, db_session: AsyncSession, ) -> None: """Piano roll UI page is accessible without a JWT token.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/main") assert response.status_code == 200 @pytest.mark.anyio async def test_piano_roll_page_loads_piano_roll_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """View page embeds the viewerType in page config JSON.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/main") assert response.status_code == 200 assert "viewerType" in response.text @pytest.mark.anyio async def test_piano_roll_page_contains_canvas( client: AsyncClient, db_session: AsyncSession, ) -> None: """Insights page (aliases /view) renders the domain viewer container server-side.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/main") assert response.status_code == 200 body = response.text assert "ins-page" in body @pytest.mark.anyio async def test_piano_roll_page_has_token_form( client: AsyncClient, db_session: AsyncSession, ) -> None: """Insights page (aliases /view) renders the domain viewer container and page config.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats/view/main") assert response.status_code == 200 assert "ins-page" in response.text assert "viewerType" in response.text @pytest.mark.anyio async def test_piano_roll_page_unknown_repo_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """Piano roll page for an unknown repo returns 404.""" response = await client.get("/nobody/no-repo/view/main") assert response.status_code == 404 @pytest.mark.anyio async def test_arrange_tab_in_repo_nav( client: AsyncClient, db_session: AsyncSession, ) -> None: """Repo home page navigation includes an Insights link for the domain viewer.""" await _make_repo(db_session) response = await client.get("/testuser/test-beats") assert response.status_code == 200 assert "/insights/" in response.text @pytest.mark.anyio async def test_piano_roll_track_page_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /view/{ref}/{path} (single track) returns 200.""" await _make_repo(db_session) response = await client.get( "/testuser/test-beats/view/main/tracks/bass.mid" ) assert response.status_code == 200 @pytest.mark.anyio async def test_piano_roll_track_page_embeds_path( client: AsyncClient, db_session: AsyncSession, ) -> None: """Single-track piano roll page embeds the MIDI file path in the JS context.""" await _make_repo(db_session) response = await client.get( "/testuser/test-beats/view/main/tracks/bass.mid" ) assert response.status_code == 200 assert "tracks/bass.mid" in response.text @pytest.mark.anyio async def test_piano_roll_js_served(client: AsyncClient) -> None: """GET /static/piano-roll.js returns 200 JavaScript.""" response = await client.get("/static/piano-roll.js") assert response.status_code == 200 assert "javascript" in response.headers.get("content-type", "") @pytest.mark.anyio async def test_piano_roll_js_contains_renderer(client: AsyncClient) -> None: """piano-roll.js exports the PianoRoll.render function.""" response = await client.get("/static/piano-roll.js") assert response.status_code == 200 body = response.text assert "PianoRoll" in body assert "render" in body async def _seed_blob_fixtures(db_session: AsyncSession) -> str: """Seed a public repo with a branch and typed objects for blob viewer tests. Creates: - repo: testuser/blob-test (public) - branch: main - objects: tracks/bass.mid, tracks/keys.mp3, metadata.json, cover.webp Returns repo_id. """ repo = MusehubRepo( name="blob-test", owner="testuser", slug="blob-test", visibility="public", owner_user_id="test-owner", ) db_session.add(repo) await db_session.flush() commit = MusehubCommit( commit_id="blobdeadbeef12", repo_id=str(repo.repo_id), message="add blob fixtures", branch="main", author="testuser", timestamp=datetime.now(tz=UTC), ) db_session.add(commit) branch = MusehubBranch( repo_id=str(repo.repo_id), name="main", head_commit_id="blobdeadbeef12", ) db_session.add(branch) for path, size in [ ("tracks/bass.mid", 2048), ("tracks/keys.mp3", 8192), ("metadata.json", 512), ("cover.webp", 4096), ]: obj = MusehubObject( object_id=f"sha256:blob_{path.replace('/', '_')}", repo_id=str(repo.repo_id), path=path, size_bytes=size, disk_path=f"/tmp/blob_{path.replace('/', '_')}", ) db_session.add(obj) await db_session.commit() return str(repo.repo_id) @pytest.mark.anyio async def test_blob_404_unknown_path( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /api/v1/repos/{repo_id}/blob/{ref}/{path} returns 404 for unknown path.""" repo_id = await _seed_blob_fixtures(db_session) response = await client.get(f"/api/v1/repos/{repo_id}/blob/main/does/not/exist.mid") assert response.status_code == 404 @pytest.mark.anyio async def test_blob_image_shows_inline( client: AsyncClient, db_session: AsyncSession, ) -> None: """Blob page for .webp file includes image config in the page_json data block.""" await _seed_blob_fixtures(db_session) response = await client.get("/testuser/blob-test/blob/main/cover.webp") assert response.status_code == 200 body = response.text # blob.ts handles image rendering client-side; SSR provides config via page_json assert '"page": "blob"' in body assert "cover.webp" in body @pytest.mark.anyio async def test_blob_json_response( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /api/v1/repos/{repo_id}/blob/{ref}/{path} returns BlobMetaResponse JSON.""" repo_id = await _seed_blob_fixtures(db_session) response = await client.get( f"/api/v1/repos/{repo_id}/blob/main/tracks/bass.mid" ) assert response.status_code == 200 data = response.json() assert data["path"] == "tracks/bass.mid" assert data["filename"] == "bass.mid" assert data["sizeBytes"] == 2048 assert data["fileType"] == "midi" assert data["sha"].startswith("sha256:") assert "/raw/" in data["rawUrl"] # MIDI is binary — no content_text assert data["contentText"] is None @pytest.mark.anyio async def test_blob_json_syntax_highlighted( client: AsyncClient, db_session: AsyncSession, ) -> None: """Blob page for .json file includes syntax-highlighting config in page_json data block.""" await _seed_blob_fixtures(db_session) response = await client.get("/testuser/blob-test/blob/main/metadata.json") assert response.status_code == 200 body = response.text # blob.ts handles syntax highlighting client-side; SSR provides config via page_json assert '"page": "blob"' in body assert "metadata.json" in body @pytest.mark.anyio async def test_blob_midi_shows_piano_roll_link( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{owner}/{repo}/blob/{ref}/{path} returns 200 HTML for a .mid file. The template's client-side JS must reference the piano roll URL pattern so that clicking the page in a browser navigates to the piano roll viewer. """ await _seed_blob_fixtures(db_session) response = await client.get("/testuser/blob-test/blob/main/tracks/bass.mid") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] body = response.text # JS in the template constructs piano-roll URLs for MIDI files assert "piano-roll" in body or "Piano Roll" in body # Filename is embedded in the page context assert "bass.mid" in body @pytest.mark.anyio async def test_blob_raw_button( client: AsyncClient, db_session: AsyncSession, ) -> None: """Blob page JS constructs a Raw download link via the /raw/ endpoint.""" await _seed_blob_fixtures(db_session) response = await client.get("/testuser/blob-test/blob/main/tracks/bass.mid") assert response.status_code == 200 body = response.text # JS constructs raw URL — the string '/raw/' must appear in the template script assert "/raw/" in body @pytest.mark.anyio async def test_score_unknown_repo_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{unknown}/{slug}/score/{ref} returns 404.""" response = await client.get("/nobody/no-beats/score/main") assert response.status_code == 404 # --------------------------------------------------------------------------- # Arrangement matrix page — # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Piano roll page tests — # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_ui_commit_page_artifact_auth_uses_blob_proxy( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page (SSR, issue #583) returns 404 for non-existent commits. The pre-SSR blob-proxy artifact pattern no longer applies — artifacts are loaded via the API. Non-existent commit SHAs now return 404 rather than an empty JS shell. """ await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 404 # --------------------------------------------------------------------------- # Reaction bars — # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_reaction_bar_js_in_musehub_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """musehub.js must define loadReactions and toggleReaction for all detail pages.""" response = await client.get("/static/musehub.js") assert response.status_code == 200 body = response.text assert "loadReactions" in body assert "toggleReaction" in body assert "REACTION_BAR_EMOJIS" in body @pytest.mark.anyio async def test_reaction_bar_emojis_in_musehub_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """musehub.js reaction bar must include all 8 required emojis.""" response = await client.get("/static/musehub.js") assert response.status_code == 200 body = response.text for emoji in ["🔥", "❤️", "👏", "✨", "🎵", "🎸", "🎹", "🥁"]: assert emoji in body, f"Emoji {emoji!r} missing from musehub.js" @pytest.mark.anyio async def test_reaction_bar_commit_page_has_load_call( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page (SSR, issue #583) returns 404 for non-existent commits. Reactions are loaded via the API; the reaction bar is no longer a JS-only element in the SSR commit_detail.html template. Non-existent commits return 404. """ await _make_repo(db_session) commit_id = "abc1234567890abcdef1234567890abcdef12345678" response = await client.get(f"/testuser/test-beats/commits/{commit_id}") assert response.status_code == 404 @pytest.mark.anyio async def test_reaction_bar_pr_detail_has_load_call( client: AsyncClient, db_session: AsyncSession, ) -> None: """PR detail page renders SSR pull request content.""" from musehub.db.musehub_models import MusehubPullRequest repo_id = await _make_repo(db_session) pr = MusehubPullRequest( repo_id=repo_id, title="Test PR for reaction bar", body="", state="open", from_branch="feat/test", to_branch="main", author="testuser", ) db_session.add(pr) await db_session.commit() await db_session.refresh(pr) pr_id = str(pr.pr_id) response = await client.get(f"/testuser/test-beats/pulls/{pr_id}") assert response.status_code == 200 body = response.text assert "pd-layout" in body assert pr_id[:8] in body @pytest.mark.anyio async def test_reaction_bar_issue_detail_has_load_call( client: AsyncClient, db_session: AsyncSession, ) -> None: """Issue detail page renders SSR issue content.""" from musehub.db.musehub_models import MusehubIssue repo_id = await _make_repo(db_session) issue = MusehubIssue( repo_id=repo_id, number=1, title="Test issue for reaction bar", body="", state="open", labels=[], author="testuser", ) db_session.add(issue) await db_session.commit() response = await client.get("/testuser/test-beats/issues/1") assert response.status_code == 200 body = response.text assert "id-layout" in body assert "Test issue for reaction bar" in body @pytest.mark.anyio async def test_reaction_bar_release_detail_has_load_call( client: AsyncClient, db_session: AsyncSession, ) -> None: """Release detail page renders SSR release content (includes loadReactions call).""" repo_id = await _make_repo(db_session) release = MusehubRelease( repo_id=repo_id, tag="v1.0", title="Test Release v1.0", body="Initial release notes.", author="testuser", ) db_session.add(release) await db_session.commit() response = await client.get("/testuser/test-beats/releases/v1.0") assert response.status_code == 200 body = response.text assert "v1.0" in body assert "Test Release v1.0" in body assert "rd-header" in body assert '"page": "release-detail"' in body @pytest.mark.anyio async def test_reaction_bar_session_detail_has_load_call( client: AsyncClient, db_session: AsyncSession, ) -> None: """Session detail page renders SSR session content.""" repo_id = await _make_repo(db_session) session_id = await _make_session(db_session, repo_id) response = await client.get(f"/testuser/test-beats/sessions/{session_id}") assert response.status_code == 200 body = response.text assert "Session" in body assert session_id[:8] in body @pytest.mark.anyio async def test_reaction_api_allows_new_emojis( client: AsyncClient, db_session: AsyncSession, ) -> None: """POST /reactions with 👏 and 🎹 (new emojis) must be accepted (not 400).""" from musehub.db.musehub_models import MusehubRepo repo = MusehubRepo( name="reaction-test", owner="testuser", slug="reaction-test", visibility="public", owner_user_id="reaction-owner", ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) repo_id = str(repo.repo_id) token_headers = {"Authorization": "Bearer test-token"} for emoji in ["👏", "🎹"]: response = await client.post( f"/api/v1/repos/{repo_id}/reactions", json={"target_type": "commit", "target_id": "abc123", "emoji": emoji}, headers=token_headers, ) assert response.status_code not in (400, 422), ( f"Emoji {emoji!r} rejected by API: {response.status_code} {response.text}" ) @pytest.mark.anyio async def test_reaction_api_allows_release_and_session_target_types( client: AsyncClient, db_session: AsyncSession, ) -> None: """POST /reactions must accept 'release' and 'session' as target_type values. These target types were added to support reaction bars on release_detail and session_detail pages. """ from musehub.db.musehub_models import MusehubRepo repo = MusehubRepo( name="target-type-test", owner="testuser", slug="target-type-test", visibility="public", owner_user_id="target-type-owner", ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) repo_id = str(repo.repo_id) token_headers = {"Authorization": "Bearer test-token"} for target_type in ["release", "session"]: response = await client.post( f"/api/v1/repos/{repo_id}/reactions", json={"target_type": target_type, "target_id": "some-id", "emoji": "🔥"}, headers=token_headers, ) assert response.status_code not in (400, 422), ( f"target_type {target_type!r} rejected: {response.status_code} {response.text}" ) @pytest.mark.anyio async def test_reaction_bar_css_loaded_on_detail_pages( client: AsyncClient, db_session: AsyncSession, ) -> None: """Detail pages return 200 and load app.css (base stylesheet).""" from musehub.db.musehub_models import MusehubIssue, MusehubPullRequest repo_id = await _make_repo(db_session) pr = MusehubPullRequest( repo_id=repo_id, title="CSS test PR", body="", state="open", from_branch="feat/css", to_branch="main", author="testuser", ) db_session.add(pr) issue = MusehubIssue( repo_id=repo_id, number=1, title="CSS test issue", body="", state="open", labels=[], author="testuser", ) db_session.add(issue) release = MusehubRelease( repo_id=repo_id, tag="v1.0", title="CSS test release", body="", author="testuser", ) db_session.add(release) await db_session.commit() await db_session.refresh(pr) pr_id = str(pr.pr_id) session_id = await _make_session(db_session, repo_id) pages = [ f"/testuser/test-beats/pulls/{pr_id}", "/testuser/test-beats/issues/1", "/testuser/test-beats/releases/v1.0", f"/testuser/test-beats/sessions/{session_id}", ] for page in pages: response = await client.get(page) assert response.status_code == 200, f"Expected 200 for {page}, got {response.status_code}" assert "app.css" in response.text, f"app.css missing from {page}" @pytest.mark.anyio async def test_reaction_bar_components_css_has_styles( client: AsyncClient, db_session: AsyncSession, ) -> None: """app.css must define .reaction-bar and .reaction-btn CSS classes.""" response = await client.get("/static/app.css") assert response.status_code == 200 body = response.text assert ".reaction-bar" in body assert ".reaction-btn" in body assert ".reaction-btn--active" in body assert ".reaction-count" in body # --------------------------------------------------------------------------- # Feed page tests — (rich event cards) # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_feed_page_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /feed returns 200 HTML without requiring a JWT.""" response = await client.get("/feed") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] assert "Activity Feed" in response.text @pytest.mark.anyio async def test_feed_page_no_raw_json_payload( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page must not render raw JSON.stringify of notification payload. Regression guard: the old implementation called JSON.stringify(item.payload) directly into the DOM, exposing raw JSON to users. The new rich card templates must not do this. """ response = await client.get("/feed") assert response.status_code == 200 body = response.text assert "JSON.stringify(item.payload" not in body assert "JSON.stringify(item" not in body @pytest.mark.anyio async def test_feed_page_has_event_meta_for_all_types( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts which handles all 8 notification event types.""" response = await client.get("/feed") assert response.status_code == 200 body = response.text assert '"page": "feed"' in body @pytest.mark.anyio async def test_feed_page_has_data_notif_id_attribute( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page renders via feed.ts; data-notif-id attached client-side.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text @pytest.mark.anyio async def test_feed_page_has_unread_indicator( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts which highlights unread cards client-side.""" response = await client.get("/feed") assert response.status_code == 200 body = response.text assert '"page": "feed"' in body @pytest.mark.anyio async def test_feed_page_has_actor_avatar_logic( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; actorHsl / actorAvatar helpers live in that module.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text @pytest.mark.anyio async def test_feed_page_has_relative_timestamp( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; fmtRelative called client-side by that module.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text # --------------------------------------------------------------------------- # Mark-as-read UX tests — # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_feed_page_has_mark_one_read_function( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; markOneRead() lives in that module.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text @pytest.mark.anyio async def test_feed_page_has_mark_all_read_function( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; markAllRead() lives in that module.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text @pytest.mark.anyio async def test_feed_page_has_decrement_nav_badge_function( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; decrementNavBadge() lives in that module.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text @pytest.mark.anyio async def test_feed_page_mark_read_btn_targets_notification_endpoint( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; mark-read calls handled client-side by that module.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text @pytest.mark.anyio async def test_feed_page_mark_all_btn_targets_read_all_endpoint( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; read-all endpoint called client-side by that module.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text @pytest.mark.anyio async def test_feed_page_mark_all_btn_present_in_template( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; mark-all-read button rendered client-side.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text @pytest.mark.anyio async def test_feed_page_mark_read_updates_nav_badge( client: AsyncClient, db_session: AsyncSession, ) -> None: """Feed page dispatches feed.ts; nav-notif-badge updated client-side by that module.""" response = await client.get("/feed") assert response.status_code == 200 assert '"page": "feed"' in response.text # --------------------------------------------------------------------------- # Per-dimension analysis detail pages # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Issue #295 — Profile page: followers/following lists with user cards # --------------------------------------------------------------------------- # test_profile_page_has_followers_following_tabs # test_profile_page_has_user_card_js # test_profile_page_has_switch_tab_js # test_followers_list_endpoint_returns_200 # test_followers_list_returns_user_cards_for_known_user # test_following_list_returns_user_cards_for_known_user # test_followers_list_unknown_user_404 # test_following_list_unknown_user_404 # test_followers_response_includes_following_count # test_followers_list_empty_for_user_with_no_followers async def _make_follow( db_session: AsyncSession, follower_id: str, followee_id: str, ) -> MusehubFollow: """Seed a follow relationship and return the ORM row.""" import uuid row = MusehubFollow( follow_id=str(uuid.uuid4()), follower_id=follower_id, followee_id=followee_id, ) db_session.add(row) await db_session.commit() return row @pytest.mark.anyio async def test_profile_page_has_followers_following_tabs( client: AsyncClient, db_session: AsyncSession, ) -> None: """Profile page renders Followers and Following tab buttons.""" await _make_profile(db_session, username="tabuser") response = await client.get("/tabuser") assert response.status_code == 200 body = response.text assert 'data-tab="followers"' in body assert 'data-tab="following"' in body @pytest.mark.anyio async def test_profile_page_has_switch_tab_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """Profile page must include switchTab() to toggle between followers and following.""" await _make_profile(db_session, username="switchtabuser") response = await client.get("/switchtabuser") assert response.status_code == 200 # switchTab moved to app.js TypeScript module; check page dispatch and tab structure assert '"page": "user-profile"' in response.text assert "tab-btn" in response.text @pytest.mark.anyio async def test_followers_list_endpoint_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /api/v1/users/{username}/followers-list returns 200 for known user.""" await _make_profile(db_session, username="followerlistuser") response = await client.get("/api/v1/users/followerlistuser/followers-list") assert response.status_code == 200 assert isinstance(response.json(), list) @pytest.mark.anyio async def test_followers_list_returns_user_cards_for_known_user( client: AsyncClient, db_session: AsyncSession, ) -> None: """followers-list returns UserCard objects when followers exist.""" import uuid target = MusehubProfile( user_id="target-user-fl-01", username="flctarget", bio="I am the target", avatar_url=None, pinned_repo_ids=[], ) follower = MusehubProfile( user_id="follower-user-fl-01", username="flcfollower", bio="I am a follower", avatar_url=None, pinned_repo_ids=[], ) db_session.add(target) db_session.add(follower) await db_session.flush() # Seed a follow row using user_ids (same convention as the seed script) await _make_follow(db_session, follower_id="follower-user-fl-01", followee_id="target-user-fl-01") response = await client.get("/api/v1/users/flctarget/followers-list") assert response.status_code == 200 cards = response.json() assert len(cards) >= 1 usernames = [c["username"] for c in cards] assert "flcfollower" in usernames @pytest.mark.anyio async def test_following_list_returns_user_cards_for_known_user( client: AsyncClient, db_session: AsyncSession, ) -> None: """following-list returns UserCard objects for users that the target follows.""" actor = MusehubProfile( user_id="actor-user-fl-02", username="flcactor", bio="I follow people", avatar_url=None, pinned_repo_ids=[], ) followee = MusehubProfile( user_id="followee-user-fl-02", username="flcfollowee", bio="I am followed", avatar_url=None, pinned_repo_ids=[], ) db_session.add(actor) db_session.add(followee) await db_session.flush() await _make_follow(db_session, follower_id="actor-user-fl-02", followee_id="followee-user-fl-02") response = await client.get("/api/v1/users/flcactor/following-list") assert response.status_code == 200 cards = response.json() assert len(cards) >= 1 usernames = [c["username"] for c in cards] assert "flcfollowee" in usernames @pytest.mark.anyio async def test_followers_list_unknown_user_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """followers-list returns 404 when the target username does not exist.""" response = await client.get("/api/v1/users/nonexistent-ghost-user/followers-list") assert response.status_code == 404 @pytest.mark.anyio async def test_following_list_unknown_user_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """following-list returns 404 when the target username does not exist.""" response = await client.get("/api/v1/users/nonexistent-ghost-user/following-list") assert response.status_code == 404 @pytest.mark.anyio async def test_followers_response_includes_following_count( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /users/{username}/followers now includes following_count in response.""" await _make_profile(db_session, username="followcountuser") response = await client.get("/api/v1/users/followcountuser/followers") assert response.status_code == 200 data = response.json() assert "followerCount" in data or "follower_count" in data assert "followingCount" in data or "following_count" in data @pytest.mark.anyio async def test_followers_list_empty_for_user_with_no_followers( client: AsyncClient, db_session: AsyncSession, ) -> None: """followers-list returns an empty list when no one follows the user.""" await _make_profile(db_session, username="lonelyuser295") response = await client.get("/api/v1/users/lonelyuser295/followers-list") assert response.status_code == 200 assert response.json() == [] # --------------------------------------------------------------------------- # Issue #450 — Enhanced commit detail: inline audio player, muse_tags panel, # reactions, comment thread, cross-references # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_commit_page_has_inline_audio_player_section( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page (SSR, issue #583) renders WaveSurfer shell when snapshot_id is set. Post-SSR migration: the audio player shell (commit-waveform + WaveSurfer script) is rendered only when the commit has a snapshot_id. Non-existent commits → 404. """ from datetime import datetime, timezone from musehub.db.musehub_models import MusehubCommit repo = MusehubRepo( name="audio-player-test", owner="audiouser", slug="audio-player-test", visibility="public", owner_user_id="audio-uid", ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) snap_id = "sha256:deadbeefcafe" commit_id = "c0ffee0000111122223333444455556666c0ffee" commit = MusehubCommit( commit_id=commit_id, repo_id=str(repo.repo_id), branch="main", parent_ids=[], message="Add audio snapshot", author="audiouser", timestamp=datetime.now(tz=timezone.utc), snapshot_id=snap_id, ) db_session.add(commit) await db_session.commit() response = await client.get(f"/audiouser/audio-player-test/commits/{commit_id}") assert response.status_code == 200 body = response.text # SSR commit page renders for any repo type assert "audio-player-test" in body assert "Add audio snapshot" in body assert "audioUser" in body.lower() or "audiouser" in body.lower() @pytest.mark.anyio async def test_commit_page_inline_player_has_track_selector_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page (SSR, issue #583) returns 404 for non-existent commits. Track selector JS was part of the pre-SSR commit.html. The new commit_detail.html renders a simplified WaveSurfer shell from the commit's snapshot_id. Non-existent commits return 404 rather than an empty JS shell. """ repo = MusehubRepo( name="track-sel-test", owner="trackuser", slug="track-sel-test", visibility="public", owner_user_id="track-uid", ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) commit_id = "aaaa1111bbbb2222cccc3333dddd4444eeee5555" response = await client.get(f"/trackuser/track-sel-test/commits/{commit_id}") assert response.status_code == 404 @pytest.mark.anyio async def test_commit_page_has_muse_tags_panel( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page (SSR, issue #583) returns 404 for non-existent commits. The muse-tags-panel was a JS-only construct in the pre-SSR commit.html. The new commit_detail.html renders metadata server-side; the muse-tags panel is not present. Non-existent commits return 404. """ repo = MusehubRepo( name="tags-panel-test", owner="tagsuser", slug="tags-panel-test", visibility="public", owner_user_id="tags-uid", ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) commit_id = "1234567890abcdef1234567890abcdef12345678" response = await client.get(f"/tagsuser/tags-panel-test/commits/{commit_id}") assert response.status_code == 404 @pytest.mark.anyio async def test_commit_page_muse_tags_pill_colours_defined( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page (SSR, issue #583) returns 404 for non-existent commits. Muse-pill CSS classes were part of the pre-SSR commit.html analysis panel. The new commit_detail.html does not include muse-pill classes. Non-existent commits return 404. """ repo = MusehubRepo( name="pill-colour-test", owner="pilluser", slug="pill-colour-test", visibility="public", owner_user_id="pill-uid", ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) commit_id = "abcd1234ef567890abcd1234ef567890abcd1234" response = await client.get(f"/pilluser/pill-colour-test/commits/{commit_id}") assert response.status_code == 404 @pytest.mark.anyio async def test_commit_page_has_cross_references_section( client: AsyncClient, db_session: AsyncSession, ) -> None: """Commit detail page (SSR, issue #583) returns 404 for non-existent commits. The cross-references panel (xrefs-body, loadCrossReferences) was a JS-only construct in the pre-SSR commit.html. The new commit_detail.html does not include this panel. Non-existent commits return 404. """ repo = MusehubRepo( name="xrefs-test", owner="xrefsuser", slug="xrefs-test", visibility="public", owner_user_id="xrefs-uid", ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) commit_id = "face000011112222333344445555666677778888" response = await client.get(f"/xrefsuser/xrefs-test/commits/{commit_id}") assert response.status_code == 404 @pytest.mark.anyio async def test_commit_page_context_passes_listen_and_embed_urls( client: AsyncClient, db_session: AsyncSession, ) -> None: """commit_page() (SSR, issue #583) injects listenUrl and embedUrl into the JS page-data block. The SSR template still exposes these URLs server-side for the JS and for navigation links. Requires the commit to exist in the DB. """ from datetime import datetime, timezone from musehub.db.musehub_models import MusehubCommit repo = MusehubRepo( name="url-context-test", owner="urluser", slug="url-context-test", visibility="public", owner_user_id="url-uid", ) db_session.add(repo) await db_session.commit() await db_session.refresh(repo) commit_id = "dead0000beef1111dead0000beef1111dead0000" commit = MusehubCommit( commit_id=commit_id, repo_id=str(repo.repo_id), branch="main", parent_ids=[], message="URL context test commit", author="urluser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get(f"/urluser/url-context-test/commits/{commit_id}") assert response.status_code == 200 body = response.text assert "audioUrl" in body assert "viewerType" in body # /view/ link only appears for piano_roll domain repos; diff link is always present assert f"/commits/{commit_id}/diff" in body # --------------------------------------------------------------------------- # Issue #442 — Repo landing page enrichment panels # Explore page — filter sidebar + inline audio preview # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_repo_home_contributors_panel_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """Repo home page links to the credits page (SSR — no client-side contributor panel JS).""" repo = MusehubRepo( name="contrib-panel-test", owner="contribowner", slug="contrib-panel-test", visibility="public", owner_user_id="contrib-uid", ) db_session.add(repo) await db_session.commit() response = await client.get("/contribowner/contrib-panel-test") assert response.status_code == 200 body = response.text assert "MuseHub" in body assert "contribowner" in body assert "contrib-panel-test" in body @pytest.mark.anyio async def test_repo_home_activity_heatmap_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """Repo home page renders SSR repo metadata (no client-side heatmap JS).""" repo = MusehubRepo( name="heatmap-panel-test", owner="heatmapowner", slug="heatmap-panel-test", visibility="public", owner_user_id="heatmap-uid", ) db_session.add(repo) await db_session.commit() response = await client.get("/heatmapowner/heatmap-panel-test") assert response.status_code == 200 body = response.text assert "MuseHub" in body assert "heatmapowner" in body assert "heatmap-panel-test" in body @pytest.mark.anyio async def test_repo_home_instrument_bar_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """Repo home page renders SSR repo metadata (no client-side instrument-bar JS).""" repo = MusehubRepo( name="instrbar-panel-test", owner="instrbarowner", slug="instrbar-panel-test", visibility="public", owner_user_id="instrbar-uid", ) db_session.add(repo) await db_session.commit() response = await client.get("/instrbarowner/instrbar-panel-test") assert response.status_code == 200 body = response.text assert "MuseHub" in body assert "instrbarowner" in body assert "instrbar-panel-test" in body @pytest.mark.anyio async def test_repo_home_clone_widget_renders( client: AsyncClient, db_session: AsyncSession, ) -> None: """Repo home page renders clone URLs server-side into read-only inputs.""" repo = MusehubRepo( name="clone-widget-test", owner="cloneowner", slug="clone-widget-test", visibility="public", owner_user_id="clone-uid", ) db_session.add(repo) await db_session.commit() response = await client.get("/cloneowner/clone-widget-test") assert response.status_code == 200 body = response.text # Clone URLs injected server-side by repo_page() assert "musehub://cloneowner/clone-widget-test" in body assert "https://musehub.ai/cloneowner/clone-widget-test" in body # SSH is not supported — must not appear assert "git@" not in body # SSR clone widget DOM elements assert "clone-input" in body async def test_explore_page_returns_200( client: AsyncClient, ) -> None: """GET /explore returns 200 without authentication.""" response = await client.get("/explore") assert response.status_code == 200 @pytest.mark.anyio async def test_explore_page_has_filter_sidebar( client: AsyncClient, ) -> None: """Explore page renders a filter sidebar with sort, license, and clear-filters sections.""" response = await client.get("/explore") assert response.status_code == 200 body = response.text assert "ex-sidebar" in body assert "Clear filters" in body assert "Sort by" in body assert "License" in body @pytest.mark.anyio async def test_explore_page_has_sort_options( client: AsyncClient, ) -> None: """Explore page sidebar includes all four sort radio options.""" response = await client.get("/explore") assert response.status_code == 200 body = response.text assert "Most starred" in body assert "Recently updated" in body assert "Most forked" in body assert "Trending" in body @pytest.mark.anyio async def test_explore_page_has_license_options( client: AsyncClient, ) -> None: """Explore page sidebar includes the expected license filter options.""" response = await client.get("/explore") assert response.status_code == 200 body = response.text assert "CC0" in body assert "CC BY" in body assert "CC BY-SA" in body assert "CC BY-NC" in body assert "All Rights Reserved" in body @pytest.mark.anyio async def test_explore_page_has_repo_grid( client: AsyncClient, ) -> None: """Explore page includes the repo grid and JS discover API loader.""" response = await client.get("/explore") assert response.status_code == 200 body = response.text assert "repo-grid" in body assert "filter-form" in body @pytest.mark.anyio async def test_explore_page_has_audio_preview_js( client: AsyncClient, ) -> None: """Explore page renders the filter sidebar and repo grid (SSR, no inline audio-preview JS).""" response = await client.get("/explore") assert response.status_code == 200 body = response.text assert "filter-form" in body assert "ex-browse" in body assert "repo-grid" in body @pytest.mark.anyio async def test_explore_page_default_sort_stars( client: AsyncClient, ) -> None: """Explore page defaults to 'stars' sort when no sort param given.""" response = await client.get("/explore") assert response.status_code == 200 body = response.text # 'stars' radio should be pre-checked (default sort) assert 'value="stars"' in body assert 'checked' in body @pytest.mark.anyio async def test_explore_page_sort_param_honoured( client: AsyncClient, ) -> None: """Explore page honours the ?sort= query param for pre-selecting a sort option.""" response = await client.get("/explore?sort=updated") assert response.status_code == 200 body = response.text assert 'value="updated"' in body @pytest.mark.anyio async def test_explore_page_no_auth_required( client: AsyncClient, ) -> None: """Explore page is publicly accessible — no JWT required (zero-friction discovery).""" response = await client.get("/explore") assert response.status_code == 200 assert response.status_code != 401 assert response.status_code != 403 @pytest.mark.anyio async def test_explore_page_chip_toggle_js( client: AsyncClient, ) -> None: """Explore page dispatches explore.ts module (toggleChip is now in explore.ts).""" response = await client.get("/explore") assert response.status_code == 200 body = response.text assert '"page": "explore"' in body # filter-form is always present; data-filter chips appear when repos with tags exist assert "filter-form" in body @pytest.mark.anyio async def test_explore_page_get_params_preserved( client: AsyncClient, ) -> None: """Explore page accepts lang, license, topic, sort GET params without error.""" response = await client.get( "/explore?lang=piano&license=CC0&topic=jazz&sort=stars" ) assert response.status_code == 200