gabriel / musehub public
test_repo_card_enrichment_unit.py python
237 lines 9.2 KB
Raw
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