"""TDD spec — Phase 2: enrich symbol list rows with musehub_symbol_vitals JOIN. Problem ─────── The symbol list route builds each row from MusehubSymbolIntel alone. It has no coupling_count, no first_introduced, and its change_count comes from the volatile intel.churn field. Solution ──────── Extract the data-fetching into ``_fetch_symbol_list(db, repo_id, ...)`` and LEFT JOIN musehub_symbol_vitals ON (repo_id, address). Every row now carries pre-computed vitals fields: coupling_count — from vitals (0 when no coupling rows exist) first_introduced — ISO string or None when not yet indexed change_count — from vitals (authoritative, replaces intel.churn) The JOIN is additive: symbols without a vitals row still appear. Tier breakdown ────────────── R201 coupling_count per row from vitals R202 coupling_count is 0 for symbols with no coupling partners R203 first_introduced per row (ISO string or None) R204 change_count comes from vitals, not intel.churn R205 Symbols without a vitals row still appear (LEFT JOIN) R206 Pagination cursor still works correctly after JOIN R207 Kind filter (op=) still works after JOIN R208 Search filter (q=) still works after JOIN """ from __future__ import annotations import datetime as _dt import secrets from datetime import timezone import pytest from sqlalchemy.ext.asyncio import AsyncSession from musehub.db.musehub_intel_models import MusehubSymbolIntel, MusehubSymbolVitals from muse.core.types import blob_id, long_id from tests.factories import create_repo # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _now() -> _dt.datetime: return _dt.datetime.now(tz=timezone.utc) def _cid() -> str: return blob_id(secrets.token_bytes(32)) def _lid() -> str: return long_id(secrets.token_hex(32)) async def _seed_symbol( session: AsyncSession, repo_id: str, address: str, *, churn: int = 5, op: str = "insert", coupling_count: int = 0, first_introduced: _dt.datetime | None = None, change_count: int | None = None, symbol_kind: str | None = None, ) -> None: """Insert a MusehubSymbolIntel row + MusehubSymbolVitals row.""" intel = MusehubSymbolIntel( repo_id=repo_id, address=address, churn=churn, churn_30d=churn, churn_90d=churn, blast=0, blast_direct=0, blast_cross=0, blast_top=[], last_changed=_now(), author_count=1, gravity=0.0, weekly=[], last_commit_id=_lid(), op=op, symbol_kind=symbol_kind, ) session.add(intel) vitals = MusehubSymbolVitals( repo_id=repo_id, address=address, first_introduced=first_introduced or _now(), change_count=change_count if change_count is not None else churn, version_count=1, op_add=1 if op == "insert" else 0, op_modify=1 if op != "insert" else 0, op_delete=0, op_move=0, coupling_count=coupling_count, ) session.add(vitals) await session.flush() async def _seed_intel_only( session: AsyncSession, repo_id: str, address: str, *, churn: int = 2, op: str = "insert", ) -> None: """Insert a MusehubSymbolIntel row with NO vitals row (LEFT JOIN test).""" intel = MusehubSymbolIntel( repo_id=repo_id, address=address, churn=churn, churn_30d=churn, churn_90d=churn, blast=0, blast_direct=0, blast_cross=0, blast_top=[], last_changed=_now(), author_count=1, gravity=0.0, weekly=[], last_commit_id=_lid(), op=op, ) session.add(intel) await session.flush() # --------------------------------------------------------------------------- # R201 — _fetch_symbol_list returns coupling_count per row # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r201_fetch_returns_coupling_count(db_session: AsyncSession) -> None: """Each row from _fetch_symbol_list must include coupling_count from vitals.""" from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list repo = await create_repo(db_session, owner="gabriel") await _seed_symbol(db_session, repo.repo_id, "src/a.py::fn_one", coupling_count=7) await db_session.flush() symbols, total, next_cursor = await _fetch_symbol_list( db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50 ) sym = next((s for s in symbols if s["address"] == "src/a.py::fn_one"), None) assert sym is not None assert sym["coupling_count"] == 7 # --------------------------------------------------------------------------- # R202 — coupling_count is 0 for symbols with no coupling partners # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r202_coupling_count_zero_when_no_partners(db_session: AsyncSession) -> None: """Symbols with coupling_count=0 in vitals must return 0, not None.""" from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list repo = await create_repo(db_session, owner="gabriel") await _seed_symbol(db_session, repo.repo_id, "src/lonely.py::solo", coupling_count=0) await db_session.flush() symbols, _, _ = await _fetch_symbol_list( db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50 ) sym = next((s for s in symbols if s["address"] == "src/lonely.py::solo"), None) assert sym is not None assert sym["coupling_count"] == 0 # --------------------------------------------------------------------------- # R203 — first_introduced per row (ISO string or None) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r203_fetch_returns_first_introduced(db_session: AsyncSession) -> None: """first_introduced must be present on each row as an ISO string.""" from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list introduced = _dt.datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) repo = await create_repo(db_session, owner="gabriel") await _seed_symbol( db_session, repo.repo_id, "src/b.py::born_old", first_introduced=introduced, ) await db_session.flush() symbols, _, _ = await _fetch_symbol_list( db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50 ) sym = next((s for s in symbols if s["address"] == "src/b.py::born_old"), None) assert sym is not None assert sym["first_introduced"] is not None assert "2025-01-15" in sym["first_introduced"] # --------------------------------------------------------------------------- # R204 — change_count comes from vitals, not intel.churn # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r204_change_count_from_vitals_not_churn(db_session: AsyncSession) -> None: """change_count per row must match vitals.change_count, not intel.churn.""" from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list repo = await create_repo(db_session, owner="gabriel") # vitals says 42 changes; intel.churn says 5 await _seed_symbol( db_session, repo.repo_id, "src/c.py::counted", churn=5, change_count=42, ) await db_session.flush() symbols, _, _ = await _fetch_symbol_list( db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50 ) sym = next((s for s in symbols if s["address"] == "src/c.py::counted"), None) assert sym is not None assert sym["change_count"] == 42 # --------------------------------------------------------------------------- # R205 — symbols without a vitals row still appear (LEFT JOIN semantics) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r205_symbol_without_vitals_still_appears(db_session: AsyncSession) -> None: """A symbol with intel but no vitals must still appear in the list.""" from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list repo = await create_repo(db_session, owner="gabriel") await _seed_intel_only(db_session, repo.repo_id, "src/d.py::no_vitals") await db_session.flush() symbols, _, _ = await _fetch_symbol_list( db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50 ) addresses = [s["address"] for s in symbols] assert "src/d.py::no_vitals" in addresses # --------------------------------------------------------------------------- # R206 — pagination cursor still works correctly after JOIN # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r206_pagination_still_works_after_join(db_session: AsyncSession) -> None: """Cursor-based pagination must return non-overlapping pages after the JOIN.""" from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list repo = await create_repo(db_session, owner="gabriel") for i in range(5): await _seed_symbol(db_session, repo.repo_id, f"src/page.py::sym_{i:02d}") await db_session.flush() syms_p1, total, next_cursor = await _fetch_symbol_list( db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=3 ) assert len(syms_p1) == 3 assert total == 5 assert next_cursor is not None syms_p2, _, nc2 = await _fetch_symbol_list( db_session, repo.repo_id, q=None, op=[], cursor=next_cursor, per_page=3 ) assert len(syms_p2) == 2 assert nc2 is None addrs_p1 = {s["address"] for s in syms_p1} addrs_p2 = {s["address"] for s in syms_p2} assert addrs_p1.isdisjoint(addrs_p2) # --------------------------------------------------------------------------- # R207 — kind filter still works after JOIN # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r207_kind_filter_works_after_join(db_session: AsyncSession) -> None: """op filter must still work when the vitals JOIN is present.""" from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list repo = await create_repo(db_session, owner="gabriel") await _seed_symbol(db_session, repo.repo_id, "src/e.py::added_fn", op="insert") await _seed_symbol(db_session, repo.repo_id, "src/e.py::modified_fn", op="replace") await db_session.flush() syms, _, _ = await _fetch_symbol_list( db_session, repo.repo_id, q=None, op=["insert"], cursor=None, per_page=50 ) addresses = [s["address"] for s in syms] assert "src/e.py::added_fn" in addresses assert "src/e.py::modified_fn" not in addresses # --------------------------------------------------------------------------- # R208 — search filter still works after JOIN # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r208_search_filter_works_after_join(db_session: AsyncSession) -> None: """q= substring search must still work when the vitals JOIN is present.""" from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list repo = await create_repo(db_session, owner="gabriel") await _seed_symbol(db_session, repo.repo_id, "src/f.py::compute_invoice") await _seed_symbol(db_session, repo.repo_id, "src/f.py::send_email") await db_session.flush() syms, _, _ = await _fetch_symbol_list( db_session, repo.repo_id, q="invoice", op=[], cursor=None, per_page=50 ) addresses = [s["address"] for s in syms] assert "src/f.py::compute_invoice" in addresses assert "src/f.py::send_email" not in addresses