gabriel / musehub public
test_phase4_gravity_detail.py python
223 lines 7.9 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 123 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_models import MusehubSymbolIntel
34 from tests.factories import create_repo
35
36
37 # ---------------------------------------------------------------------------
38 # Shared fixtures
39 # ---------------------------------------------------------------------------
40
41 _OWNER = "testuser"
42 _SLUG = "gravdetailrepo"
43 _ADDRESS = "musehub/storage/backends.py::S3Backend._key"
44 _DIST = {"1": 3, "2": 11, "3": 7, "4": 2}
45
46
47 @pytest_asyncio.fixture
48 async def detail_repo(db_session: AsyncSession):
49 return await create_repo(db_session, owner=_OWNER, slug=_SLUG)
50
51
52 @pytest_asyncio.fixture
53 async def detail_symbol(db_session: AsyncSession, detail_repo):
54 row = MusehubSymbolIntel(
55 repo_id=str(detail_repo.repo_id),
56 address=_ADDRESS,
57 gravity_pct=38.9,
58 gravity_direct_dependents=11,
59 gravity_transitive_dependents=733,
60 gravity_max_depth=6,
61 gravity_depth_distribution=_DIST,
62 symbol_kind="method",
63 )
64 db_session.add(row)
65 await db_session.commit()
66 return row
67
68
69 def _enc(addr: str) -> str:
70 return quote(addr, safe="")
71
72
73 # ---------------------------------------------------------------------------
74 # Layer 1 — _depth_bars() pure helper
75 # ---------------------------------------------------------------------------
76
77 class TestDepthBarsHelper:
78 def test_P4_01_none_returns_empty(self):
79 from musehub.api.routes.musehub.ui_intel import _depth_bars
80 assert _depth_bars(None) == []
81
82 def test_P4_02_empty_dict_returns_empty(self):
83 from musehub.api.routes.musehub.ui_intel import _depth_bars
84 assert _depth_bars({}) == []
85
86 def test_P4_03_single_bucket_is_100pct(self):
87 from musehub.api.routes.musehub.ui_intel import _depth_bars
88 result = _depth_bars({"3": 5})
89 assert len(result) == 1
90 assert result[0]["level"] == 3
91 assert result[0]["count"] == 5
92 assert result[0]["pct"] == 100
93
94 def test_P4_04_max_bucket_is_100_others_proportional(self):
95 from musehub.api.routes.musehub.ui_intel import _depth_bars
96 result = _depth_bars({"1": 4, "2": 8, "3": 2})
97 by_level = {r["level"]: r for r in result}
98 assert by_level[2]["pct"] == 100
99 assert by_level[1]["pct"] == 50
100 assert by_level[3]["pct"] == 25
101
102 def test_P4_05_sorted_by_level_ascending(self):
103 from musehub.api.routes.musehub.ui_intel import _depth_bars
104 result = _depth_bars({"3": 1, "1": 5, "2": 3})
105 assert [r["level"] for r in result] == [1, 2, 3]
106
107 def test_P4_06_string_keys_sorted_as_int(self):
108 from musehub.api.routes.musehub.ui_intel import _depth_bars
109 result = _depth_bars({"9": 1, "10": 2, "1": 5})
110 assert [r["level"] for r in result] == [1, 9, 10]
111
112
113 # ---------------------------------------------------------------------------
114 # Layer 2 — Route registration
115 # ---------------------------------------------------------------------------
116
117 class TestRouteRegistration:
118 def test_P4_07_detail_route_registered(self):
119 from musehub.api.routes.musehub.ui_intel import router
120 paths = [r.path for r in router.routes]
121 assert any("gravity/detail" in p for p in paths)
122
123
124 # ---------------------------------------------------------------------------
125 # Layer 3 — Route responses
126 # ---------------------------------------------------------------------------
127
128 class TestRouteResponses:
129 @pytest.mark.asyncio
130 async def test_P4_08_known_address_returns_200(
131 self, client: AsyncClient, detail_repo, detail_symbol
132 ):
133 resp = await client.get(
134 f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}"
135 )
136 assert resp.status_code == 200
137
138 @pytest.mark.asyncio
139 async def test_P4_09_unknown_address_returns_200_with_empty_state(
140 self, client: AsyncClient, detail_repo
141 ):
142 resp = await client.get(
143 f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address=no%2F%3A%3Asuch"
144 )
145 assert resp.status_code == 200
146 assert "empty" in resp.text.lower() or "no gravity" in resp.text.lower() or "no data" in resp.text.lower()
147
148 @pytest.mark.asyncio
149 async def test_P4_10_missing_address_param_returns_200_with_empty_state(
150 self, client: AsyncClient, detail_repo
151 ):
152 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/gravity/detail")
153 assert resp.status_code == 200
154 html = resp.text.lower()
155 assert "no gravity" in html or "no data" in html or "empty" in html
156
157
158 # ---------------------------------------------------------------------------
159 # Layer 4 — Template content
160 # ---------------------------------------------------------------------------
161
162 class TestTemplateContent:
163 @pytest.mark.asyncio
164 async def test_P4_11_symbol_name_in_html(
165 self, client: AsyncClient, detail_repo, detail_symbol
166 ):
167 resp = await client.get(
168 f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}"
169 )
170 assert "S3Backend._key" in resp.text
171
172 @pytest.mark.asyncio
173 async def test_P4_12_gravity_pct_formatted_in_html(
174 self, client: AsyncClient, detail_repo, detail_symbol
175 ):
176 resp = await client.get(
177 f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}"
178 )
179 assert "38." in resp.text
180
181 @pytest.mark.asyncio
182 async def test_P4_13_kind_badge_rendered(
183 self, client: AsyncClient, detail_repo, detail_symbol
184 ):
185 resp = await client.get(
186 f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}"
187 )
188 assert "method" in resp.text
189
190 @pytest.mark.asyncio
191 async def test_P4_14_reach_counts_rendered(
192 self, client: AsyncClient, detail_repo, detail_symbol
193 ):
194 resp = await client.get(
195 f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}"
196 )
197 assert "11" in resp.text # direct
198 assert "733" in resp.text # transitive
199
200 @pytest.mark.asyncio
201 async def test_P4_15_one_depth_bar_per_bucket(
202 self, client: AsyncClient, detail_repo, detail_symbol
203 ):
204 resp = await client.get(
205 f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}"
206 )
207 # _DIST has 4 levels; expect 4 depth-bar elements
208 assert resp.text.count("depth-bar") >= 4
209
210
211 # ---------------------------------------------------------------------------
212 # Layer 5 — Navigation
213 # ---------------------------------------------------------------------------
214
215 class TestNavigation:
216 @pytest.mark.asyncio
217 async def test_P4_16_back_link_to_gravity_list(
218 self, client: AsyncClient, detail_repo, detail_symbol
219 ):
220 resp = await client.get(
221 f"/{_OWNER}/{_SLUG}/intel/gravity/detail?address={_enc(_ADDRESS)}"
222 )
223 assert f"/{_OWNER}/{_SLUG}/intel/gravity" in resp.text
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 123 days ago