test_phase4_gravity_detail.py
python
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠ breaking
21 days ago
| 1 | """TDD spec for Phase 4 — /intel/gravity/detail per-symbol detail page (issue #9). |
| 2 | |
| 3 | New route: |
| 4 | GET /{owner}/{repo_slug}/intel/gravity/detail?address=<symbol_address> |
| 5 | |
| 6 | Shows a per-symbol view with: |
| 7 | - Symbol header (name, address, kind badge, gravity_pct) |
| 8 | - Depth distribution bar chart (one bar per depth level, proportional heights) |
| 9 | - Reach numbers (direct / transitive dependents, max chain depth) |
| 10 | - Back link to /intel/gravity |
| 11 | |
| 12 | New helper exposed from ui_intel: |
| 13 | _depth_bars(dist) → list of {level, count, pct} sorted by level ascending |
| 14 | |
| 15 | Layers: |
| 16 | 1. Helper — _depth_bars() pure function: None, empty, single, multi, sort |
| 17 | 2. Route — handler registered; ?address= resolves to 200 or empty-state |
| 18 | 3. Content — HTML contains name, gravity_pct, kind, reach counts |
| 19 | 4. Bars — one depth bar per bucket; max bucket renders at 100% |
| 20 | 5. Nav — back link present; title/breadcrumb correct |
| 21 | """ |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import secrets |
| 25 | from urllib.parse import quote |
| 26 | |
| 27 | import pytest |
| 28 | import pytest_asyncio |
| 29 | from httpx import AsyncClient |
| 30 | from sqlalchemy.ext.asyncio import AsyncSession |
| 31 | |
| 32 | from muse.core.types import fake_id |
| 33 | from musehub.db.musehub_intel_models import MusehubSymbolIntel |
| 34 | from musehub.db.musehub_repo_models import MusehubRepo |
| 35 | from tests.factories import create_repo |
| 36 | |
| 37 | |
| 38 | # --------------------------------------------------------------------------- |
| 39 | # Shared fixtures |
| 40 | # --------------------------------------------------------------------------- |
| 41 | |
| 42 | _OWNER = "testuser" |
| 43 | _SLUG = "gravdetailrepo" |
| 44 | _ADDRESS = "musehub/storage/backends.py::S3Backend._key" |
| 45 | _DIST = {"1": 3, "2": 11, "3": 7, "4": 2} |
| 46 | |
| 47 | |
| 48 | @pytest_asyncio.fixture |
| 49 | async def detail_repo(db_session: AsyncSession) -> MusehubRepo: |
| 50 | return await create_repo(db_session, owner=_OWNER, slug=_SLUG) |
| 51 | |
| 52 | |
| 53 | @pytest_asyncio.fixture |
| 54 | async def detail_symbol(db_session: AsyncSession, detail_repo: MusehubRepo) -> MusehubSymbolIntel: |
| 55 | row = MusehubSymbolIntel( |
| 56 | repo_id=str(detail_repo.repo_id), |
| 57 | address=_ADDRESS, |
| 58 | gravity_pct=38.9, |
| 59 | gravity_direct_dependents=11, |
| 60 | gravity_transitive_dependents=733, |
| 61 | gravity_max_depth=6, |
| 62 | gravity_depth_distribution=_DIST, |
| 63 | symbol_kind="method", |
| 64 | ) |
| 65 | db_session.add(row) |
| 66 | await db_session.commit() |
| 67 | return row |
| 68 | |
| 69 | |
| 70 | def _enc(addr: str) -> str: |
| 71 | return quote(addr, safe="") |
| 72 | |
| 73 | |
| 74 | # --------------------------------------------------------------------------- |
| 75 | # Layer 1 — _depth_bars() pure helper |
| 76 | # --------------------------------------------------------------------------- |
| 77 | |
| 78 | class TestDepthBarsHelper: |
| 79 | def test_P4_01_none_returns_empty(self) -> None: |
| 80 | from musehub.api.routes.musehub.ui_intel import _depth_bars |
| 81 | assert _depth_bars(None) == [] |
| 82 | |
| 83 | def test_P4_02_empty_dict_returns_empty(self) -> None: |
| 84 | from musehub.api.routes.musehub.ui_intel import _depth_bars |
| 85 | assert _depth_bars({}) == [] |
| 86 | |
| 87 | def test_P4_03_single_bucket_is_100pct(self) -> None: |
| 88 | from musehub.api.routes.musehub.ui_intel import _depth_bars |
| 89 | result = _depth_bars({"3": 5}) |
| 90 | assert len(result) == 1 |
| 91 | assert result[0]["level"] == 3 |
| 92 | assert result[0]["count"] == 5 |
| 93 | assert result[0]["pct"] == 100 |
| 94 | |
| 95 | def test_P4_04_max_bucket_is_100_others_proportional(self) -> None: |
| 96 | from musehub.api.routes.musehub.ui_intel import _depth_bars |
| 97 | result = _depth_bars({"1": 4, "2": 8, "3": 2}) |
| 98 | by_level = {r["level"]: r for r in result} |
| 99 | assert by_level[2]["pct"] == 100 |
| 100 | assert by_level[1]["pct"] == 50 |
| 101 | assert by_level[3]["pct"] == 25 |
| 102 | |
| 103 | def test_P4_05_sorted_by_level_ascending(self) -> None: |
| 104 | from musehub.api.routes.musehub.ui_intel import _depth_bars |
| 105 | result = _depth_bars({"3": 1, "1": 5, "2": 3}) |
| 106 | assert [r["level"] for r in result] == [1, 2, 3] |
| 107 | |
| 108 | def test_P4_06_string_keys_sorted_as_int(self) -> None: |
| 109 | from musehub.api.routes.musehub.ui_intel import _depth_bars |
| 110 | result = _depth_bars({"9": 1, "10": 2, "1": 5}) |
| 111 | assert [r["level"] for r in result] == [1, 9, 10] |
| 112 | |
| 113 | |
| 114 | # --------------------------------------------------------------------------- |
| 115 | # Layer 2 — Route registration |
| 116 | # --------------------------------------------------------------------------- |
| 117 | |
| 118 | class TestRouteRegistration: |
| 119 | def test_P4_07_detail_route_registered(self) -> None: |
| 120 | from musehub.api.routes.musehub.ui_intel import router |
| 121 | paths = [r.path for r in router.routes] |
| 122 | assert any("gravity/detail" in p for p in paths) |
| 123 | |
| 124 | |
| 125 | # --------------------------------------------------------------------------- |
| 126 | # Layer 3 — Route responses |
| 127 | # --------------------------------------------------------------------------- |
| 128 | |
| 129 | class TestRouteResponses: |
| 130 | @pytest.mark.asyncio |
| 131 | async def test_P4_08_known_address_returns_200( |
| 132 | self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel |
| 133 | ) -> None: |
| 134 | resp = await client.get( |
| 135 | f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" |
| 136 | ) |
| 137 | assert resp.status_code == 200 |
| 138 | |
| 139 | @pytest.mark.asyncio |
| 140 | async def test_P4_09_unknown_address_returns_200_with_empty_state( |
| 141 | self, client: AsyncClient, detail_repo: MusehubRepo |
| 142 | ) -> None: |
| 143 | resp = await client.get( |
| 144 | f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address=no%2F%3A%3Asuch" |
| 145 | ) |
| 146 | assert resp.status_code == 200 |
| 147 | assert "empty" in resp.text.lower() or "no gravity" in resp.text.lower() or "no data" in resp.text.lower() |
| 148 | |
| 149 | @pytest.mark.asyncio |
| 150 | async def test_P4_10_missing_address_param_returns_200_with_empty_state( |
| 151 | self, client: AsyncClient, detail_repo: MusehubRepo |
| 152 | ) -> None: |
| 153 | resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/gravity/detail") |
| 154 | assert resp.status_code == 200 |
| 155 | html = resp.text.lower() |
| 156 | assert "no gravity" in html or "no data" in html or "empty" in html |
| 157 | |
| 158 | |
| 159 | # --------------------------------------------------------------------------- |
| 160 | # Layer 4 — Template content |
| 161 | # --------------------------------------------------------------------------- |
| 162 | |
| 163 | class TestTemplateContent: |
| 164 | @pytest.mark.asyncio |
| 165 | async def test_P4_11_symbol_name_in_html( |
| 166 | self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel |
| 167 | ) -> None: |
| 168 | resp = await client.get( |
| 169 | f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" |
| 170 | ) |
| 171 | assert "S3Backend._key" in resp.text |
| 172 | |
| 173 | @pytest.mark.asyncio |
| 174 | async def test_P4_12_gravity_pct_formatted_in_html( |
| 175 | self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel |
| 176 | ) -> None: |
| 177 | resp = await client.get( |
| 178 | f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" |
| 179 | ) |
| 180 | assert "38." in resp.text |
| 181 | |
| 182 | @pytest.mark.asyncio |
| 183 | async def test_P4_13_kind_badge_rendered( |
| 184 | self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel |
| 185 | ) -> None: |
| 186 | resp = await client.get( |
| 187 | f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" |
| 188 | ) |
| 189 | assert "method" in resp.text |
| 190 | |
| 191 | @pytest.mark.asyncio |
| 192 | async def test_P4_14_reach_counts_rendered( |
| 193 | self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel |
| 194 | ) -> None: |
| 195 | resp = await client.get( |
| 196 | f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" |
| 197 | ) |
| 198 | assert "11" in resp.text # direct |
| 199 | assert "733" in resp.text # transitive |
| 200 | |
| 201 | @pytest.mark.asyncio |
| 202 | async def test_P4_15_one_depth_bar_per_bucket( |
| 203 | self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel |
| 204 | ) -> None: |
| 205 | resp = await client.get( |
| 206 | f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" |
| 207 | ) |
| 208 | # _DIST has 4 levels; expect 4 depth-bar elements |
| 209 | assert resp.text.count("depth-bar") >= 4 |
| 210 | |
| 211 | |
| 212 | # --------------------------------------------------------------------------- |
| 213 | # Layer 5 — Navigation |
| 214 | # --------------------------------------------------------------------------- |
| 215 | |
| 216 | class TestNavigation: |
| 217 | @pytest.mark.asyncio |
| 218 | async def test_P4_16_back_link_to_gravity_list( |
| 219 | self, client: AsyncClient, detail_repo: MusehubRepo, detail_symbol: MusehubSymbolIntel |
| 220 | ) -> None: |
| 221 | resp = await client.get( |
| 222 | f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}" |
| 223 | ) |
| 224 | assert f"/{_OWNER}/{_SLUG}/intel/gravity" in resp.text |
File History
1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
21 days ago