"""TDD spec for Phase 4 — /intel/gravity/detail per-symbol detail page (issue #9). New route: GET /{owner}/{repo_slug}/intel/gravity/detail?address= Shows a per-symbol view with: - Symbol header (name, address, kind badge, gravity_pct) - Depth distribution bar chart (one bar per depth level, proportional heights) - Reach numbers (direct / transitive dependents, max chain depth) - Back link to /intel/gravity New helper exposed from ui_intel: _depth_bars(dist) → list of {level, count, pct} sorted by level ascending Layers: 1. Helper — _depth_bars() pure function: None, empty, single, multi, sort 2. Route — handler registered; ?address= resolves to 200 or empty-state 3. Content — HTML contains name, gravity_pct, kind, reach counts 4. Bars — one depth bar per bucket; max bucket renders at 100% 5. Nav — back link present; title/breadcrumb correct """ from __future__ import annotations import secrets from urllib.parse import quote import pytest import pytest_asyncio from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id from musehub.db.musehub_intel_models import MusehubSymbolIntel from musehub.db.musehub_repo_models import MusehubRepo from tests.factories import create_repo # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- _OWNER = "testuser" _SLUG = "gravdetailrepo" _ADDRESS = "musehub/storage/backends.py::S3Backend._key" _DIST = {"1": 3, "2": 11, "3": 7, "4": 2} @pytest_asyncio.fixture async def detail_repo(db_session: AsyncSession) -> MusehubRepo: return await create_repo(db_session, owner=_OWNER, slug=_SLUG) @pytest_asyncio.fixture async def detail_symbol(db_session: AsyncSession, detail_repo: MusehubRepo) -> MusehubSymbolIntel: row = MusehubSymbolIntel( repo_id=str(detail_repo.repo_id), address=_ADDRESS, gravity_pct=38.9, gravity_direct_dependents=11, gravity_transitive_dependents=733, gravity_max_depth=6, gravity_depth_distribution=_DIST, symbol_kind="method", ) db_session.add(row) await db_session.commit() return row def _enc(addr: str) -> str: return quote(addr, safe="") # --------------------------------------------------------------------------- # Layer 1 — _depth_bars() pure helper # --------------------------------------------------------------------------- class TestDepthBarsHelper: def test_P4_01_none_returns_empty(self) -> None: from musehub.api.routes.musehub.ui_intel import _depth_bars assert _depth_bars(None) == [] def test_P4_02_empty_dict_returns_empty(self) -> None: from musehub.api.routes.musehub.ui_intel import _depth_bars assert _depth_bars({}) == [] def test_P4_03_single_bucket_is_100pct(self) -> None: from musehub.api.routes.musehub.ui_intel import _depth_bars result = _depth_bars({"3": 5}) assert len(result) == 1 assert result[0]["level"] == 3 assert result[0]["count"] == 5 assert result[0]["pct"] == 100 def test_P4_04_max_bucket_is_100_others_proportional(self) -> None: from musehub.api.routes.musehub.ui_intel import _depth_bars result = _depth_bars({"1": 4, "2": 8, "3": 2}) by_level = {r["level"]: r for r in result} assert by_level[2]["pct"] == 100 assert by_level[1]["pct"] == 50 assert by_level[3]["pct"] == 25 def test_P4_05_sorted_by_level_ascending(self) -> None: from musehub.api.routes.musehub.ui_intel import _depth_bars result = _depth_bars({"3": 1, "1": 5, "2": 3}) assert [r["level"] for r in result] == [1, 2, 3] def test_P4_06_string_keys_sorted_as_int(self) -> None: from musehub.api.routes.musehub.ui_intel import _depth_bars result = _depth_bars({"9": 1, "10": 2, "1": 5}) assert [r["level"] for r in result] == [1, 9, 10] # --------------------------------------------------------------------------- # Layer 2 — Route registration # --------------------------------------------------------------------------- class TestRouteRegistration: def test_P4_07_detail_route_registered(self) -> None: from musehub.api.routes.musehub.ui_intel import router paths = [r.path for r in router.routes] assert any("gravity/detail" in p for p in paths) # --------------------------------------------------------------------------- # Layer 3 — Route responses # --------------------------------------------------------------------------- class TestRouteResponses: @pytest.mark.asyncio async def test_P4_08_known_address_returns_200( self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_P4_09_unknown_address_returns_200_with_empty_state( self, client: AsyncClient, detail_repo: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address=no%2F%3A%3Asuch" ) assert resp.status_code == 200 assert "empty" in resp.text.lower() or "no gravity" in resp.text.lower() or "no data" in resp.text.lower() @pytest.mark.asyncio async def test_P4_10_missing_address_param_returns_200_with_empty_state( self, client: AsyncClient, detail_repo: MusehubRepo ) -> None: resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/gravity/detail") assert resp.status_code == 200 html = resp.text.lower() assert "no gravity" in html or "no data" in html or "empty" in html # --------------------------------------------------------------------------- # Layer 4 — Template content # --------------------------------------------------------------------------- class TestTemplateContent: @pytest.mark.asyncio async def test_P4_11_symbol_name_in_html( self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" ) assert "S3Backend._key" in resp.text @pytest.mark.asyncio async def test_P4_12_gravity_pct_formatted_in_html( self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" ) assert "38." in resp.text @pytest.mark.asyncio async def test_P4_13_kind_badge_rendered( self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" ) assert "method" in resp.text @pytest.mark.asyncio async def test_P4_14_reach_counts_rendered( self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" ) assert "11" in resp.text # direct assert "733" in resp.text # transitive @pytest.mark.asyncio async def test_P4_15_one_depth_bar_per_bucket( self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" ) # _DIST has 4 levels; expect 4 depth-bar elements assert resp.text.count("depth-bar") >= 4 # --------------------------------------------------------------------------- # Layer 5 — Navigation # --------------------------------------------------------------------------- class TestNavigation: @pytest.mark.asyncio async def test_P4_16_back_link_to_gravity_list( self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" ) assert f"/{_OWNER}/{_SLUG}/intel/gravity" in resp.text