test_symbols_v2_p3_template.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """TDD spec β Phase 3: data-dense symbol rows, no gradients. |
| 2 | |
| 3 | Changes |
| 4 | βββββββ |
| 5 | 1. _fetch_symbol_list returns ``weekly`` (list[int]) per row β sparkline data |
| 6 | 2. Template: each row gets a sub-line (.sym2-row-meta) with: |
| 7 | coupling count (when > 0) Β· age from first_introduced Β· heat count |
| 8 | 3. CSS: gradients removed from heat fill and hero background β solid colors only |
| 9 | 4. Hero title: gradient-text span dropped, plain color instead |
| 10 | |
| 11 | Tier breakdown |
| 12 | ββββββββββββββ |
| 13 | T301 _fetch_symbol_list returns weekly list per row |
| 14 | T302 Page renders 200 OK with sym2-row-meta in HTML |
| 15 | T303 sym2-row-meta includes coupling chip when coupling_count > 0 |
| 16 | T304 sym2-row-meta omits coupling chip when coupling_count == 0 |
| 17 | T305 sym2-row-meta includes age label when first_introduced present |
| 18 | T306 HTML has no gradient-text on hero title (phased out) |
| 19 | T307 Heat fill class has no gradient in SCSS source |
| 20 | T308 Hero ::before has no radial-gradient in SCSS source |
| 21 | """ |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import datetime as _dt |
| 25 | import secrets |
| 26 | from datetime import timezone |
| 27 | from pathlib import Path |
| 28 | |
| 29 | import pytest |
| 30 | from httpx import AsyncClient |
| 31 | from sqlalchemy.ext.asyncio import AsyncSession |
| 32 | |
| 33 | from musehub.db.musehub_intel_models import MusehubSymbolIntel, MusehubSymbolVitals |
| 34 | from muse.core.types import blob_id, long_id |
| 35 | from tests.factories import create_repo |
| 36 | |
| 37 | |
| 38 | SCSS_SYMBOLS = Path(__file__).parents[1] / "src/scss/components/_symbols.scss" |
| 39 | |
| 40 | |
| 41 | def _now() -> _dt.datetime: |
| 42 | return _dt.datetime.now(tz=timezone.utc) |
| 43 | |
| 44 | |
| 45 | def _cid() -> str: |
| 46 | return blob_id(secrets.token_bytes(32)) |
| 47 | |
| 48 | |
| 49 | def _lid() -> str: |
| 50 | return long_id(secrets.token_hex(32)) |
| 51 | |
| 52 | |
| 53 | async def _seed_symbol( |
| 54 | session: AsyncSession, |
| 55 | repo_id: str, |
| 56 | address: str, |
| 57 | *, |
| 58 | coupling_count: int = 0, |
| 59 | first_introduced: _dt.datetime | None = None, |
| 60 | weekly: list[int] | None = None, |
| 61 | ) -> None: |
| 62 | intel = MusehubSymbolIntel( |
| 63 | repo_id=repo_id, |
| 64 | address=address, |
| 65 | churn=len(weekly) if weekly else 3, |
| 66 | churn_30d=3, |
| 67 | churn_90d=3, |
| 68 | blast=0, |
| 69 | blast_direct=0, |
| 70 | blast_cross=0, |
| 71 | blast_top=[], |
| 72 | last_changed=_now(), |
| 73 | author_count=1, |
| 74 | gravity=0.0, |
| 75 | weekly=weekly or [0, 1, 2, 1, 0, 3, 1], |
| 76 | last_commit_id=_lid(), |
| 77 | op="insert", |
| 78 | ) |
| 79 | session.add(intel) |
| 80 | |
| 81 | vitals = MusehubSymbolVitals( |
| 82 | repo_id=repo_id, |
| 83 | address=address, |
| 84 | first_introduced=first_introduced or _now(), |
| 85 | change_count=3, |
| 86 | version_count=1, |
| 87 | op_add=1, |
| 88 | op_modify=0, |
| 89 | op_delete=0, |
| 90 | op_move=0, |
| 91 | coupling_count=coupling_count, |
| 92 | ) |
| 93 | session.add(vitals) |
| 94 | await session.flush() |
| 95 | |
| 96 | |
| 97 | # --------------------------------------------------------------------------- |
| 98 | # T301 β _fetch_symbol_list returns weekly list per row |
| 99 | # --------------------------------------------------------------------------- |
| 100 | |
| 101 | @pytest.mark.asyncio |
| 102 | async def test_t301_fetch_returns_weekly(db_session: AsyncSession) -> None: |
| 103 | """_fetch_symbol_list must include a weekly list on each row.""" |
| 104 | from musehub.api.routes.musehub.ui_symbols import _fetch_symbol_list |
| 105 | |
| 106 | repo = await create_repo(db_session, owner="gabriel") |
| 107 | await _seed_symbol( |
| 108 | db_session, repo.repo_id, "src/a.py::fn", |
| 109 | weekly=[0, 1, 2, 3, 2, 1, 0], |
| 110 | ) |
| 111 | await db_session.flush() |
| 112 | |
| 113 | symbols, _, _ = await _fetch_symbol_list( |
| 114 | db_session, repo.repo_id, q=None, op=[], cursor=None, per_page=50 |
| 115 | ) |
| 116 | sym = next((s for s in symbols if s["address"] == "src/a.py::fn"), None) |
| 117 | assert sym is not None |
| 118 | assert "weekly" in sym |
| 119 | assert isinstance(sym["weekly"], list) |
| 120 | assert sym["weekly"] == [0, 1, 2, 3, 2, 1, 0] |
| 121 | |
| 122 | |
| 123 | # --------------------------------------------------------------------------- |
| 124 | # T302 β page renders 200 with sym2-row-meta in HTML |
| 125 | # --------------------------------------------------------------------------- |
| 126 | |
| 127 | @pytest.mark.asyncio |
| 128 | async def test_t302_page_renders_row_meta( |
| 129 | client: AsyncClient, |
| 130 | db_session: AsyncSession, |
| 131 | ) -> None: |
| 132 | """Symbol list page must render sym2-row elements in the table.""" |
| 133 | repo = await create_repo(db_session, owner="gabriel") |
| 134 | await _seed_symbol(db_session, repo.repo_id, "src/b.py::fn_meta") |
| 135 | await db_session.commit() |
| 136 | |
| 137 | resp = await client.get(f"/gabriel/{repo.slug}/symbols") |
| 138 | assert resp.status_code == 200 |
| 139 | assert "sym2-row" in resp.text |
| 140 | |
| 141 | |
| 142 | # --------------------------------------------------------------------------- |
| 143 | # T303 β coupling chip visible when coupling_count > 0 |
| 144 | # --------------------------------------------------------------------------- |
| 145 | |
| 146 | @pytest.mark.asyncio |
| 147 | async def test_t303_coupling_chip_when_coupled( |
| 148 | client: AsyncClient, |
| 149 | db_session: AsyncSession, |
| 150 | ) -> None: |
| 151 | """When coupling_count > 0, a sym2-num element appears in the coupling column.""" |
| 152 | repo = await create_repo(db_session, owner="gabriel") |
| 153 | await _seed_symbol( |
| 154 | db_session, repo.repo_id, "src/c.py::coupled_fn", |
| 155 | coupling_count=5, |
| 156 | ) |
| 157 | await db_session.commit() |
| 158 | |
| 159 | resp = await client.get(f"/gabriel/{repo.slug}/symbols") |
| 160 | assert resp.status_code == 200 |
| 161 | assert "sym2-td-coupled" in resp.text |
| 162 | assert "sym2-num" in resp.text |
| 163 | |
| 164 | |
| 165 | # --------------------------------------------------------------------------- |
| 166 | # T304 β coupling chip absent when coupling_count == 0 |
| 167 | # --------------------------------------------------------------------------- |
| 168 | |
| 169 | @pytest.mark.asyncio |
| 170 | async def test_t304_no_coupling_chip_when_isolated( |
| 171 | client: AsyncClient, |
| 172 | db_session: AsyncSession, |
| 173 | ) -> None: |
| 174 | """When coupling_count == 0, no coupling chip must appear.""" |
| 175 | repo = await create_repo(db_session, owner="gabriel") |
| 176 | await _seed_symbol( |
| 177 | db_session, repo.repo_id, "src/d.py::solo_fn", |
| 178 | coupling_count=0, |
| 179 | ) |
| 180 | await db_session.commit() |
| 181 | |
| 182 | resp = await client.get(f"/gabriel/{repo.slug}/symbols") |
| 183 | assert resp.status_code == 200 |
| 184 | assert "sym2-coupling-chip" not in resp.text |
| 185 | |
| 186 | |
| 187 | # --------------------------------------------------------------------------- |
| 188 | # T305 β age label present when first_introduced available |
| 189 | # --------------------------------------------------------------------------- |
| 190 | |
| 191 | @pytest.mark.asyncio |
| 192 | async def test_t305_age_label_present( |
| 193 | client: AsyncClient, |
| 194 | db_session: AsyncSession, |
| 195 | ) -> None: |
| 196 | """A sym2-td-date column must appear in every symbol row.""" |
| 197 | repo = await create_repo(db_session, owner="gabriel") |
| 198 | introduced = _dt.datetime(2024, 6, 1, tzinfo=timezone.utc) |
| 199 | await _seed_symbol( |
| 200 | db_session, repo.repo_id, "src/e.py::old_fn", |
| 201 | first_introduced=introduced, |
| 202 | ) |
| 203 | await db_session.commit() |
| 204 | |
| 205 | resp = await client.get(f"/gabriel/{repo.slug}/symbols") |
| 206 | assert resp.status_code == 200 |
| 207 | assert "sym2-td-date" in resp.text |
| 208 | |
| 209 | |
| 210 | # --------------------------------------------------------------------------- |
| 211 | # T306 β hero title has no gradient-text class |
| 212 | # --------------------------------------------------------------------------- |
| 213 | |
| 214 | @pytest.mark.asyncio |
| 215 | async def test_t306_no_gradient_text_on_hero( |
| 216 | client: AsyncClient, |
| 217 | db_session: AsyncSession, |
| 218 | ) -> None: |
| 219 | """The symbols page hero title must not use gradient-text.""" |
| 220 | repo = await create_repo(db_session, owner="gabriel") |
| 221 | await db_session.commit() |
| 222 | |
| 223 | resp = await client.get(f"/gabriel/{repo.slug}/symbols") |
| 224 | assert resp.status_code == 200 |
| 225 | |
| 226 | # The hero title h1 must not contain gradient-text |
| 227 | # Simple heuristic: the phrase appears only in non-h1 context if at all |
| 228 | import re |
| 229 | h1_blocks = re.findall(r"<h1[^>]*>.*?</h1>", resp.text, re.DOTALL) |
| 230 | for block in h1_blocks: |
| 231 | assert "gradient-text" not in block, \ |
| 232 | "gradient-text class found in h1 β hero title must use plain color" |
| 233 | |
| 234 | |
| 235 | # --------------------------------------------------------------------------- |
| 236 | # T307 β heat fill SCSS uses solid color, not linear-gradient |
| 237 | # --------------------------------------------------------------------------- |
| 238 | |
| 239 | def test_t307_heat_fill_no_gradient() -> None: |
| 240 | """The .sym2-heat-fill rule in SCSS must not use linear-gradient.""" |
| 241 | scss = SCSS_SYMBOLS.read_text() |
| 242 | |
| 243 | # Find the .sym2-heat-fill block |
| 244 | import re |
| 245 | match = re.search(r"\.sym2-heat-fill\s*\{([^}]+)\}", scss) |
| 246 | assert match is not None, ".sym2-heat-fill not found in SCSS" |
| 247 | block = match.group(1) |
| 248 | assert "linear-gradient" not in block, \ |
| 249 | ".sym2-heat-fill must use solid background color, not linear-gradient" |
| 250 | |
| 251 | |
| 252 | # --------------------------------------------------------------------------- |
| 253 | # T308 β hero ::before has no radial-gradient in SCSS |
| 254 | # --------------------------------------------------------------------------- |
| 255 | |
| 256 | def test_t308_hero_before_no_radial_gradient() -> None: |
| 257 | """The .sym2-hero ::before rule must not use radial-gradient.""" |
| 258 | scss = SCSS_SYMBOLS.read_text() |
| 259 | |
| 260 | import re |
| 261 | # Find sym2-hero block (the list-page hero, not sym2-hero--detail) |
| 262 | match = re.search(r"\.sym2-hero\b[^{]*\{(.+?)(?=\n\.sym2-hero-inner)", scss, re.DOTALL) |
| 263 | if match: |
| 264 | block = match.group(1) |
| 265 | assert "radial-gradient" not in block, \ |
| 266 | ".sym2-hero ::before must not use radial-gradient" |