"""Tests for Phase 1 of issue #35 — proposal list enrichment models and service. Tier 1 — Unit Pure logic tests: _score_to_band, _enrich_one (via minimal stubs), model field defaults, and ProposalListFilters validation. No DB, no async. Tier 5 — Data Integrity Invariant assertions over the enrichment layer: zero DB I/O per row after prefetch, deterministic output for identical input, field-range constraints, and correct behaviour under null / edge-case DB values. """ from __future__ import annotations import uuid from datetime import datetime, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from musehub.models.musehub import ( DomainHeatEntry, DomainHeatResponse, MergeReadinessResponse, ProposalListEntry, ProposalListFilters, ) from musehub.services.musehub_proposals import ( _DEFAULT_REQUIRED_APPROVALS, _DOMAIN_WEIGHTS, _ProposalPrefetch, _enrich_one, _score_to_band, ) # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── def _make_proposal( *, proposal_id: str | None = None, proposal_number: int = 1, title: str = "feat: add thing", state: str = "open", from_branch: str = "feat/add-thing", to_branch: str = "dev", author: str = "gabriel", risk_score: float = 0.0, breakage_count: int = 0, test_gap_count: int = 0, symbols_changed: int = 0, touched_symbols: list[str] | None = None, merged_at: datetime | None = None, proposal_type: str = "state_merge", merge_strategy: str = "overlay", agent_model: str | None = None, agent_spawned_by: str | None = None, payment_avax_address: str | None = None, payment_claim_count: int = 0, payment_ledger_delta_nano: int = 0, midi_tracks_changed: int = 0, midi_notes_delta: int = 0, harmonic_tension_delta: float | None = None, ) -> MagicMock: """Return a minimal ORM-like stub for a MusehubProposal.""" p = MagicMock() p.proposal_id = proposal_id or str(uuid.uuid4()) p.proposal_number = proposal_number p.title = title p.state = state p.from_branch = from_branch p.to_branch = to_branch p.author = author p.risk_score = risk_score p.breakage_count = breakage_count p.test_gap_count = test_gap_count p.symbols_changed = symbols_changed p.touched_symbols = touched_symbols or [] p.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) p.merged_at = merged_at p.proposal_type = proposal_type p.merge_strategy = merge_strategy p.agent_model = agent_model p.agent_spawned_by = agent_spawned_by p.payment_avax_address = payment_avax_address p.payment_claim_count = payment_claim_count p.payment_ledger_delta_nano = payment_ledger_delta_nano p.midi_tracks_changed = midi_tracks_changed p.midi_notes_delta = midi_notes_delta p.harmonic_tension_delta = harmonic_tension_delta p.is_draft = state == "drafting" return p def _make_review(*, proposal_id: str, state: str = "approved") -> MagicMock: r = MagicMock() r.proposal_id = proposal_id r.state = state return r def _empty_prefetch(proposal_id: str | None = None) -> _ProposalPrefetch: pid = proposal_id or str(uuid.uuid4()) return _ProposalPrefetch( reviews_by_proposal={pid: []}, author_types={}, ) def _prefetch_with_reviews( proposal_id: str, reviews: list[MagicMock], *, author_type: str = "human" ) -> _ProposalPrefetch: return _ProposalPrefetch( reviews_by_proposal={proposal_id: reviews}, author_types={"gabriel": author_type}, ) # ───────────────────────────────────────────────────────────────────────────── # Tier 1 — Unit: _score_to_band # ───────────────────────────────────────────────────────────────────────────── class TestUnitScoreToBand: """_score_to_band maps [0.0, 1.0] floats to band labels. Tier 1 (Unit): pure function, no I/O. """ def test_zero_returns_none(self) -> None: assert _score_to_band(0.0) == "none" def test_below_low_threshold_returns_none(self) -> None: # anything under 0.01 is none assert _score_to_band(0.005) == "none" def test_exactly_low_threshold_returns_low(self) -> None: assert _score_to_band(0.01) == "low" def test_below_medium_threshold_returns_low(self) -> None: assert _score_to_band(0.24) == "low" def test_exactly_medium_threshold_returns_medium(self) -> None: assert _score_to_band(0.25) == "medium" def test_below_high_threshold_returns_medium(self) -> None: assert _score_to_band(0.49) == "medium" def test_exactly_high_threshold_returns_high(self) -> None: assert _score_to_band(0.50) == "high" def test_below_critical_threshold_returns_high(self) -> None: assert _score_to_band(0.74) == "high" def test_exactly_critical_threshold_returns_critical(self) -> None: assert _score_to_band(0.75) == "critical" def test_max_score_returns_critical(self) -> None: assert _score_to_band(1.0) == "critical" def test_mid_critical_range(self) -> None: assert _score_to_band(0.9) == "critical" # ───────────────────────────────────────────────────────────────────────────── # Tier 1 — Unit: _enrich_one risk computation # ───────────────────────────────────────────────────────────────────────────── class TestUnitEnrichOneRisk: """_enrich_one populates risk fields correctly from proposal.risk_score. Tier 1 (Unit): synchronous, no DB. """ def test_zero_risk_score_yields_no_active_domains(self) -> None: p = _make_proposal(risk_score=0.0) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.active_domains == [] assert entry.domain_risk == {} assert entry.aggregate_risk_score == 0.0 assert entry.aggregate_risk_band == "none" def test_nonzero_risk_activates_code_domain(self) -> None: p = _make_proposal(risk_score=0.6) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert "code" in entry.active_domains assert "code" in entry.domain_risk assert entry.domain_risk["code"] == pytest.approx(0.6) def test_aggregate_score_equals_code_when_only_domain(self) -> None: p = _make_proposal(risk_score=0.8) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.aggregate_risk_score == pytest.approx(0.8) assert entry.aggregate_risk_band == "critical" def test_domain_risk_band_follows_score(self) -> None: p = _make_proposal(risk_score=0.3) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.domain_risk_band["code"] == "medium" def test_invalid_risk_score_raises_value_error(self) -> None: p = _make_proposal(risk_score=1.5) with pytest.raises(ValueError, match="risk_score"): _enrich_one(p, _empty_prefetch(p.proposal_id)) def test_negative_risk_score_raises_value_error(self) -> None: p = _make_proposal(risk_score=-0.1) with pytest.raises(ValueError, match="risk_score"): _enrich_one(p, _empty_prefetch(p.proposal_id)) def test_aggregate_score_rounded_to_4_decimal_places(self) -> None: p = _make_proposal(risk_score=1 / 3) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) # Must be rounded to 4dp; raw 1/3 has many more assert entry.aggregate_risk_score == round(1 / 3, 4) # ───────────────────────────────────────────────────────────────────────────── # Tier 1 — Unit: _enrich_one review/approval logic # ───────────────────────────────────────────────────────────────────────────── class TestUnitEnrichOneApprovals: """_enrich_one computes approval status from pre-fetched reviews. Tier 1 (Unit): synchronous, no DB. """ def test_no_reviews_zero_approvals(self) -> None: p = _make_proposal() entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.approval_count == 0 assert entry.all_merge_conditions_met is False def test_one_approved_review_increments_count(self) -> None: p = _make_proposal(risk_score=0.5) review = _make_review(proposal_id=p.proposal_id) pre = _prefetch_with_reviews(p.proposal_id, [review]) entry = _enrich_one(p, pre) assert entry.approval_count == 1 def test_required_approvals_default_is_two(self) -> None: p = _make_proposal() entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.required_approvals == _DEFAULT_REQUIRED_APPROVALS def test_all_merge_conditions_met_requires_approvals_and_no_breakage(self) -> None: p = _make_proposal(breakage_count=0) reviews = [ _make_review(proposal_id=p.proposal_id), _make_review(proposal_id=p.proposal_id), ] pre = _prefetch_with_reviews(p.proposal_id, reviews) entry = _enrich_one(p, pre) assert entry.all_merge_conditions_met is True def test_breakage_blocks_merge_even_with_enough_approvals(self) -> None: p = _make_proposal(breakage_count=3) reviews = [ _make_review(proposal_id=p.proposal_id), _make_review(proposal_id=p.proposal_id), ] pre = _prefetch_with_reviews(p.proposal_id, reviews) entry = _enrich_one(p, pre) assert entry.all_merge_conditions_met is False def test_non_approved_reviews_not_counted(self) -> None: p = _make_proposal() reviews = [ _make_review(proposal_id=p.proposal_id, state="changes_requested"), _make_review(proposal_id=p.proposal_id, state="pending"), ] pre = _prefetch_with_reviews(p.proposal_id, reviews) entry = _enrich_one(p, pre) assert entry.approval_count == 0 assert entry.all_merge_conditions_met is False def test_domains_approved_populated_when_code_active_and_approved(self) -> None: p = _make_proposal(risk_score=0.4) reviews = [_make_review(proposal_id=p.proposal_id)] pre = _prefetch_with_reviews(p.proposal_id, reviews) entry = _enrich_one(p, pre) assert "code" in entry.domains_approved def test_domains_pending_review_when_not_approved(self) -> None: p = _make_proposal(risk_score=0.4) pre = _prefetch_with_reviews(p.proposal_id, []) entry = _enrich_one(p, pre) assert "code" in entry.domains_pending_review assert "code" not in entry.domains_approved # ───────────────────────────────────────────────────────────────────────────── # Tier 1 — Unit: _enrich_one misc fields # ───────────────────────────────────────────────────────────────────────────── class TestUnitEnrichOneMiscFields: """_enrich_one populates metadata fields correctly. Tier 1 (Unit): synchronous, no DB. """ def test_title_passthrough_under_limit(self) -> None: p = _make_proposal(title="short title") entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.title == "short title" def test_title_truncated_at_80_chars(self) -> None: long_title = "x" * 100 p = _make_proposal(title=long_title) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert len(entry.title) <= 82 # 80 chars + "…" assert entry.title.endswith("…") def test_is_draft_true_for_drafting_state(self) -> None: p = _make_proposal(state="drafting") entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.is_draft is True def test_is_draft_false_for_open_state(self) -> None: p = _make_proposal(state="open") entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.is_draft is False def test_author_type_resolved_from_prefetch(self) -> None: p = _make_proposal(author="gabriel") pre = _ProposalPrefetch( reviews_by_proposal={p.proposal_id: []}, author_types={"gabriel": "agent"}, ) entry = _enrich_one(p, pre) assert entry.author_type == "agent" def test_author_type_defaults_to_human_if_missing(self) -> None: p = _make_proposal(author="unknown-handle") entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.author_type == "human" def test_touched_symbols_preview_capped_at_three(self) -> None: symbols = ["a::Fn", "b::Fn", "c::Fn", "d::Fn", "e::Fn"] p = _make_proposal(touched_symbols=symbols) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.touched_symbols_preview == symbols[:3] def test_touched_symbols_preview_empty_list(self) -> None: p = _make_proposal(touched_symbols=[]) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.touched_symbols_preview == [] def test_is_blocked_false_by_default(self) -> None: p = _make_proposal() entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.is_blocked is False assert entry.blocked_by == [] assert entry.blocks == [] # ───────────────────────────────────────────────────────────────────────────── # Tier 1 — Unit: ProposalListFilters validation # ───────────────────────────────────────────────────────────────────────────── class TestUnitProposalListFilters: """ProposalListFilters enforces field constraints via Pydantic. Tier 1 (Unit): pure Pydantic validation, no DB. """ def test_default_state_is_open(self) -> None: f = ProposalListFilters() assert f.state == "open" def test_valid_states_accepted(self) -> None: for state in ("open", "in_review", "approved", "drafting", "settling", "merged", "abandoned", "all"): f = ProposalListFilters(state=state) assert f.state == state def test_invalid_state_raises(self) -> None: from pydantic import ValidationError with pytest.raises(ValidationError): ProposalListFilters(state="nonsense") def test_default_limit_is_20(self) -> None: f = ProposalListFilters() assert f.limit == 20 def test_limit_min_is_1(self) -> None: from pydantic import ValidationError with pytest.raises(ValidationError): ProposalListFilters(limit=0) def test_limit_max_is_100(self) -> None: from pydantic import ValidationError with pytest.raises(ValidationError): ProposalListFilters(limit=101) def test_default_sort_is_newest(self) -> None: f = ProposalListFilters() assert f.sort == "newest" def test_valid_sorts_accepted(self) -> None: for sort in ("newest", "oldest", "risk_desc", "risk_asc", "merge_ready_first"): f = ProposalListFilters(sort=sort) assert f.sort == sort def test_invalid_sort_raises(self) -> None: from pydantic import ValidationError with pytest.raises(ValidationError): ProposalListFilters(sort="random") def test_default_author_type_is_all(self) -> None: f = ProposalListFilters() assert f.author_type == "all" def test_cursor_is_none_by_default(self) -> None: f = ProposalListFilters() assert f.cursor is None def test_assigned_reviewer_pattern_rejects_spaces(self) -> None: from pydantic import ValidationError with pytest.raises(ValidationError): ProposalListFilters(assigned_reviewer="bad handle") # ───────────────────────────────────────────────────────────────────────────── # Tier 1 — Unit: DomainHeatResponse and MergeReadinessResponse defaults # ───────────────────────────────────────────────────────────────────────────── class TestUnitResponseDefaults: """Response model default values. Tier 1 (Unit): pure Pydantic instantiation. """ def test_domain_heat_response_defaults(self) -> None: r = DomainHeatResponse() assert r.domains == {} assert r.total_open == 0 def test_domain_heat_entry_defaults(self) -> None: e = DomainHeatEntry() assert e.count == 0 assert e.avg_risk == 0.0 def test_merge_readiness_response_defaults(self) -> None: r = MergeReadinessResponse() assert r.ready == [] assert r.blocked == [] assert r.settling == [] assert r.needs_review == [] def test_domain_heat_entry_rejects_negative_count(self) -> None: from pydantic import ValidationError with pytest.raises(ValidationError): DomainHeatEntry(count=-1) def test_domain_heat_entry_rejects_risk_above_1(self) -> None: from pydantic import ValidationError with pytest.raises(ValidationError): DomainHeatEntry(avg_risk=1.1) # ───────────────────────────────────────────────────────────────────────────── # Tier 5 — Data Integrity: invariant assertions # ───────────────────────────────────────────────────────────────────────────── class TestDataIntegrityEnrichOne: """_enrich_one output satisfies domain-level invariants for all inputs. Tier 5 (Data Integrity): no DB; asserts invariants hold over ranges of inputs rather than specific expected values. """ def test_aggregate_risk_score_always_in_0_1(self) -> None: for score in (0.0, 0.01, 0.25, 0.5, 0.75, 1.0): p = _make_proposal(risk_score=score) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert 0.0 <= entry.aggregate_risk_score <= 1.0, ( f"out of range for risk_score={score}" ) def test_domain_risk_values_always_in_0_1(self) -> None: for score in (0.0, 0.5, 1.0): p = _make_proposal(risk_score=score) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) for v in entry.domain_risk.values(): assert 0.0 <= v <= 1.0 def test_active_domains_subset_of_domain_risk_keys(self) -> None: for score in (0.0, 0.3, 0.8): p = _make_proposal(risk_score=score) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert set(entry.active_domains) == set(entry.domain_risk.keys()) def test_domains_approved_subset_of_active_domains(self) -> None: for score in (0.0, 0.5, 1.0): p = _make_proposal(risk_score=score) reviews = [_make_review(proposal_id=p.proposal_id)] pre = _prefetch_with_reviews(p.proposal_id, reviews) entry = _enrich_one(p, pre) assert set(entry.domains_approved).issubset(set(entry.active_domains)) def test_domains_pending_review_complement_of_approved_within_active(self) -> None: for score in (0.0, 0.5): p = _make_proposal(risk_score=score) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) active = set(entry.active_domains) approved = set(entry.domains_approved) pending = set(entry.domains_pending_review) assert approved | pending == active assert approved & pending == set() def test_deterministic_output_same_input(self) -> None: p = _make_proposal(risk_score=0.6, breakage_count=1) pre = _empty_prefetch(p.proposal_id) entry1 = _enrich_one(p, pre) entry2 = _enrich_one(p, pre) assert entry1.model_dump() == entry2.model_dump() def test_proposal_id_preserved_exactly(self) -> None: pid = str(uuid.uuid4()) p = _make_proposal(proposal_id=pid) entry = _enrich_one(p, _empty_prefetch(pid)) assert entry.proposal_id == pid def test_touched_symbols_preview_never_longer_than_3(self) -> None: for n in range(6): symbols = [f"file.py::Fn{i}" for i in range(n)] p = _make_proposal(touched_symbols=symbols) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert len(entry.touched_symbols_preview) <= 3 def test_aggregate_band_consistent_with_score(self) -> None: for score in (0.0, 0.1, 0.3, 0.55, 0.8, 1.0): p = _make_proposal(risk_score=score) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) expected = _score_to_band(entry.aggregate_risk_score) assert entry.aggregate_risk_band == expected, ( f"band mismatch at score={score}" ) def test_merge_condition_met_only_when_no_breakage_and_sufficient_approvals(self) -> None: cases: list[tuple[int, int, bool]] = [ (0, 2, True), (0, 1, False), (1, 2, False), (3, 3, False), ] for breakage, n_approvals, expected in cases: p = _make_proposal(breakage_count=breakage) reviews = [_make_review(proposal_id=p.proposal_id) for _ in range(n_approvals)] pre = _prefetch_with_reviews(p.proposal_id, reviews) entry = _enrich_one(p, pre) assert entry.all_merge_conditions_met is expected, ( f"breakage={breakage} approvals={n_approvals} expected={expected}" ) def test_null_touched_symbols_treated_as_empty(self) -> None: p = _make_proposal(touched_symbols=None) entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.touched_symbols_preview == [] def test_null_risk_score_treated_as_zero(self) -> None: p = _make_proposal(risk_score=0.0) p.risk_score = None entry = _enrich_one(p, _empty_prefetch(p.proposal_id)) assert entry.aggregate_risk_score == 0.0 assert entry.aggregate_risk_band == "none"