test_repo_card_enrichment_unit.py
python
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠ breaking
20 days ago
| 1 | """ |
| 2 | Tier 1 — Unit tests for RepoCardEnrichment model and pure helper functions. |
| 3 | |
| 4 | Tests run in-process with no database. Every function under test is a pure |
| 5 | Python function — no async, no SQLAlchemy, no fixtures required. |
| 6 | |
| 7 | Test IDs |
| 8 | -------- |
| 9 | T100 — health_status returns 'clean' when all counts are zero |
| 10 | T101 — health_status returns 'warn' when dead_count > 0, error_count == 0 |
| 11 | T102 — health_status returns 'risk' when error_count > 0 regardless of others |
| 12 | T103 — SymbolStat.name extracts the last segment after '::' |
| 13 | T104 — SymbolStat.name returns full address when no '::' present |
| 14 | T105 — autonomy_pct of 0 is the default (no ZeroDivisionError at construction) |
| 15 | T106 — _build_pulse always returns exactly 30 PulseBucket entries |
| 16 | T107 — _build_pulse zero-fills missing days |
| 17 | T108 — _build_pulse sets h=0 for zero-count buckets |
| 18 | T109 — _normalise_heights scales busiest bar to _SPARKLINE_HEIGHT |
| 19 | T110 — _normalise_heights sets minimum h=2 for any count > 0 |
| 20 | T111 — spectral_color(0, 30) returns the blue stop #60a5fa |
| 21 | T112 — spectral_color(29, 30) returns the pink stop #c084fc |
| 22 | T113 — spectral_color returns a valid hex string for all positions |
| 23 | T114 — spectral_color with total=1 returns the first stop without division error |
| 24 | T115 — PulseBucket colours traverse the full spectral gradient across 30 bars |
| 25 | T116 — _make_empty_buckets dates are in ascending chronological order |
| 26 | T117 — enrich_repo_cards([]) returns empty dict without raising |
| 27 | """ |
| 28 | from __future__ import annotations |
| 29 | |
| 30 | import re |
| 31 | from datetime import date, timedelta |
| 32 | from unittest.mock import AsyncMock, MagicMock |
| 33 | |
| 34 | import pytest |
| 35 | |
| 36 | from musehub.services.repo_card_enrichment import ( |
| 37 | PulseBucket, |
| 38 | RepoCardEnrichment, |
| 39 | SymbolStat, |
| 40 | _PULSE_DAYS, |
| 41 | _SPARKLINE_HEIGHT, |
| 42 | _build_pulse, |
| 43 | _make_empty_buckets, |
| 44 | _normalise_heights, |
| 45 | spectral_color, |
| 46 | enrich_repo_cards, |
| 47 | ) |
| 48 | |
| 49 | # --------------------------------------------------------------------------- |
| 50 | # T100–T102: health_status property |
| 51 | # --------------------------------------------------------------------------- |
| 52 | |
| 53 | def test_t100_health_clean_when_all_zero() -> None: |
| 54 | """T100: all counts zero → 'clean'.""" |
| 55 | enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=0, error_count=0, warning_count=0) |
| 56 | assert enc.health_status == "clean" |
| 57 | |
| 58 | |
| 59 | def test_t101_health_warn_when_dead_nonzero() -> None: |
| 60 | """T101: dead_count > 0, error_count == 0 → 'warn'.""" |
| 61 | enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=3, error_count=0, warning_count=0) |
| 62 | assert enc.health_status == "warn" |
| 63 | |
| 64 | |
| 65 | def test_t101b_health_warn_when_warnings_nonzero() -> None: |
| 66 | """T101b: warning_count > 0, error_count == 0 → 'warn'.""" |
| 67 | enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=0, error_count=0, warning_count=2) |
| 68 | assert enc.health_status == "warn" |
| 69 | |
| 70 | |
| 71 | def test_t102_health_risk_when_errors_nonzero() -> None: |
| 72 | """T102: error_count > 0 → 'risk', regardless of dead or warning counts.""" |
| 73 | enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=10, error_count=1, warning_count=5) |
| 74 | assert enc.health_status == "risk" |
| 75 | |
| 76 | |
| 77 | def test_t102b_health_risk_dominates_warn() -> None: |
| 78 | """T102b: error_count > 0 always returns 'risk' even when dead_count == 0.""" |
| 79 | enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=0, error_count=1, warning_count=0) |
| 80 | assert enc.health_status == "risk" |
| 81 | |
| 82 | |
| 83 | # --------------------------------------------------------------------------- |
| 84 | # T103–T104: SymbolStat.name property |
| 85 | # --------------------------------------------------------------------------- |
| 86 | |
| 87 | def test_t103_symbol_stat_name_extracts_after_double_colon() -> None: |
| 88 | """T103: name is the segment after the last '::'.""" |
| 89 | sym = SymbolStat(address="musehub/services/foo.py::bar_baz", churn_30d=4, blast=12) |
| 90 | assert sym.name == "bar_baz" |
| 91 | |
| 92 | |
| 93 | def test_t103b_symbol_stat_name_handles_nested_path() -> None: |
| 94 | """T103b: name works when the address has multiple '::' separators.""" |
| 95 | sym = SymbolStat(address="src/a.py::MyClass::method", churn_30d=1, blast=0) |
| 96 | assert sym.name == "method" |
| 97 | |
| 98 | |
| 99 | def test_t104_symbol_stat_name_returns_full_address_when_no_separator() -> None: |
| 100 | """T104: when no '::' is present the full address is returned.""" |
| 101 | sym = SymbolStat(address="plain_symbol", churn_30d=0, blast=0) |
| 102 | assert sym.name == "plain_symbol" |
| 103 | |
| 104 | |
| 105 | # --------------------------------------------------------------------------- |
| 106 | # T105: safe default autonomy_pct |
| 107 | # --------------------------------------------------------------------------- |
| 108 | |
| 109 | def test_t105_default_autonomy_pct_is_zero() -> None: |
| 110 | """T105: RepoCardEnrichment.autonomy_pct defaults to 0 — no ZeroDivisionError.""" |
| 111 | enc = RepoCardEnrichment(repo_id="sha256:abc") |
| 112 | assert enc.autonomy_pct == 0 |
| 113 | |
| 114 | |
| 115 | # --------------------------------------------------------------------------- |
| 116 | # T106–T108: _build_pulse bucket count, zero-fill, h=0 |
| 117 | # --------------------------------------------------------------------------- |
| 118 | |
| 119 | def test_t106_build_pulse_always_30_buckets() -> None: |
| 120 | """T106: _build_pulse returns exactly 30 PulseBucket entries.""" |
| 121 | today = date.today() |
| 122 | buckets = _build_pulse({}, today) |
| 123 | assert len(buckets) == _PULSE_DAYS |
| 124 | |
| 125 | |
| 126 | def test_t107_build_pulse_zero_fills_missing_days() -> None: |
| 127 | """T107: days absent from raw dict are zero-filled.""" |
| 128 | today = date.today() |
| 129 | # only provide one day of data |
| 130 | raw = {today.isoformat(): 5} |
| 131 | buckets = _build_pulse(raw, today) |
| 132 | zero_buckets = [b for b in buckets if b.date != today.isoformat()] |
| 133 | assert all(b.count == 0 for b in zero_buckets) |
| 134 | |
| 135 | |
| 136 | def test_t108_build_pulse_h_zero_for_zero_count() -> None: |
| 137 | """T108: buckets with count=0 have h=0 after normalisation.""" |
| 138 | today = date.today() |
| 139 | raw = {today.isoformat(): 10} |
| 140 | buckets = _build_pulse(raw, today) |
| 141 | zero_buckets = [b for b in buckets if b.count == 0] |
| 142 | assert all(b.h == 0 for b in zero_buckets) |
| 143 | |
| 144 | |
| 145 | # --------------------------------------------------------------------------- |
| 146 | # T109–T110: _normalise_heights |
| 147 | # --------------------------------------------------------------------------- |
| 148 | |
| 149 | def test_t109_normalise_sets_max_to_sparkline_height() -> None: |
| 150 | """T109: the busiest bucket is scaled to exactly _SPARKLINE_HEIGHT.""" |
| 151 | today = date.today() |
| 152 | buckets = _make_empty_buckets(today) |
| 153 | buckets[0].count = 100 |
| 154 | buckets[1].count = 50 |
| 155 | _normalise_heights(buckets) |
| 156 | assert buckets[0].h == _SPARKLINE_HEIGHT |
| 157 | |
| 158 | |
| 159 | def test_t110_normalise_minimum_height_two_for_nonzero() -> None: |
| 160 | """T110: any bucket with count > 0 gets h >= 2 (visible even if tiny).""" |
| 161 | today = date.today() |
| 162 | buckets = _make_empty_buckets(today) |
| 163 | buckets[0].count = 1000 # dominant |
| 164 | buckets[1].count = 1 # would round to 0 without minimum |
| 165 | _normalise_heights(buckets) |
| 166 | assert buckets[1].h >= 2 |
| 167 | |
| 168 | |
| 169 | def test_t110b_normalise_noop_when_all_zero() -> None: |
| 170 | """T110b: _normalise_heights is a no-op when all counts are zero.""" |
| 171 | today = date.today() |
| 172 | buckets = _make_empty_buckets(today) |
| 173 | _normalise_heights(buckets) |
| 174 | assert all(b.h == 0 for b in buckets) |
| 175 | |
| 176 | |
| 177 | # --------------------------------------------------------------------------- |
| 178 | # T111–T115: spectral_color |
| 179 | # --------------------------------------------------------------------------- |
| 180 | |
| 181 | _HEX_RE = re.compile(r"^#[0-9a-f]{6}$", re.IGNORECASE) |
| 182 | |
| 183 | |
| 184 | def test_t111_spectral_color_first_is_blue() -> None: |
| 185 | """T111: spectral_color(0, 30) returns #60a5fa (blue stop).""" |
| 186 | assert spectral_color(0, 30) == "#60a5fa" |
| 187 | |
| 188 | |
| 189 | def test_t112_spectral_color_last_is_pink() -> None: |
| 190 | """T112: spectral_color(29, 30) returns #c084fc (pink stop).""" |
| 191 | assert spectral_color(29, 30) == "#c084fc" |
| 192 | |
| 193 | |
| 194 | def test_t113_spectral_color_valid_hex_for_all_positions() -> None: |
| 195 | """T113: every position in a 30-bar sparkline returns a valid hex colour.""" |
| 196 | for i in range(30): |
| 197 | color = spectral_color(i, 30) |
| 198 | assert _HEX_RE.match(color), f"invalid hex at index {i}: {color!r}" |
| 199 | |
| 200 | |
| 201 | def test_t114_spectral_color_total_one_no_division_error() -> None: |
| 202 | """T114: spectral_color with total=1 does not raise ZeroDivisionError.""" |
| 203 | color = spectral_color(0, 1) |
| 204 | assert _HEX_RE.match(color) |
| 205 | |
| 206 | |
| 207 | def test_t115_spectral_gradient_traverses_full_range() -> None: |
| 208 | """T115: colours across 30 bars include both blue (#60a5fa) and pink (#c084fc).""" |
| 209 | colors = [spectral_color(i, 30) for i in range(30)] |
| 210 | assert "#60a5fa" in colors |
| 211 | assert "#c084fc" in colors |
| 212 | |
| 213 | |
| 214 | # --------------------------------------------------------------------------- |
| 215 | # T116: _make_empty_buckets date ordering |
| 216 | # --------------------------------------------------------------------------- |
| 217 | |
| 218 | def test_t116_empty_buckets_ascending_dates() -> None: |
| 219 | """T116: bucket dates are in strictly ascending chronological order.""" |
| 220 | today = date.today() |
| 221 | buckets = _make_empty_buckets(today) |
| 222 | dates = [date.fromisoformat(b.date) for b in buckets] |
| 223 | assert dates == sorted(dates) |
| 224 | assert dates[-1] == today |
| 225 | |
| 226 | |
| 227 | # --------------------------------------------------------------------------- |
| 228 | # T117: enrich_repo_cards([]) fast path |
| 229 | # --------------------------------------------------------------------------- |
| 230 | |
| 231 | @pytest.mark.asyncio |
| 232 | async def test_t117_empty_repo_ids_returns_empty_dict() -> None: |
| 233 | """T117: enrich_repo_cards([]) returns {} without querying the database.""" |
| 234 | mock_db = MagicMock() |
| 235 | result = await enrich_repo_cards(mock_db, []) |
| 236 | assert result == {} |
| 237 | mock_db.execute.assert_not_called() |
File History
1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
20 days ago