gabriel / musehub public
test_symbols_v2_p2_route_join.py python
329 lines 11.7 KB
Raw
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago
1 """TDD spec — Phase 2: enrich symbol list rows with musehub_symbol_vitals JOIN.
2
3 Problem
4 ───────
5 The symbol list route builds each row from MusehubSymbolIntel alone.
6 It has no coupling_count, no first_introduced, and its change_count
7 comes from the volatile intel.churn field.
8
9 Solution
10 ────────
11 Extract the data-fetching into ``_fetch_symbol_list(db, repo_id, ...)``
12 and LEFT JOIN musehub_symbol_vitals ON (repo_id, address). Every row
13 now carries pre-computed vitals fields:
14
15 coupling_count — from vitals (0 when no coupling rows exist)
16 first_introduced — ISO string or None when not yet indexed
17 change_count — from vitals (authoritative, replaces intel.churn)
18
19 The JOIN is additive: symbols without a vitals row still appear.
20
21 Tier breakdown
22 ──────────────
23 R201 coupling_count per row from vitals
24 R202 coupling_count is 0 for symbols with no coupling partners
25 R203 first_introduced per row (ISO string or None)
26 R204 change_count comes from vitals, not intel.churn
27 R205 Symbols without a vitals row still appear (LEFT JOIN)
28 R206 Pagination cursor still works correctly after JOIN
29 R207 Kind filter (op=) still works after JOIN
30 R208 Search filter (q=) still works after JOIN
31 """
32 from __future__ import annotations
33
34 import datetime as _dt
35 import secrets
36 from datetime import timezone
37
38 import pytest
39 from sqlalchemy.ext.asyncio import AsyncSession
40
41 from musehub.db.musehub_intel_models import MusehubSymbolIntel, MusehubSymbolVitals
42 from muse.core.types import blob_id, long_id
43 from tests.factories import create_repo
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50 def _now() -> _dt.datetime:
51 return _dt.datetime.now(tz=timezone.utc)
52
53
54 def _cid() -> str:
55 return blob_id(secrets.token_bytes(32))
56
57
58 def _lid() -> str:
59 return long_id(secrets.token_hex(32))
60
61
62 async def _seed_symbol(
63 session: AsyncSession,
64 repo_id: str,
65 address: str,
66 *,
67 churn: int = 5,
68 op: str = "insert",
69 coupling_count: int = 0,
70 first_introduced: _dt.datetime | None = None,
71 change_count: int | None = None,
72 symbol_kind: str | None = None,
73 ) -> None:
74 """Insert a MusehubSymbolIntel row + MusehubSymbolVitals row."""
75 intel = MusehubSymbolIntel(
76 repo_id=repo_id,
77 address=address,
78 churn=churn,
79 churn_30d=churn,
80 churn_90d=churn,
81 blast=0,
82 blast_direct=0,
83 blast_cross=0,
84 blast_top=[],
85 last_changed=_now(),
86 author_count=1,
87 gravity=0.0,
88 weekly=[],
89 last_commit_id=_lid(),
90 op=op,
91 symbol_kind=symbol_kind,
92 )
93 session.add(intel)
94
95 vitals = MusehubSymbolVitals(
96 repo_id=repo_id,
97 address=address,
98 first_introduced=first_introduced or _now(),
99 change_count=change_count if change_count is not None else churn,
100 version_count=1,
101 op_add=1 if op == "insert" else 0,
102 op_modify=1 if op != "insert" else 0,
103 op_delete=0,
104 op_move=0,
105 coupling_count=coupling_count,
106 )
107 session.add(vitals)
108 await session.flush()
109
110
111 async def _seed_intel_only(
112 session: AsyncSession,
113 repo_id: str,
114 address: str,
115 *,
116 churn: int = 2,
117 op: str = "insert",
118 ) -> None:
119 """Insert a MusehubSymbolIntel row with NO vitals row (LEFT JOIN test)."""
120 intel = MusehubSymbolIntel(
121 repo_id=repo_id,
122 address=address,
123 churn=churn,
124 churn_30d=churn,
125 churn_90d=churn,
126 blast=0,
127 blast_direct=0,
128 blast_cross=0,
129 blast_top=[],
130 last_changed=_now(),
131 author_count=1,
132 gravity=0.0,
133 weekly=[],
134 last_commit_id=_lid(),
135 op=op,
136 )
137 session.add(intel)
138 await session.flush()
139
140
141 # ---------------------------------------------------------------------------
142 # R201 — _fetch_symbol_list returns coupling_count per row
143 # ---------------------------------------------------------------------------
144
145 @pytest.mark.asyncio
146 async def test_r201_fetch_returns_coupling_count(db_session: AsyncSession) -> None:
147 """Each row from _fetch_symbol_list must include coupling_count from vitals."""
148 from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list
149
150 repo = await create_repo(db_session, owner="gabriel")
151 await _seed_symbol(db_session, repo.repo_id, "src/a.py::fn_one", coupling_count=7)
152 await db_session.flush()
153
154 symbols, total, next_cursor = await _fetch_symbol_list(
155 db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50
156 )
157
158 sym = next((s for s in symbols if s["address"] == "src/a.py::fn_one"), None)
159 assert sym is not None
160 assert sym["coupling_count"] == 7
161
162
163 # ---------------------------------------------------------------------------
164 # R202 — coupling_count is 0 for symbols with no coupling partners
165 # ---------------------------------------------------------------------------
166
167 @pytest.mark.asyncio
168 async def test_r202_coupling_count_zero_when_no_partners(db_session: AsyncSession) -> None:
169 """Symbols with coupling_count=0 in vitals must return 0, not None."""
170 from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list
171
172 repo = await create_repo(db_session, owner="gabriel")
173 await _seed_symbol(db_session, repo.repo_id, "src/lonely.py::solo", coupling_count=0)
174 await db_session.flush()
175
176 symbols, _, _ = await _fetch_symbol_list(
177 db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50
178 )
179 sym = next((s for s in symbols if s["address"] == "src/lonely.py::solo"), None)
180 assert sym is not None
181 assert sym["coupling_count"] == 0
182
183
184 # ---------------------------------------------------------------------------
185 # R203 — first_introduced per row (ISO string or None)
186 # ---------------------------------------------------------------------------
187
188 @pytest.mark.asyncio
189 async def test_r203_fetch_returns_first_introduced(db_session: AsyncSession) -> None:
190 """first_introduced must be present on each row as an ISO string."""
191 from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list
192
193 introduced = _dt.datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
194 repo = await create_repo(db_session, owner="gabriel")
195 await _seed_symbol(
196 db_session, repo.repo_id, "src/b.py::born_old",
197 first_introduced=introduced,
198 )
199 await db_session.flush()
200
201 symbols, _, _ = await _fetch_symbol_list(
202 db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50
203 )
204 sym = next((s for s in symbols if s["address"] == "src/b.py::born_old"), None)
205 assert sym is not None
206 assert sym["first_introduced"] is not None
207 assert "2025-01-15" in sym["first_introduced"]
208
209
210 # ---------------------------------------------------------------------------
211 # R204 — change_count comes from vitals, not intel.churn
212 # ---------------------------------------------------------------------------
213
214 @pytest.mark.asyncio
215 async def test_r204_change_count_from_vitals_not_churn(db_session: AsyncSession) -> None:
216 """change_count per row must match vitals.change_count, not intel.churn."""
217 from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list
218
219 repo = await create_repo(db_session, owner="gabriel")
220 # vitals says 42 changes; intel.churn says 5
221 await _seed_symbol(
222 db_session, repo.repo_id, "src/c.py::counted",
223 churn=5,
224 change_count=42,
225 )
226 await db_session.flush()
227
228 symbols, _, _ = await _fetch_symbol_list(
229 db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50
230 )
231 sym = next((s for s in symbols if s["address"] == "src/c.py::counted"), None)
232 assert sym is not None
233 assert sym["change_count"] == 42
234
235
236 # ---------------------------------------------------------------------------
237 # R205 — symbols without a vitals row still appear (LEFT JOIN semantics)
238 # ---------------------------------------------------------------------------
239
240 @pytest.mark.asyncio
241 async def test_r205_symbol_without_vitals_still_appears(db_session: AsyncSession) -> None:
242 """A symbol with intel but no vitals must still appear in the list."""
243 from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list
244
245 repo = await create_repo(db_session, owner="gabriel")
246 await _seed_intel_only(db_session, repo.repo_id, "src/d.py::no_vitals")
247 await db_session.flush()
248
249 symbols, _, _ = await _fetch_symbol_list(
250 db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50
251 )
252 addresses = [s["address"] for s in symbols]
253 assert "src/d.py::no_vitals" in addresses
254
255
256 # ---------------------------------------------------------------------------
257 # R206 — pagination cursor still works correctly after JOIN
258 # ---------------------------------------------------------------------------
259
260 @pytest.mark.asyncio
261 async def test_r206_pagination_still_works_after_join(db_session: AsyncSession) -> None:
262 """Cursor-based pagination must return non-overlapping pages after the JOIN."""
263 from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list
264
265 repo = await create_repo(db_session, owner="gabriel")
266 for i in range(5):
267 await _seed_symbol(db_session, repo.repo_id, f"src/page.py::sym_{i:02d}")
268 await db_session.flush()
269
270 syms_p1, total, next_cursor = await _fetch_symbol_list(
271 db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=3
272 )
273 assert len(syms_p1) == 3
274 assert total == 5
275 assert next_cursor is not None
276
277 syms_p2, _, nc2 = await _fetch_symbol_list(
278 db_session, repo.repo_id, q=None, op=[], cursor=next_cursor, per_page=3
279 )
280 assert len(syms_p2) == 2
281 assert nc2 is None
282
283 addrs_p1 = {s["address"] for s in syms_p1}
284 addrs_p2 = {s["address"] for s in syms_p2}
285 assert addrs_p1.isdisjoint(addrs_p2)
286
287
288 # ---------------------------------------------------------------------------
289 # R207 — kind filter still works after JOIN
290 # ---------------------------------------------------------------------------
291
292 @pytest.mark.asyncio
293 async def test_r207_kind_filter_works_after_join(db_session: AsyncSession) -> None:
294 """op filter must still work when the vitals JOIN is present."""
295 from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list
296
297 repo = await create_repo(db_session, owner="gabriel")
298 await _seed_symbol(db_session, repo.repo_id, "src/e.py::added_fn", op="insert")
299 await _seed_symbol(db_session, repo.repo_id, "src/e.py::modified_fn", op="replace")
300 await db_session.flush()
301
302 syms, _, _ = await _fetch_symbol_list(
303 db_session, repo.repo_id, q=None, op=["insert"], cursor=None, per_page=50
304 )
305 addresses = [s["address"] for s in syms]
306 assert "src/e.py::added_fn" in addresses
307 assert "src/e.py::modified_fn" not in addresses
308
309
310 # ---------------------------------------------------------------------------
311 # R208 — search filter still works after JOIN
312 # ---------------------------------------------------------------------------
313
314 @pytest.mark.asyncio
315 async def test_r208_search_filter_works_after_join(db_session: AsyncSession) -> None:
316 """q= substring search must still work when the vitals JOIN is present."""
317 from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list
318
319 repo = await create_repo(db_session, owner="gabriel")
320 await _seed_symbol(db_session, repo.repo_id, "src/f.py::compute_invoice")
321 await _seed_symbol(db_session, repo.repo_id, "src/f.py::send_email")
322 await db_session.flush()
323
324 syms, _, _ = await _fetch_symbol_list(
325 db_session, repo.repo_id, q="invoice", op=[], cursor=None, per_page=50
326 )
327 addresses = [s["address"] for s in syms]
328 assert "src/f.py::compute_invoice" in addresses
329 assert "src/f.py::send_email" not in addresses
File History 1 commit
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago