"""TDD spec for Phase 3, Part 2 — GravityProvider (issue #9). New provider class ``GravityProvider`` with job type ``intel.code.gravity``. Runs ``muse -C code gravity --json`` on push and upserts the 6 gravity columns (gravity_pct, gravity_direct_dependents, gravity_transitive_dependents, gravity_max_depth, gravity_depth_distribution, symbol_kind) into musehub_symbol_intel. Key constraint: gravity upsert must NOT touch churn/blast columns. Layers: 1. Registry — "intel.code.gravity" in _PROVIDER_REGISTRY 2. Protocol — provider instance satisfies IntelProvider 3. Dispatch — job_types_for_push("code") includes "intel.code.gravity" 4. Write — upserts all 6 gravity columns from muse output 5. Preserve — churn/blast untouched after gravity upsert 6. JSONB — depth_dist stored as dict, not string 7. Idempotent — run twice, one row with latest data 8. Multi-row — multiple symbols all upserted in one compute() call 9. Empty — empty symbols list returns [] gracefully 10. Error — non-zero subprocess exit returns [] gracefully """ from __future__ import annotations import json import secrets from unittest.mock import AsyncMock, patch import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id from tests.factories import create_repo def _uid() -> str: return fake_id(secrets.token_hex(16)) def _mock_process(stdout: str, returncode: int = 0) -> AsyncMock: proc = AsyncMock() proc.returncode = returncode proc.communicate = AsyncMock(return_value=(stdout.encode(), b"")) return proc _GRAVITY_OUTPUT = json.dumps({ "total_production_symbols": 1883, "max_depth": 9, "symbols": [ { "address": "musehub/storage/backends.py::S3Backend._key", "name": "_key", "kind": "method", "file": "musehub/storage/backends.py", "gravity_pct": 38.9, "direct_dependents": 11, "transitive_dependents": 733, "max_depth": 6, "depth_distribution": {"1": 11, "2": 484, "3": 197, "4": 35, "5": 5, "6": 1}, }, { "address": "musehub/storage/backends.py::StorageBackend.get", "name": "get", "kind": "async_method", "file": "musehub/storage/backends.py", "gravity_pct": 36.2, "direct_dependents": 424, "transitive_dependents": 682, "max_depth": 5, "depth_distribution": {"1": 424, "2": 206, "3": 46, "4": 5, "5": 1}, }, ], }) # ───────────────────────────────────────────────────────────────────────────── # Layer 1 — Registry # ───────────────────────────────────────────────────────────────────────────── class TestGravityProviderRegistry: def test_P3_29_gravity_in_provider_registry(self) -> None: from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY assert "intel.code.gravity" in _PROVIDER_REGISTRY, ( "intel.code.gravity not registered in _PROVIDER_REGISTRY" ) def test_P3_30_gravity_satisfies_intel_provider_protocol(self) -> None: from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY, IntelProvider provider = _PROVIDER_REGISTRY["intel.code.gravity"] assert isinstance(provider, IntelProvider) # ───────────────────────────────────────────────────────────────────────────── # Layer 2 — Dispatch # ───────────────────────────────────────────────────────────────────────────── class TestGravityProviderDispatch: def test_P3_31_job_types_for_push_code_includes_gravity(self) -> None: from musehub.services.musehub_intel_providers import job_types_for_push types = job_types_for_push("code") assert "intel.code.gravity" in types def test_P3_32_job_types_for_push_midi_excludes_gravity(self) -> None: from musehub.services.musehub_intel_providers import job_types_for_push assert "intel.code.gravity" not in job_types_for_push("midi") def test_P3_33_job_types_for_push_legacy_types_intact(self) -> None: from musehub.services.musehub_intel_providers import job_types_for_push types = job_types_for_push("code") for required in ("intel.structural", "intel.code", "gc"): assert required in types, f"{required} missing from job_types_for_push('code')"