gabriel / musehub public
test_phase3_gravity_schema.py python
523 lines 22.9 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """TDD spec for Phase 3, Part 1 — gravity schema extension (issue #9).
2
3 Extends musehub_symbol_intel with 6 new columns that power /intel/gravity:
4
5 gravity_pct FLOAT — gravity_pct from muse code gravity
6 gravity_direct_dependents INTEGER — direct_dependents count
7 gravity_transitive_dependents INTEGER — transitive_dependents count
8 gravity_max_depth SMALLINT — deepest dependency chain
9 gravity_depth_distribution JSONB — {depth_level: count} for the sparkline
10 symbol_kind VARCHAR(64) — method/function/class/async_method
11
12 New index: (repo_id, gravity_pct DESC) — primary sort key for the page.
13
14 Layers:
15 1. Schema — column types, nullability, ORM model
16 2. Index — (repo_id, gravity_pct DESC) exists in DB metadata
17 3. Write — insert and read back all 6 new fields
18 4. JSONB — depth_dist round-trips as a Python dict
19 5. Upsert — gravity update leaves churn/blast columns untouched
20 6. Null-safe — existing rows without gravity data remain valid
21 7. Ordering — rows ordered by gravity_pct DESC via SQL
22 8. Kind — all four symbol_kind values round-trip correctly
23 """
24 from __future__ import annotations
25
26 import secrets
27 from datetime import datetime, timezone
28
29 import pytest
30 from sqlalchemy import inspect, select, text
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from muse.core.types import fake_id
34 from tests.factories import create_repo
35
36
37 def _uid() -> str:
38 return fake_id(secrets.token_hex(16))
39
40
41 def _now() -> datetime:
42 return datetime.now(tz=timezone.utc)
43
44
45 # ─────────────────────────────────────────────────────────────────────────────
46 # Layer 1 — Schema: 6 new columns on MusehubSymbolIntel
47 # ─────────────────────────────────────────────────────────────────────────────
48
49 class TestGravitySchemaColumns:
50
51 def test_P3_01_gravity_pct_column_exists(self) -> None:
52 from musehub.db.musehub_models import MusehubSymbolIntel
53 cols = {c.name for c in MusehubSymbolIntel.__table__.columns}
54 assert "gravity_pct" in cols
55
56 def test_P3_02_gravity_direct_dependents_column_exists(self) -> None:
57 from musehub.db.musehub_models import MusehubSymbolIntel
58 cols = {c.name for c in MusehubSymbolIntel.__table__.columns}
59 assert "gravity_direct_dependents" in cols
60
61 def test_P3_03_gravity_transitive_dependents_column_exists(self) -> None:
62 from musehub.db.musehub_models import MusehubSymbolIntel
63 cols = {c.name for c in MusehubSymbolIntel.__table__.columns}
64 assert "gravity_transitive_dependents" in cols
65
66 def test_P3_04_gravity_max_depth_column_exists(self) -> None:
67 from musehub.db.musehub_models import MusehubSymbolIntel
68 cols = {c.name for c in MusehubSymbolIntel.__table__.columns}
69 assert "gravity_max_depth" in cols
70
71 def test_P3_05_gravity_depth_distribution_column_exists(self) -> None:
72 from musehub.db.musehub_models import MusehubSymbolIntel
73 cols = {c.name for c in MusehubSymbolIntel.__table__.columns}
74 assert "gravity_depth_distribution" in cols
75
76 def test_P3_06_symbol_kind_column_exists(self) -> None:
77 from musehub.db.musehub_models import MusehubSymbolIntel
78 cols = {c.name for c in MusehubSymbolIntel.__table__.columns}
79 assert "symbol_kind" in cols
80
81 def test_P3_07_all_six_columns_present(self) -> None:
82 from musehub.db.musehub_models import MusehubSymbolIntel
83 cols = {c.name for c in MusehubSymbolIntel.__table__.columns}
84 assert cols >= {
85 "gravity_pct",
86 "gravity_direct_dependents",
87 "gravity_transitive_dependents",
88 "gravity_max_depth",
89 "gravity_depth_distribution",
90 "symbol_kind",
91 }
92
93 def test_P3_08_gravity_pct_is_nullable(self) -> None:
94 from musehub.db.musehub_models import MusehubSymbolIntel
95 col = MusehubSymbolIntel.__table__.c.gravity_pct
96 assert col.nullable is True, "gravity_pct must be nullable — existing rows have no gravity data"
97
98 def test_P3_09_gravity_depth_distribution_is_nullable(self) -> None:
99 from musehub.db.musehub_models import MusehubSymbolIntel
100 col = MusehubSymbolIntel.__table__.c.gravity_depth_distribution
101 assert col.nullable is True, "gravity_depth_distribution must be nullable"
102
103 def test_P3_10_symbol_kind_is_nullable(self) -> None:
104 from musehub.db.musehub_models import MusehubSymbolIntel
105 col = MusehubSymbolIntel.__table__.c.symbol_kind
106 assert col.nullable is True, "symbol_kind must be nullable — backfilled on next push"
107
108
109 # ─────────────────────────────────────────────────────────────────────────────
110 # Layer 2 — Index: (repo_id, gravity_pct DESC) in table args
111 # ─────────────────────────────────────────────────────────────────────────────
112
113 class TestGravityIndex:
114
115 def test_P3_11_gravity_pct_index_defined(self) -> None:
116 from musehub.db.musehub_models import MusehubSymbolIntel
117 index_names = {idx.name for idx in MusehubSymbolIntel.__table__.indexes}
118 assert any("gravity_pct" in name for name in index_names), (
119 f"Expected an index on gravity_pct, got: {index_names}"
120 )
121
122
123 # ─────────────────────────────────────────────────────────────────────────────
124 # Layer 3 — Write: all 6 new fields insert and read back
125 # ─────────────────────────────────────────────────────────────────────────────
126
127 class TestGravityWrite:
128
129 @pytest.mark.asyncio
130 async def test_P3_12_gravity_full_write(self, db_session: AsyncSession) -> None:
131 from musehub.db import musehub_models as db
132 repo = await create_repo(db_session)
133 dist = {"1": 11, "2": 484, "3": 197, "4": 35, "5": 5, "6": 1}
134 row = db.MusehubSymbolIntel(
135 repo_id=repo.repo_id,
136 address="musehub/storage/backends.py::S3Backend._key",
137 gravity_pct=38.9,
138 gravity_direct_dependents=11,
139 gravity_transitive_dependents=733,
140 gravity_max_depth=6,
141 gravity_depth_distribution=dist,
142 symbol_kind="method",
143 )
144 db_session.add(row)
145 await db_session.flush()
146 result = await db_session.execute(
147 select(db.MusehubSymbolIntel).where(
148 db.MusehubSymbolIntel.repo_id == repo.repo_id
149 )
150 )
151 back = result.scalars().first()
152 assert back is not None
153 assert back.gravity_pct == pytest.approx(38.9)
154 assert back.gravity_direct_dependents == 11
155 assert back.gravity_transitive_dependents == 733
156 assert back.gravity_max_depth == 6
157 assert back.symbol_kind == "method"
158
159 @pytest.mark.asyncio
160 async def test_P3_13_gravity_write_without_optional_fields(self, db_session: AsyncSession) -> None:
161 from musehub.db import musehub_models as db
162 repo = await create_repo(db_session)
163 row = db.MusehubSymbolIntel(
164 repo_id=repo.repo_id,
165 address="musehub/services/musehub_jobs.py::enqueue_push_intel",
166 )
167 db_session.add(row)
168 await db_session.flush()
169 result = await db_session.execute(
170 select(db.MusehubSymbolIntel).where(
171 db.MusehubSymbolIntel.repo_id == repo.repo_id
172 )
173 )
174 back = result.scalars().first()
175 assert back is not None
176 assert back.gravity_pct is None
177 assert back.gravity_depth_distribution is None
178 assert back.symbol_kind is None
179
180
181 # ─────────────────────────────────────────────────────────────────────────────
182 # Layer 4 — JSONB: depth_distribution round-trips as a Python dict
183 # ─────────────────────────────────────────────────────────────────────────────
184
185 class TestGravityDepthDistJsonb:
186
187 @pytest.mark.asyncio
188 async def test_P3_14_depth_dist_shallow_broad(self, db_session: AsyncSession) -> None:
189 from musehub.db import musehub_models as db
190 repo = await create_repo(db_session)
191 dist = {"1": 424, "2": 206, "3": 46, "4": 5, "5": 1}
192 row = db.MusehubSymbolIntel(
193 repo_id=repo.repo_id,
194 address="musehub/storage/backends.py::StorageBackend.get",
195 gravity_depth_distribution=dist,
196 )
197 db_session.add(row)
198 await db_session.flush()
199 await db_session.refresh(row)
200 assert row.gravity_depth_distribution == dist
201
202 @pytest.mark.asyncio
203 async def test_P3_15_depth_dist_deep_narrow(self, db_session: AsyncSession) -> None:
204 from musehub.db import musehub_models as db
205 repo = await create_repo(db_session)
206 dist = {"1": 1, "2": 17, "3": 27, "4": 406, "5": 189, "6": 42, "7": 5, "8": 1}
207 row = db.MusehubSymbolIntel(
208 repo_id=repo.repo_id,
209 address="musehub/storage/backends.py::LocalBackend",
210 gravity_depth_distribution=dist,
211 )
212 db_session.add(row)
213 await db_session.flush()
214 await db_session.refresh(row)
215 assert row.gravity_depth_distribution == dist
216
217 @pytest.mark.asyncio
218 async def test_P3_16_depth_dist_single_depth(self, db_session: AsyncSession) -> None:
219 from musehub.db import musehub_models as db
220 repo = await create_repo(db_session)
221 dist = {"1": 3}
222 row = db.MusehubSymbolIntel(
223 repo_id=repo.repo_id,
224 address="musehub/services/some_leaf.py::leaf_fn",
225 gravity_depth_distribution=dist,
226 )
227 db_session.add(row)
228 await db_session.flush()
229 await db_session.refresh(row)
230 assert row.gravity_depth_distribution == dist
231
232 @pytest.mark.asyncio
233 async def test_P3_17_depth_dist_nine_levels(self, db_session: AsyncSession) -> None:
234 from musehub.db import musehub_models as db
235 repo = await create_repo(db_session)
236 dist = {"1": 1, "2": 8, "3": 155, "4": 71, "5": 11, "6": 4, "7": 8, "8": 5, "9": 3}
237 row = db.MusehubSymbolIntel(
238 repo_id=repo.repo_id,
239 address="musehub/models/musehub.py::RepoResponse",
240 gravity_depth_distribution=dist,
241 )
242 db_session.add(row)
243 await db_session.flush()
244 await db_session.refresh(row)
245 assert row.gravity_depth_distribution == dist
246 assert len(row.gravity_depth_distribution) == 9
247
248
249 # ─────────────────────────────────────────────────────────────────────────────
250 # Layer 5 — Upsert: gravity update does not touch churn/blast columns
251 # ─────────────────────────────────────────────────────────────────────────────
252
253 class TestGravityUpsert:
254
255 @pytest.mark.asyncio
256 async def test_P3_18_upsert_gravity_preserves_churn(self, db_session: AsyncSession) -> None:
257 from sqlalchemy.dialects.postgresql import insert as pg_insert
258 from musehub.db import musehub_models as db
259 repo = await create_repo(db_session)
260 address = "musehub/storage/backends.py::S3Backend._key"
261
262 # Insert base row with churn data
263 await db_session.execute(
264 pg_insert(db.MusehubSymbolIntel).values(
265 repo_id=repo.repo_id,
266 address=address,
267 churn=42,
268 gravity=0.0,
269 ).on_conflict_do_update(
270 index_elements=["repo_id", "address"],
271 set_={"churn": 42},
272 )
273 )
274 await db_session.flush()
275
276 # Now upsert with gravity fields only
277 await db_session.execute(
278 pg_insert(db.MusehubSymbolIntel).values(
279 repo_id=repo.repo_id,
280 address=address,
281 gravity_pct=38.9,
282 gravity_direct_dependents=11,
283 gravity_transitive_dependents=733,
284 gravity_max_depth=6,
285 gravity_depth_distribution={"1": 11, "2": 484, "3": 197, "4": 35, "5": 5, "6": 1},
286 symbol_kind="method",
287 ).on_conflict_do_update(
288 index_elements=["repo_id", "address"],
289 set_={
290 "gravity_pct": 38.9,
291 "gravity_direct_dependents": 11,
292 "gravity_transitive_dependents": 733,
293 "gravity_max_depth": 6,
294 "gravity_depth_distribution": {"1": 11, "2": 484, "3": 197, "4": 35, "5": 5, "6": 1},
295 "symbol_kind": "method",
296 },
297 )
298 )
299 await db_session.flush()
300
301 result = await db_session.execute(
302 select(db.MusehubSymbolIntel).where(
303 db.MusehubSymbolIntel.repo_id == repo.repo_id,
304 db.MusehubSymbolIntel.address == address,
305 )
306 )
307 back = result.scalars().first()
308 assert back is not None
309 assert back.churn == 42, "churn must be preserved after gravity upsert"
310 assert back.gravity_pct == pytest.approx(38.9)
311 assert back.gravity_direct_dependents == 11
312
313 @pytest.mark.asyncio
314 async def test_P3_19_upsert_gravity_idempotent(self, db_session: AsyncSession) -> None:
315 from sqlalchemy.dialects.postgresql import insert as pg_insert
316 from musehub.db import musehub_models as db
317 repo = await create_repo(db_session)
318 address = "musehub/storage/backends.py::get_backend"
319 dist = {"1": 17, "2": 27, "3": 406, "4": 189, "5": 42, "6": 5, "7": 1}
320
321 for _ in range(3):
322 await db_session.execute(
323 pg_insert(db.MusehubSymbolIntel).values(
324 repo_id=repo.repo_id,
325 address=address,
326 gravity_pct=36.5,
327 gravity_direct_dependents=17,
328 gravity_transitive_dependents=687,
329 gravity_max_depth=7,
330 gravity_depth_distribution=dist,
331 symbol_kind="function",
332 ).on_conflict_do_update(
333 index_elements=["repo_id", "address"],
334 set_={
335 "gravity_pct": 36.5,
336 "gravity_direct_dependents": 17,
337 "gravity_transitive_dependents": 687,
338 "gravity_max_depth": 7,
339 "gravity_depth_distribution": dist,
340 "symbol_kind": "function",
341 },
342 )
343 )
344 await db_session.flush()
345
346 result = await db_session.execute(
347 select(db.MusehubSymbolIntel).where(
348 db.MusehubSymbolIntel.repo_id == repo.repo_id
349 )
350 )
351 rows = result.scalars().all()
352 assert len(rows) == 1, "idempotent upsert must not create duplicate rows"
353 assert rows[0].gravity_pct == pytest.approx(36.5)
354
355
356 # ─────────────────────────────────────────────────────────────────────────────
357 # Layer 6 — Null-safe: rows without gravity data are valid
358 # ─────────────────────────────────────────────────────────────────────────────
359
360 class TestGravityNullSafe:
361
362 @pytest.mark.asyncio
363 async def test_P3_20_churn_only_row_valid(self, db_session: AsyncSession) -> None:
364 from musehub.db import musehub_models as db
365 repo = await create_repo(db_session)
366 row = db.MusehubSymbolIntel(
367 repo_id=repo.repo_id,
368 address="musehub/services/musehub_jobs.py::some_fn",
369 churn=5,
370 blast=2,
371 )
372 db_session.add(row)
373 await db_session.flush()
374 result = await db_session.execute(
375 select(db.MusehubSymbolIntel).where(
376 db.MusehubSymbolIntel.repo_id == repo.repo_id
377 )
378 )
379 back = result.scalars().first()
380 assert back is not None
381 assert back.gravity_pct is None
382 assert back.gravity_depth_distribution is None
383 assert back.symbol_kind is None
384 assert back.churn == 5
385
386 @pytest.mark.asyncio
387 async def test_P3_21_gravity_pct_filter_excludes_nulls(self, db_session: AsyncSession) -> None:
388 from musehub.db import musehub_models as db
389 repo = await create_repo(db_session)
390
391 # One row with gravity, one without
392 db_session.add(db.MusehubSymbolIntel(
393 repo_id=repo.repo_id,
394 address="backends.py::S3Backend._key",
395 gravity_pct=38.9,
396 ))
397 db_session.add(db.MusehubSymbolIntel(
398 repo_id=repo.repo_id,
399 address="backends.py::some_utility",
400 ))
401 await db_session.flush()
402
403 result = await db_session.execute(
404 select(db.MusehubSymbolIntel).where(
405 db.MusehubSymbolIntel.repo_id == repo.repo_id,
406 db.MusehubSymbolIntel.gravity_pct.is_not(None),
407 )
408 )
409 rows = result.scalars().all()
410 assert len(rows) == 1
411 assert rows[0].address == "backends.py::S3Backend._key"
412
413
414 # ─────────────────────────────────────────────────────────────────────────────
415 # Layer 7 — Ordering: gravity_pct DESC gives correct rank order
416 # ─────────────────────────────────────────────────────────────────────────────
417
418 class TestGravityOrdering:
419
420 @pytest.mark.asyncio
421 async def test_P3_22_ordered_by_gravity_pct_desc(self, db_session: AsyncSession) -> None:
422 from musehub.db import musehub_models as db
423 repo = await create_repo(db_session)
424
425 symbols = [
426 ("backends.py::_key", 38.9),
427 ("backends.py::_get_client", 38.8),
428 ("backends.py::get_backend", 36.5),
429 ("models.py::RepoResponse", 14.1),
430 ]
431 for address, pct in symbols:
432 db_session.add(db.MusehubSymbolIntel(
433 repo_id=repo.repo_id,
434 address=address,
435 gravity_pct=pct,
436 ))
437 await db_session.flush()
438
439 result = await db_session.execute(
440 select(db.MusehubSymbolIntel)
441 .where(
442 db.MusehubSymbolIntel.repo_id == repo.repo_id,
443 db.MusehubSymbolIntel.gravity_pct.is_not(None),
444 )
445 .order_by(db.MusehubSymbolIntel.gravity_pct.desc())
446 )
447 rows = result.scalars().all()
448 pcts = [r.gravity_pct for r in rows]
449 assert pcts == sorted(pcts, reverse=True)
450 assert pcts[0] == pytest.approx(38.9)
451
452 @pytest.mark.asyncio
453 async def test_P3_23_top_n_query(self, db_session: AsyncSession) -> None:
454 from musehub.db import musehub_models as db
455 repo = await create_repo(db_session)
456
457 for i in range(10):
458 db_session.add(db.MusehubSymbolIntel(
459 repo_id=repo.repo_id,
460 address=f"backends.py::sym_{i:02d}",
461 gravity_pct=float(i),
462 ))
463 await db_session.flush()
464
465 result = await db_session.execute(
466 select(db.MusehubSymbolIntel)
467 .where(
468 db.MusehubSymbolIntel.repo_id == repo.repo_id,
469 db.MusehubSymbolIntel.gravity_pct.is_not(None),
470 )
471 .order_by(db.MusehubSymbolIntel.gravity_pct.desc())
472 .limit(3)
473 )
474 rows = result.scalars().all()
475 assert len(rows) == 3
476 assert rows[0].gravity_pct == pytest.approx(9.0)
477
478
479 # ─────────────────────────────────────────────────────────────────────────────
480 # Layer 8 — Kind: all four symbol_kind values round-trip
481 # ─────────────────────────────────────────────────────────────────────────────
482
483 class TestGravitySymbolKind:
484
485 @pytest.mark.asyncio
486 @pytest.mark.parametrize("kind", ["method", "function", "class", "async_method"])
487 async def test_P3_24_symbol_kind_roundtrip(self, db_session: AsyncSession, kind: str) -> None:
488 from musehub.db import musehub_models as db
489 repo = await create_repo(db_session)
490 row = db.MusehubSymbolIntel(
491 repo_id=repo.repo_id,
492 address=f"backends.py::sym_for_{kind}",
493 symbol_kind=kind,
494 gravity_pct=10.0,
495 )
496 db_session.add(row)
497 await db_session.flush()
498 await db_session.refresh(row)
499 assert row.symbol_kind == kind
500
501 @pytest.mark.asyncio
502 async def test_P3_28_kind_filter_query(self, db_session: AsyncSession) -> None:
503 from musehub.db import musehub_models as db
504 repo = await create_repo(db_session)
505
506 for kind in ("method", "function", "class", "async_method"):
507 db_session.add(db.MusehubSymbolIntel(
508 repo_id=repo.repo_id,
509 address=f"backends.py::sym_{kind}",
510 symbol_kind=kind,
511 gravity_pct=10.0,
512 ))
513 await db_session.flush()
514
515 result = await db_session.execute(
516 select(db.MusehubSymbolIntel).where(
517 db.MusehubSymbolIntel.repo_id == repo.repo_id,
518 db.MusehubSymbolIntel.symbol_kind == "method",
519 )
520 )
521 rows = result.scalars().all()
522 assert len(rows) == 1
523 assert rows[0].symbol_kind == "method"
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 143 days ago