"""Phase 5 TDD: Profile activity canvas — Mist domain grid. domain_id in musehub_repos is a plain string label ("mist", "code", …) or NULL. No musehub_domains join or sha256-hash domain IDs are involved. These tests require a running PostgreSQL test DB (port 5434) — same as phases 1–4. """ from __future__ import annotations import secrets from datetime import datetime, timedelta, timezone import pytest from sqlalchemy.ext.asyncio import AsyncSession from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo from musehub.core.genesis import compute_identity_id, compute_repo_id from muse.core.types import long_id # --------------------------------------------------------------------------- # Seed helpers # --------------------------------------------------------------------------- def _handle() -> str: return f"mist_canvas_{secrets.token_hex(4)}" def _make_repo(handle: str, slug: str, domain_id: str, ts: datetime) -> MusehubRepo: owner_id = compute_identity_id(handle.encode()) repo_id = compute_repo_id(owner_id, slug, domain_id, ts.isoformat()) return MusehubRepo( repo_id=repo_id, name=slug, owner=handle, slug=slug, visibility="public", owner_user_id=owner_id, domain_id=domain_id, description="", tags=[], created_at=ts, ) async def _seed_mist_repo_with_commits( session: AsyncSession, handle: str, n_commits: int = 2, days_ago: int = 3, ) -> tuple[MusehubRepo, list[MusehubCommit]]: """Create a mist-domain repo with n_commits.""" ts = datetime.now(tz=timezone.utc) - timedelta(days=days_ago) repo = _make_repo(handle, f"mist-proj-{secrets.token_hex(4)}", "mist", ts) session.add(repo) commits = [] for i in range(n_commits): cid = long_id(secrets.token_hex(32)) c = MusehubCommit( commit_id=cid, branch="main", parent_ids=[], author=handle, message=f"commit {i}", timestamp=ts - timedelta(hours=i), ) session.add(c) session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) commits.append(c) await session.flush() return repo, commits async def _seed_mist_repo_no_commits( session: AsyncSession, handle: str, ) -> MusehubRepo: """Create a mist-domain repo with no commits.""" ts = datetime.now(tz=timezone.utc) - timedelta(days=10) repo = _make_repo(handle, f"empty-mist-{secrets.token_hex(4)}", "mist", ts) session.add(repo) await session.flush() return repo # --------------------------------------------------------------------------- # 1. build_activity_canvas includes "mist" domain # --------------------------------------------------------------------------- class TestMistCanvasInclusion: @pytest.mark.asyncio async def test_build_activity_canvas_includes_mist_domain( self, db_session: AsyncSession ) -> None: """build_activity_canvas must return an entry with domain='mist'.""" from musehub.services.musehub_profile import build_activity_canvas handle = _handle() await _seed_mist_repo_with_commits(db_session, handle, n_commits=2) domains = await build_activity_canvas(db_session, handle) domain_names = [d.domain for d in domains] assert "mist" in domain_names, ( f"Expected 'mist' in activity canvas domains; got {domain_names}" ) @pytest.mark.asyncio async def test_mist_domain_grid_has_correct_length( self, db_session: AsyncSession ) -> None: """The mist domain grid must be 364 integers (52 weeks × 7 days).""" from musehub.services.musehub_profile import build_activity_canvas, _GRID_DAYS handle = _handle() await _seed_mist_repo_with_commits(db_session, handle, n_commits=1) domains = await build_activity_canvas(db_session, handle) mist = next((d for d in domains if d.domain == "mist"), None) assert mist is not None assert len(mist.grid) == _GRID_DAYS, ( f"Expected grid of {_GRID_DAYS} integers, got {len(mist.grid)}" ) @pytest.mark.asyncio async def test_mist_domain_total_reflects_commits( self, db_session: AsyncSession ) -> None: """total on the mist domain entry must be >= number of commits seeded.""" from musehub.services.musehub_profile import build_activity_canvas handle = _handle() await _seed_mist_repo_with_commits(db_session, handle, n_commits=3) domains = await build_activity_canvas(db_session, handle) mist = next((d for d in domains if d.domain == "mist"), None) assert mist is not None assert mist.total >= 3, ( f"Expected mist.total >= 3 for 3 commits; got {mist.total}" ) # --------------------------------------------------------------------------- # 2. Empty mist repo → zero grid, no crash # --------------------------------------------------------------------------- class TestMistCanvasEmptyRepo: @pytest.mark.asyncio async def test_empty_mist_repo_not_in_canvas( self, db_session: AsyncSession ) -> None: """A mist repo with no commits is excluded — canvas only shows active domains.""" from musehub.services.musehub_profile import build_activity_canvas handle = _handle() await _seed_mist_repo_no_commits(db_session, handle) domains = await build_activity_canvas(db_session, handle) mist = next((d for d in domains if d.domain == "mist"), None) assert mist is None, "mist domain must be absent when there are no commits" @pytest.mark.asyncio async def test_no_mist_repos_not_in_canvas( self, db_session: AsyncSession ) -> None: """A handle with no mist repos at all must not get a mist entry.""" from musehub.services.musehub_profile import build_activity_canvas handle = _handle() # no repos seeded at all domains = await build_activity_canvas(db_session, handle) mist = next((d for d in domains if d.domain == "mist"), None) assert mist is None, "mist domain must not appear when the user has no mist repos" # --------------------------------------------------------------------------- # 3. _build_domain_commit_grid isolation # --------------------------------------------------------------------------- class TestBuildMistVcsGrid: @pytest.mark.asyncio async def test_domain_commit_grid_is_importable(self) -> None: """_build_domain_commit_grid must be defined in musehub_profile.""" import musehub.services.musehub_profile as _mod assert hasattr(_mod, "_build_domain_commit_grid"), ( "_build_domain_commit_grid must be defined in musehub_profile" ) @pytest.mark.asyncio async def test_domain_commit_grid_counts_only_target_domain( self, db_session: AsyncSession ) -> None: """_build_domain_commit_grid must NOT count commits from other domains.""" from musehub.services.musehub_profile import _build_domain_commit_grid, _utc_today handle = _handle() ts = datetime.now(tz=timezone.utc) cutoff = ts - timedelta(weeks=52) today = _utc_today() # Seed a code-domain repo with 5 commits owner_id = compute_identity_id(handle.encode()) code_repo = _make_repo(handle, f"code-proj-{secrets.token_hex(4)}", "code", ts) db_session.add(code_repo) for i in range(5): cid = long_id(secrets.token_hex(32)) db_session.add(MusehubCommit( commit_id=cid, branch="main", parent_ids=[], author=handle, message=f"code {i}", timestamp=ts - timedelta(hours=i), )) db_session.add(MusehubCommitRef(repo_id=code_repo.repo_id, commit_id=cid)) # Seed a mist-domain repo with 2 commits await _seed_mist_repo_with_commits(db_session, handle, n_commits=2) await db_session.flush() grid = await _build_domain_commit_grid(db_session, handle, today, cutoff, "mist") total = sum(grid) assert total == 2, ( f"_build_domain_commit_grid('mist') must count only mist-domain commits; " f"got total={total} (expected 2)" ) # --------------------------------------------------------------------------- # 4. Regression — existing domains still present # --------------------------------------------------------------------------- class TestMistCanvasRegression: @pytest.mark.asyncio async def test_mist_and_code_both_shown_when_active( self, db_session: AsyncSession ) -> None: """When a user has active mist and code repos, both domains appear.""" from musehub.services.musehub_profile import build_activity_canvas handle = _handle() ts = datetime.now(tz=timezone.utc) - timedelta(days=2) # Seed a code repo with commits code_repo = _make_repo(handle, f"code-{secrets.token_hex(4)}", "code", ts) db_session.add(code_repo) _cid = long_id(secrets.token_hex(32)) db_session.add(MusehubCommit( commit_id=_cid, branch="main", parent_ids=[], author=handle, message="init", timestamp=ts, )) db_session.add(MusehubCommitRef(repo_id=code_repo.repo_id, commit_id=_cid)) # Seed a mist repo with commits await _seed_mist_repo_with_commits(db_session, handle, n_commits=1) await db_session.commit() domains = await build_activity_canvas(db_session, handle) domain_names = {d.domain for d in domains} assert "mist" in domain_names, f"mist missing from {domain_names}" assert "code" in domain_names, f"code missing from {domain_names}" @pytest.mark.asyncio async def test_canvas_only_includes_active_domains( self, db_session: AsyncSession ) -> None: """Canvas returns only domains with real activity — no phantom zero rows.""" from musehub.services.musehub_profile import build_activity_canvas handle = _handle() # No repos seeded — canvas must be empty (no phantom domains) domains = await build_activity_canvas(db_session, handle) assert domains == [], ( f"Expected empty canvas for user with no activity; got {[d.domain for d in domains]}" )