""" Tier 1 — Unit tests for RepoCardEnrichment model and pure helper functions. Tests run in-process with no database. Every function under test is a pure Python function — no async, no SQLAlchemy, no fixtures required. Test IDs -------- T100 — health_status returns 'clean' when all counts are zero T101 — health_status returns 'warn' when dead_count > 0, error_count == 0 T102 — health_status returns 'risk' when error_count > 0 regardless of others T103 — SymbolStat.name extracts the last segment after '::' T104 — SymbolStat.name returns full address when no '::' present T105 — autonomy_pct of 0 is the default (no ZeroDivisionError at construction) T106 — _build_pulse always returns exactly 30 PulseBucket entries T107 — _build_pulse zero-fills missing days T108 — _build_pulse sets h=0 for zero-count buckets T109 — _normalise_heights scales busiest bar to _SPARKLINE_HEIGHT T110 — _normalise_heights sets minimum h=2 for any count > 0 T111 — spectral_color(0, 30) returns the blue stop #60a5fa T112 — spectral_color(29, 30) returns the pink stop #c084fc T113 — spectral_color returns a valid hex string for all positions T114 — spectral_color with total=1 returns the first stop without division error T115 — PulseBucket colours traverse the full spectral gradient across 30 bars T116 — _make_empty_buckets dates are in ascending chronological order T117 — enrich_repo_cards([]) returns empty dict without raising """ from __future__ import annotations import re from datetime import date, timedelta from unittest.mock import AsyncMock, MagicMock import pytest from musehub.services.repo_card_enrichment import ( PulseBucket, RepoCardEnrichment, SymbolStat, _PULSE_DAYS, _SPARKLINE_HEIGHT, _build_pulse, _make_empty_buckets, _normalise_heights, spectral_color, enrich_repo_cards, ) # --------------------------------------------------------------------------- # T100–T102: health_status property # --------------------------------------------------------------------------- def test_t100_health_clean_when_all_zero() -> None: """T100: all counts zero → 'clean'.""" enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=0, error_count=0, warning_count=0) assert enc.health_status == "clean" def test_t101_health_warn_when_dead_nonzero() -> None: """T101: dead_count > 0, error_count == 0 → 'warn'.""" enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=3, error_count=0, warning_count=0) assert enc.health_status == "warn" def test_t101b_health_warn_when_warnings_nonzero() -> None: """T101b: warning_count > 0, error_count == 0 → 'warn'.""" enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=0, error_count=0, warning_count=2) assert enc.health_status == "warn" def test_t102_health_risk_when_errors_nonzero() -> None: """T102: error_count > 0 → 'risk', regardless of dead or warning counts.""" enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=10, error_count=1, warning_count=5) assert enc.health_status == "risk" def test_t102b_health_risk_dominates_warn() -> None: """T102b: error_count > 0 always returns 'risk' even when dead_count == 0.""" enc = RepoCardEnrichment(repo_id="sha256:abc", dead_count=0, error_count=1, warning_count=0) assert enc.health_status == "risk" # --------------------------------------------------------------------------- # T103–T104: SymbolStat.name property # --------------------------------------------------------------------------- def test_t103_symbol_stat_name_extracts_after_double_colon() -> None: """T103: name is the segment after the last '::'.""" sym = SymbolStat(address="musehub/services/foo.py::bar_baz", churn_30d=4, blast=12) assert sym.name == "bar_baz" def test_t103b_symbol_stat_name_handles_nested_path() -> None: """T103b: name works when the address has multiple '::' separators.""" sym = SymbolStat(address="src/a.py::MyClass::method", churn_30d=1, blast=0) assert sym.name == "method" def test_t104_symbol_stat_name_returns_full_address_when_no_separator() -> None: """T104: when no '::' is present the full address is returned.""" sym = SymbolStat(address="plain_symbol", churn_30d=0, blast=0) assert sym.name == "plain_symbol" # --------------------------------------------------------------------------- # T105: safe default autonomy_pct # --------------------------------------------------------------------------- def test_t105_default_autonomy_pct_is_zero() -> None: """T105: RepoCardEnrichment.autonomy_pct defaults to 0 — no ZeroDivisionError.""" enc = RepoCardEnrichment(repo_id="sha256:abc") assert enc.autonomy_pct == 0 # --------------------------------------------------------------------------- # T106–T108: _build_pulse bucket count, zero-fill, h=0 # --------------------------------------------------------------------------- def test_t106_build_pulse_always_30_buckets() -> None: """T106: _build_pulse returns exactly 30 PulseBucket entries.""" today = date.today() buckets = _build_pulse({}, today) assert len(buckets) == _PULSE_DAYS def test_t107_build_pulse_zero_fills_missing_days() -> None: """T107: days absent from raw dict are zero-filled.""" today = date.today() # only provide one day of data raw = {today.isoformat(): 5} buckets = _build_pulse(raw, today) zero_buckets = [b for b in buckets if b.date != today.isoformat()] assert all(b.count == 0 for b in zero_buckets) def test_t108_build_pulse_h_zero_for_zero_count() -> None: """T108: buckets with count=0 have h=0 after normalisation.""" today = date.today() raw = {today.isoformat(): 10} buckets = _build_pulse(raw, today) zero_buckets = [b for b in buckets if b.count == 0] assert all(b.h == 0 for b in zero_buckets) # --------------------------------------------------------------------------- # T109–T110: _normalise_heights # --------------------------------------------------------------------------- def test_t109_normalise_sets_max_to_sparkline_height() -> None: """T109: the busiest bucket is scaled to exactly _SPARKLINE_HEIGHT.""" today = date.today() buckets = _make_empty_buckets(today) buckets[0].count = 100 buckets[1].count = 50 _normalise_heights(buckets) assert buckets[0].h == _SPARKLINE_HEIGHT def test_t110_normalise_minimum_height_two_for_nonzero() -> None: """T110: any bucket with count > 0 gets h >= 2 (visible even if tiny).""" today = date.today() buckets = _make_empty_buckets(today) buckets[0].count = 1000 # dominant buckets[1].count = 1 # would round to 0 without minimum _normalise_heights(buckets) assert buckets[1].h >= 2 def test_t110b_normalise_noop_when_all_zero() -> None: """T110b: _normalise_heights is a no-op when all counts are zero.""" today = date.today() buckets = _make_empty_buckets(today) _normalise_heights(buckets) assert all(b.h == 0 for b in buckets) # --------------------------------------------------------------------------- # T111–T115: spectral_color # --------------------------------------------------------------------------- _HEX_RE = re.compile(r"^#[0-9a-f]{6}$", re.IGNORECASE) def test_t111_spectral_color_first_is_blue() -> None: """T111: spectral_color(0, 30) returns #60a5fa (blue stop).""" assert spectral_color(0, 30) == "#60a5fa" def test_t112_spectral_color_last_is_pink() -> None: """T112: spectral_color(29, 30) returns #c084fc (pink stop).""" assert spectral_color(29, 30) == "#c084fc" def test_t113_spectral_color_valid_hex_for_all_positions() -> None: """T113: every position in a 30-bar sparkline returns a valid hex colour.""" for i in range(30): color = spectral_color(i, 30) assert _HEX_RE.match(color), f"invalid hex at index {i}: {color!r}" def test_t114_spectral_color_total_one_no_division_error() -> None: """T114: spectral_color with total=1 does not raise ZeroDivisionError.""" color = spectral_color(0, 1) assert _HEX_RE.match(color) def test_t115_spectral_gradient_traverses_full_range() -> None: """T115: colours across 30 bars include both blue (#60a5fa) and pink (#c084fc).""" colors = [spectral_color(i, 30) for i in range(30)] assert "#60a5fa" in colors assert "#c084fc" in colors # --------------------------------------------------------------------------- # T116: _make_empty_buckets date ordering # --------------------------------------------------------------------------- def test_t116_empty_buckets_ascending_dates() -> None: """T116: bucket dates are in strictly ascending chronological order.""" today = date.today() buckets = _make_empty_buckets(today) dates = [date.fromisoformat(b.date) for b in buckets] assert dates == sorted(dates) assert dates[-1] == today # --------------------------------------------------------------------------- # T117: enrich_repo_cards([]) fast path # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_t117_empty_repo_ids_returns_empty_dict() -> None: """T117: enrich_repo_cards([]) returns {} without querying the database.""" mock_db = MagicMock() result = await enrich_repo_cards(mock_db, []) assert result == {} mock_db.execute.assert_not_called()