test_musehub_ui_canvas_ssr.py
python
| 1 | """Tests for SSR scaffolding on the piano roll and score pages (issue #581). |
| 2 | |
| 3 | Verifies that the piano roll and score page handlers populate server-side |
| 4 | context — track name, instrument sidebar, transport bar, canvas data |
| 5 | attributes, and score metadata — without requiring JavaScript execution. |
| 6 | |
| 7 | Covers: |
| 8 | - test_piano_roll_page_renders_track_name_server_side |
| 9 | - test_piano_roll_page_renders_instrument_sidebar |
| 10 | - test_piano_roll_page_canvas_has_data_midi_url |
| 11 | - test_piano_roll_page_transport_bar_present |
| 12 | - test_piano_roll_track_page_canvas_has_data_instruments |
| 13 | - test_score_page_renders_title_server_side |
| 14 | - test_score_page_score_container_has_data_abc_url |
| 15 | - test_score_page_no_blank_shell |
| 16 | """ |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import pytest |
| 20 | from httpx import AsyncClient |
| 21 | from sqlalchemy.ext.asyncio import AsyncSession |
| 22 | |
| 23 | from musehub.db.musehub_models import MusehubObject, MusehubRepo |
| 24 | |
| 25 | |
| 26 | # --------------------------------------------------------------------------- |
| 27 | # Helpers |
| 28 | # --------------------------------------------------------------------------- |
| 29 | |
| 30 | |
| 31 | async def _make_repo(db_session: AsyncSession) -> str: |
| 32 | """Seed a minimal repo and return its repo_id.""" |
| 33 | repo = MusehubRepo( |
| 34 | name="canvas-test-beats", |
| 35 | owner="canvasuser", |
| 36 | slug="canvas-test-beats", |
| 37 | visibility="private", |
| 38 | owner_user_id="canvas-owner", |
| 39 | ) |
| 40 | db_session.add(repo) |
| 41 | await db_session.commit() |
| 42 | await db_session.refresh(repo) |
| 43 | return str(repo.repo_id) |
| 44 | |
| 45 | |
| 46 | async def _seed_midi_object( |
| 47 | db_session: AsyncSession, |
| 48 | repo_id: str, |
| 49 | path: str = "tracks/bass.mid", |
| 50 | size_bytes: int = 4096, |
| 51 | ) -> MusehubObject: |
| 52 | """Seed a MIDI object into the repo and return it.""" |
| 53 | obj = MusehubObject( |
| 54 | object_id=f"sha256:{'a' * 64}_{path.replace('/', '_')}", |
| 55 | repo_id=repo_id, |
| 56 | path=path, |
| 57 | size_bytes=size_bytes, |
| 58 | disk_path=f"/data/{path}", |
| 59 | ) |
| 60 | db_session.add(obj) |
| 61 | await db_session.commit() |
| 62 | await db_session.refresh(obj) |
| 63 | return obj |
| 64 | |
| 65 | |
| 66 | # --------------------------------------------------------------------------- |
| 67 | # Piano roll SSR tests |
| 68 | # --------------------------------------------------------------------------- |
| 69 | |
| 70 | |
| 71 | @pytest.mark.anyio |
| 72 | async def test_piano_roll_page_renders_track_name_server_side( |
| 73 | client: AsyncClient, |
| 74 | db_session: AsyncSession, |
| 75 | ) -> None: |
| 76 | """Piano roll track page renders the track name from the path in SSR HTML.""" |
| 77 | repo_id = await _make_repo(db_session) |
| 78 | await _seed_midi_object(db_session, repo_id, path="tracks/bass.mid") |
| 79 | response = await client.get( |
| 80 | "/canvasuser/canvas-test-beats/view/main/tracks/bass.mid" |
| 81 | ) |
| 82 | assert response.status_code == 200 |
| 83 | assert "text/html" in response.headers["content-type"] |
| 84 | body = response.text |
| 85 | # Track name derived from path stem ("bass" → "Bass") |
| 86 | assert "Bass" in body or "bass.mid" in body or "bass" in body.lower() |
| 87 | |
| 88 | |
| 89 | @pytest.mark.anyio |
| 90 | async def test_piano_roll_page_renders_instrument_sidebar( |
| 91 | client: AsyncClient, |
| 92 | db_session: AsyncSession, |
| 93 | ) -> None: |
| 94 | """View page renders successfully for a repo with MIDI objects.""" |
| 95 | repo_id = await _make_repo(db_session) |
| 96 | await _seed_midi_object(db_session, repo_id, path="tracks/keys.mid") |
| 97 | response = await client.get( |
| 98 | "/canvasuser/canvas-test-beats/view/main" |
| 99 | ) |
| 100 | assert response.status_code == 200 |
| 101 | body = response.text |
| 102 | # Domain viewer renders SSR HTML |
| 103 | assert "view-container" in body |
| 104 | |
| 105 | |
| 106 | @pytest.mark.anyio |
| 107 | async def test_piano_roll_page_canvas_has_data_midi_url( |
| 108 | client: AsyncClient, |
| 109 | db_session: AsyncSession, |
| 110 | ) -> None: |
| 111 | """View page embeds the viewerType in its page config JSON.""" |
| 112 | await _make_repo(db_session) |
| 113 | response = await client.get( |
| 114 | "/canvasuser/canvas-test-beats/view/main" |
| 115 | ) |
| 116 | assert response.status_code == 200 |
| 117 | assert "viewerType" in response.text |
| 118 | |
| 119 | |
| 120 | @pytest.mark.anyio |
| 121 | async def test_piano_roll_page_transport_bar_present( |
| 122 | client: AsyncClient, |
| 123 | db_session: AsyncSession, |
| 124 | ) -> None: |
| 125 | """View page renders the domain viewer container for any ref.""" |
| 126 | await _make_repo(db_session) |
| 127 | response = await client.get( |
| 128 | "/canvasuser/canvas-test-beats/view/main" |
| 129 | ) |
| 130 | assert response.status_code == 200 |
| 131 | assert "view-container" in response.text |
| 132 | |
| 133 | |
| 134 | @pytest.mark.anyio |
| 135 | async def test_piano_roll_track_page_canvas_has_data_instruments( |
| 136 | client: AsyncClient, |
| 137 | db_session: AsyncSession, |
| 138 | ) -> None: |
| 139 | """File view page embeds the file path in the page config JSON.""" |
| 140 | repo_id = await _make_repo(db_session) |
| 141 | await _seed_midi_object(db_session, repo_id, path="tracks/guitar.mid") |
| 142 | response = await client.get( |
| 143 | "/canvasuser/canvas-test-beats/view/main/tracks/guitar.mid" |
| 144 | ) |
| 145 | assert response.status_code == 200 |
| 146 | body = response.text |
| 147 | assert "guitar.mid" in body |
| 148 | |
| 149 | |
| 150 | # --------------------------------------------------------------------------- |
| 151 | # Score page SSR tests |
| 152 | # --------------------------------------------------------------------------- |
| 153 | |
| 154 | |
| 155 | @pytest.mark.anyio |
| 156 | async def test_score_page_renders_title_server_side( |
| 157 | client: AsyncClient, |
| 158 | db_session: AsyncSession, |
| 159 | ) -> None: |
| 160 | """Score part page renders a title derived from the path in SSR HTML.""" |
| 161 | repo_id = await _make_repo(db_session) |
| 162 | await _seed_midi_object(db_session, repo_id, path="tracks/melody.mid") |
| 163 | response = await client.get( |
| 164 | "/canvasuser/canvas-test-beats/score/main/tracks/melody.mid" |
| 165 | ) |
| 166 | assert response.status_code == 200 |
| 167 | body = response.text |
| 168 | # Title derived from path stem ("melody" → "Melody") |
| 169 | assert "Melody" in body or "melody" in body.lower() |
| 170 | |
| 171 | |
| 172 | @pytest.mark.anyio |
| 173 | async def test_score_page_score_container_has_data_abc_url( |
| 174 | client: AsyncClient, |
| 175 | db_session: AsyncSession, |
| 176 | ) -> None: |
| 177 | """Score page includes a #score-container with a data-abc-url for JS.""" |
| 178 | await _make_repo(db_session) |
| 179 | response = await client.get( |
| 180 | "/canvasuser/canvas-test-beats/score/main" |
| 181 | ) |
| 182 | assert response.status_code == 200 |
| 183 | body = response.text |
| 184 | assert "score-container" in body |
| 185 | assert "data-abc-url" in body |
| 186 | |
| 187 | |
| 188 | @pytest.mark.anyio |
| 189 | async def test_score_page_no_blank_shell( |
| 190 | client: AsyncClient, |
| 191 | db_session: AsyncSession, |
| 192 | ) -> None: |
| 193 | """Score page renders meaningful content without JS execution. |
| 194 | |
| 195 | The page must include the metadata header and score container in SSR, |
| 196 | not just a blank shell that depends entirely on a client fetch. |
| 197 | """ |
| 198 | repo_id = await _make_repo(db_session) |
| 199 | await _seed_midi_object(db_session, repo_id, path="tracks/piano.mid") |
| 200 | response = await client.get( |
| 201 | "/canvasuser/canvas-test-beats/score/main" |
| 202 | ) |
| 203 | assert response.status_code == 200 |
| 204 | body = response.text |
| 205 | # Score header is present — not a blank loading spinner as the only content |
| 206 | assert "score-container" in body or "score-meta" in body or "Score" in body |
| 207 | # The entire body is not just a loading placeholder |
| 208 | assert body.count("Loading") < 5 |