gabriel / musehub public

test_musehub_ui_blob_deep_links.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
1 """Tests for blob deep-link infrastructure: symbol→lineno enrichment and #S: fragments.
2
3 Covers:
4 _enrich_with_linenos (unit)
5 - test_enrich_adds_lineno_for_known_function
6 - test_enrich_adds_lineno_for_class
7 - test_enrich_skips_non_python_files
8 - test_enrich_does_not_overwrite_existing_lineno
9 - test_enrich_tolerates_syntax_error
10 - test_enrich_ignores_symbol_not_in_ast
11
12 _symbol_line_map (unit)
13 - test_symbol_line_map_returns_display_name_to_lineno
14 - test_symbol_line_map_excludes_symbols_without_lineno
15 - test_symbol_line_map_excludes_falsy_lineno
16
17 Issue detail SSR — #S: deep links
18 - test_issue_detail_symbol_anchor_link_contains_hash_fragment
19 - test_issue_detail_symbol_anchor_plain_file_no_fragment
20 - test_issue_detail_multiple_symbol_anchors_all_linked
21
22 Blob page SSR — symbolLines in page_json
23 - test_blob_page_json_contains_symbol_lines_key
24 """
25 from __future__ import annotations
26
27 import pytest
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from datetime import datetime, timezone
32
33 from musehub.api.routes.musehub.ui_blob import _enrich_with_linenos, _symbol_line_map
34 from muse.core.types import now_utc_iso
35 from musehub.core.genesis import compute_identity_id, compute_issue_id, compute_repo_id
36 from musehub.db.musehub_repo_models import MusehubRepo
37 from musehub.db.musehub_social_models import MusehubIssue
38 from musehub.types.json_types import JSONObject, StrDict
39
40
41 # ---------------------------------------------------------------------------
42 # Helpers
43 # ---------------------------------------------------------------------------
44
45 _PY_SRC = """\
46 class MyService:
47 def run(self) -> None:
48 pass
49
50 async def compute(x: int) -> int:
51 return x * 2
52 """
53
54
55 async def _make_repo(db: AsyncSession, owner: str = "blober", slug: str = "blobby") -> str:
56 owner_id = compute_identity_id(owner.encode())
57 created_at = datetime.now(tz=timezone.utc)
58 repo = MusehubRepo(
59 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
60 name=slug,
61 owner=owner,
62 slug=slug,
63 visibility="public",
64 owner_user_id=owner_id,
65 created_at=created_at,
66 updated_at=created_at,
67 )
68 db.add(repo)
69 await db.commit()
70 await db.refresh(repo)
71 return str(repo.repo_id)
72
73
74 async def _make_issue(
75 db: AsyncSession,
76 repo_id: str,
77 *,
78 number: int = 1,
79 title: str = "Deep link test issue",
80 symbol_anchors: list[str] | None = None,
81 ) -> MusehubIssue:
82 author_id = compute_identity_id(b"blober")
83 issue = MusehubIssue(
84 issue_id=compute_issue_id(repo_id, author_id, now_utc_iso()),
85 repo_id=repo_id,
86 number=number,
87 title=title,
88 body="",
89 state="open",
90 labels=[],
91 author="blober",
92 symbol_anchors=symbol_anchors or [],
93 )
94 db.add(issue)
95 await db.commit()
96 await db.refresh(issue)
97 return issue
98
99
100 # ---------------------------------------------------------------------------
101 # Unit: _enrich_with_linenos
102 # ---------------------------------------------------------------------------
103
104
105 def test_enrich_adds_lineno_for_known_function() -> None:
106 syms: list[JSONObject] = [{"display_name": "compute"}]
107 _enrich_with_linenos(syms, "service.py", _PY_SRC)
108 assert syms[0]["lineno"] == 5
109
110
111 def test_enrich_adds_lineno_for_class() -> None:
112 syms: list[JSONObject] = [{"display_name": "MyService"}]
113 _enrich_with_linenos(syms, "service.py", _PY_SRC)
114 assert syms[0]["lineno"] == 1
115
116
117 def test_enrich_skips_non_python_files() -> None:
118 syms: list[JSONObject] = [{"display_name": "compute"}]
119 _enrich_with_linenos(syms, "service.ts", _PY_SRC)
120 assert "lineno" not in syms[0]
121
122
123 def test_enrich_does_not_overwrite_existing_lineno() -> None:
124 syms: list[JSONObject] = [{"display_name": "compute", "lineno": 99}]
125 _enrich_with_linenos(syms, "service.py", _PY_SRC)
126 assert syms[0]["lineno"] == 99
127
128
129 def test_enrich_tolerates_syntax_error() -> None:
130 syms: list[JSONObject] = [{"display_name": "compute"}]
131 _enrich_with_linenos(syms, "bad.py", "def compute(: pass")
132 assert "lineno" not in syms[0]
133
134
135 def test_enrich_ignores_symbol_not_in_ast() -> None:
136 syms: list[JSONObject] = [{"display_name": "ghost_fn"}]
137 _enrich_with_linenos(syms, "service.py", _PY_SRC)
138 assert "lineno" not in syms[0]
139
140
141 # ---------------------------------------------------------------------------
142 # Unit: _symbol_line_map
143 # ---------------------------------------------------------------------------
144
145
146 def test_symbol_line_map_returns_display_name_to_lineno() -> None:
147 syms: list[JSONObject] = [
148 {"display_name": "foo", "lineno": 3, "end_lineno": 8},
149 {"display_name": "bar", "lineno": 10, "end_lineno": 20},
150 ]
151 result = _symbol_line_map(syms)
152 assert result == {"foo": [3, 8], "bar": [10, 20]}
153
154
155 def test_symbol_line_map_excludes_symbols_without_lineno() -> None:
156 syms: list[JSONObject] = [
157 {"display_name": "foo", "lineno": 3, "end_lineno": 5},
158 {"display_name": "no_line"},
159 ]
160 result = _symbol_line_map(syms)
161 assert "no_line" not in result
162 assert result["foo"] == [3, 5]
163
164
165 def test_symbol_line_map_excludes_falsy_lineno() -> None:
166 syms: list[JSONObject] = [{"display_name": "zero", "lineno": 0}]
167 result = _symbol_line_map(syms)
168 assert "zero" not in result
169
170
171 # ---------------------------------------------------------------------------
172 # Issue detail SSR — #S: deep links
173 # ---------------------------------------------------------------------------
174
175
176 async def test_issue_detail_symbol_anchor_link_contains_hash_fragment(
177 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
178 ) -> None:
179 repo_id = await _make_repo(db_session)
180 await _make_issue(db_session, repo_id, symbol_anchors=["musehub/services/foo.py::compute"])
181 r = await client.get("/blober/blobby/issues/1")
182 assert r.status_code == 200
183 assert "#S:compute" in r.text
184
185
186 async def test_issue_detail_symbol_anchor_plain_file_no_fragment(
187 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
188 ) -> None:
189 repo_id = await _make_repo(db_session, slug="blobby2")
190 await _make_issue(db_session, repo_id, number=1, symbol_anchors=["musehub/services/bar.py"])
191 r = await client.get("/blober/blobby2/issues/1")
192 assert r.status_code == 200
193 # plain file anchor — no #S: fragment, just the blob URL
194 assert "blob/main/musehub/services/bar.py" in r.text
195 assert "#S:" not in r.text
196
197
198 async def test_issue_detail_multiple_symbol_anchors_all_linked(
199 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
200 ) -> None:
201 repo_id = await _make_repo(db_session, slug="blobby3")
202 await _make_issue(
203 db_session,
204 repo_id,
205 symbol_anchors=[
206 "musehub/services/a.py::Alpha",
207 "musehub/services/b.py::Beta",
208 ],
209 )
210 r = await client.get("/blober/blobby3/issues/1")
211 assert r.status_code == 200
212 assert "#S:Alpha" in r.text
213 assert "#S:Beta" in r.text
214
215
216 # ---------------------------------------------------------------------------
217 # Cross-repo symbol anchors — SSR rendering (issue #38 motivating case)
218 # ---------------------------------------------------------------------------
219
220
221 async def test_cross_repo_anchor_url_points_to_other_repo(
222 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
223 ) -> None:
224 """A cross-repo anchor 'gabriel/muse::path/file.py::Symbol' must generate
225 a blob URL for gabriel/muse, not the issue's own repo (blober/blobby5)."""
226 repo_id = await _make_repo(db_session, slug="blobby5")
227 await _make_issue(
228 db_session,
229 repo_id,
230 symbol_anchors=["gabriel/muse::muse/cli/commands/bridge.py::GitExporter.fix_file_modes"],
231 )
232 r = await client.get("/blober/blobby5/issues/1")
233 assert r.status_code == 200
234 # Must link to gabriel/muse, not blober/blobby5.
235 assert "/gabriel/muse/blob/main/muse/cli/commands/bridge.py#S:GitExporter.fix_file_modes" in r.text
236 assert "/blober/blobby5/blob/" not in r.text
237
238
239 async def test_cross_repo_anchor_with_ref_in_url(
240 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
241 ) -> None:
242 """@ref suffix must appear in the blob URL path."""
243 repo_id = await _make_repo(db_session, slug="blobby6")
244 await _make_issue(
245 db_session,
246 repo_id,
247 symbol_anchors=["gabriel/muse::muse/cli/commands/bridge.py::GitExporter._has_shebang@dev"],
248 )
249 r = await client.get("/blober/blobby6/issues/1")
250 assert r.status_code == 200
251 assert "/gabriel/muse/blob/dev/muse/cli/commands/bridge.py#S:GitExporter._has_shebang" in r.text
252
253
254 async def test_same_repo_anchor_still_resolves_to_own_repo(
255 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
256 ) -> None:
257 """Existing same-repo anchor behaviour must be unchanged."""
258 repo_id = await _make_repo(db_session, slug="blobby7")
259 await _make_issue(
260 db_session,
261 repo_id,
262 symbol_anchors=["musehub/services/billing.py::compute_total"],
263 )
264 r = await client.get("/blober/blobby7/issues/1")
265 assert r.status_code == 200
266 assert "/blober/blobby7/blob/main/musehub/services/billing.py#S:compute_total" in r.text
267
268
269 async def test_cross_repo_anchor_shows_repo_prefix_in_label(
270 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
271 ) -> None:
272 """Cross-repo anchors must display 'owner/repo::' before the file path."""
273 repo_id = await _make_repo(db_session, slug="blobby8")
274 await _make_issue(
275 db_session,
276 repo_id,
277 symbol_anchors=["gabriel/muse::muse/cli/commands/bridge.py::GitExporter.fix_file_modes"],
278 )
279 r = await client.get("/blober/blobby8/issues/1")
280 assert r.status_code == 200
281 assert "gabriel/muse" in r.text
282 assert "isd-anchor--cross-repo" in r.text
283
284
285 async def test_same_repo_anchor_with_ref_in_url(
286 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
287 ) -> None:
288 """Same-repo anchor with @ref uses the ref in the blob URL."""
289 repo_id = await _make_repo(db_session, slug="blobby9")
290 await _make_issue(
291 db_session,
292 repo_id,
293 symbol_anchors=["src/main.py::build@feat/my-branch"],
294 )
295 r = await client.get("/blober/blobby9/issues/1")
296 assert r.status_code == 200
297 assert "/blober/blobby9/blob/feat/my-branch/src/main.py#S:build" in r.text
298
299
300 # ---------------------------------------------------------------------------
301 # Blob page SSR — symbolLines key present in page_json
302 # ---------------------------------------------------------------------------
303
304
305 async def test_blob_page_json_contains_symbol_lines_key(
306 client: AsyncClient, db_session: AsyncSession
307 ) -> None:
308 """Even when no file is found the blob template must emit symbolLines in page_json."""
309 await _make_repo(db_session, owner="blober", slug="blobby4")
310 r = await client.get("/blober/blobby4/blob/main/some/file.py")
311 # May 200 or 404 depending on whether the file exists, but the template always renders.
312 # We only care that the response contains "symbolLines" in the page_json script block.
313 assert r.status_code in (200, 404)
314 assert '"symbolLines"' in r.text