gabriel / musehub public
test_mist_phase5_profile_canvas.py python
273 lines 10.1 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago
1 """Phase 5 TDD: Profile activity canvas — Mist domain grid.
2
3 domain_id in musehub_repos is a plain string label ("mist", "code", …) or NULL.
4 No musehub_domains join or sha256-hash domain IDs are involved.
5
6 These tests require a running PostgreSQL test DB (port 5434) — same as phases 1–4.
7 """
8 from __future__ import annotations
9
10 import secrets
11 from datetime import datetime, timedelta, timezone
12
13 import pytest
14 from sqlalchemy.ext.asyncio import AsyncSession
15
16 from musehub.db import musehub_models as db
17 from musehub.core.genesis import compute_identity_id, compute_repo_id
18 from muse.core.types import long_id
19
20
21 # ---------------------------------------------------------------------------
22 # Seed helpers
23 # ---------------------------------------------------------------------------
24
25 def _handle() -> str:
26 return f"mist_canvas_{secrets.token_hex(4)}"
27
28
29 def _make_repo(handle: str, slug: str, domain_id: str, ts: datetime) -> db.MusehubRepo:
30 owner_id = compute_identity_id(handle.encode())
31 repo_id = compute_repo_id(owner_id, slug, domain_id, ts.isoformat())
32 return db.MusehubRepo(
33 repo_id=repo_id,
34 name=slug,
35 owner=handle,
36 slug=slug,
37 visibility="public",
38 owner_user_id=owner_id,
39 domain_id=domain_id,
40 description="",
41 tags=[],
42 created_at=ts,
43 )
44
45
46 async def _seed_mist_repo_with_commits(
47 session: AsyncSession,
48 handle: str,
49 n_commits: int = 2,
50 days_ago: int = 3,
51 ) -> tuple[db.MusehubRepo, list[db.MusehubCommit]]:
52 """Create a mist-domain repo with n_commits."""
53 ts = datetime.now(tz=timezone.utc) - timedelta(days=days_ago)
54 repo = _make_repo(handle, f"mist-proj-{secrets.token_hex(4)}", "mist", ts)
55 session.add(repo)
56
57 commits = []
58 for i in range(n_commits):
59 c = db.MusehubCommit(
60 commit_id=long_id(secrets.token_hex(32)),
61 repo_id=repo.repo_id,
62 branch="main",
63 parent_ids=[],
64 author=handle,
65 message=f"commit {i}",
66 timestamp=ts - timedelta(hours=i),
67 )
68 session.add(c)
69 commits.append(c)
70
71 await session.flush()
72 return repo, commits
73
74
75 async def _seed_mist_repo_no_commits(
76 session: AsyncSession,
77 handle: str,
78 ) -> db.MusehubRepo:
79 """Create a mist-domain repo with no commits."""
80 ts = datetime.now(tz=timezone.utc) - timedelta(days=10)
81 repo = _make_repo(handle, f"empty-mist-{secrets.token_hex(4)}", "mist", ts)
82 session.add(repo)
83 await session.flush()
84 return repo
85
86
87 # ---------------------------------------------------------------------------
88 # 1. build_activity_canvas includes "mist" domain
89 # ---------------------------------------------------------------------------
90
91 class TestMistCanvasInclusion:
92 @pytest.mark.asyncio
93 async def test_build_activity_canvas_includes_mist_domain(
94 self, db_session: AsyncSession
95 ) -> None:
96 """build_activity_canvas must return an entry with domain='mist'."""
97 from musehub.services.musehub_profile import build_activity_canvas
98
99 handle = _handle()
100 await _seed_mist_repo_with_commits(db_session, handle, n_commits=2)
101
102 domains = await build_activity_canvas(db_session, handle)
103 domain_names = [d.domain for d in domains]
104 assert "mist" in domain_names, (
105 f"Expected 'mist' in activity canvas domains; got {domain_names}"
106 )
107
108 @pytest.mark.asyncio
109 async def test_mist_domain_grid_has_correct_length(
110 self, db_session: AsyncSession
111 ) -> None:
112 """The mist domain grid must be 364 integers (52 weeks × 7 days)."""
113 from musehub.services.musehub_profile import build_activity_canvas, _GRID_DAYS
114
115 handle = _handle()
116 await _seed_mist_repo_with_commits(db_session, handle, n_commits=1)
117
118 domains = await build_activity_canvas(db_session, handle)
119 mist = next((d for d in domains if d.domain == "mist"), None)
120 assert mist is not None
121 assert len(mist.grid) == _GRID_DAYS, (
122 f"Expected grid of {_GRID_DAYS} integers, got {len(mist.grid)}"
123 )
124
125 @pytest.mark.asyncio
126 async def test_mist_domain_total_reflects_commits(
127 self, db_session: AsyncSession
128 ) -> None:
129 """total on the mist domain entry must be >= number of commits seeded."""
130 from musehub.services.musehub_profile import build_activity_canvas
131
132 handle = _handle()
133 await _seed_mist_repo_with_commits(db_session, handle, n_commits=3)
134
135 domains = await build_activity_canvas(db_session, handle)
136 mist = next((d for d in domains if d.domain == "mist"), None)
137 assert mist is not None
138 assert mist.total >= 3, (
139 f"Expected mist.total >= 3 for 3 commits; got {mist.total}"
140 )
141
142
143 # ---------------------------------------------------------------------------
144 # 2. Empty mist repo → zero grid, no crash
145 # ---------------------------------------------------------------------------
146
147 class TestMistCanvasEmptyRepo:
148 @pytest.mark.asyncio
149 async def test_empty_mist_repo_not_in_canvas(
150 self, db_session: AsyncSession
151 ) -> None:
152 """A mist repo with no commits is excluded — canvas only shows active domains."""
153 from musehub.services.musehub_profile import build_activity_canvas
154
155 handle = _handle()
156 await _seed_mist_repo_no_commits(db_session, handle)
157
158 domains = await build_activity_canvas(db_session, handle)
159 mist = next((d for d in domains if d.domain == "mist"), None)
160 assert mist is None, "mist domain must be absent when there are no commits"
161
162 @pytest.mark.asyncio
163 async def test_no_mist_repos_not_in_canvas(
164 self, db_session: AsyncSession
165 ) -> None:
166 """A handle with no mist repos at all must not get a mist entry."""
167 from musehub.services.musehub_profile import build_activity_canvas
168
169 handle = _handle() # no repos seeded at all
170
171 domains = await build_activity_canvas(db_session, handle)
172 mist = next((d for d in domains if d.domain == "mist"), None)
173 assert mist is None, "mist domain must not appear when the user has no mist repos"
174
175
176 # ---------------------------------------------------------------------------
177 # 3. _build_domain_commit_grid isolation
178 # ---------------------------------------------------------------------------
179
180 class TestBuildMistVcsGrid:
181 @pytest.mark.asyncio
182 async def test_domain_commit_grid_is_importable(self) -> None:
183 """_build_domain_commit_grid must be defined in musehub_profile."""
184 import musehub.services.musehub_profile as _mod
185 assert hasattr(_mod, "_build_domain_commit_grid"), (
186 "_build_domain_commit_grid must be defined in musehub_profile"
187 )
188
189 @pytest.mark.asyncio
190 async def test_domain_commit_grid_counts_only_target_domain(
191 self, db_session: AsyncSession
192 ) -> None:
193 """_build_domain_commit_grid must NOT count commits from other domains."""
194 from musehub.services.musehub_profile import _build_domain_commit_grid, _utc_today
195
196 handle = _handle()
197 ts = datetime.now(tz=timezone.utc)
198 cutoff = ts - timedelta(weeks=52)
199 today = _utc_today()
200
201 # Seed a code-domain repo with 5 commits
202 owner_id = compute_identity_id(handle.encode())
203 code_repo = _make_repo(handle, f"code-proj-{secrets.token_hex(4)}", "code", ts)
204 db_session.add(code_repo)
205 for i in range(5):
206 db_session.add(db.MusehubCommit(
207 commit_id=long_id(secrets.token_hex(32)),
208 repo_id=code_repo.repo_id,
209 branch="main",
210 parent_ids=[],
211 author=handle,
212 message=f"code {i}",
213 timestamp=ts - timedelta(hours=i),
214 ))
215
216 # Seed a mist-domain repo with 2 commits
217 await _seed_mist_repo_with_commits(db_session, handle, n_commits=2)
218 await db_session.flush()
219
220 grid = await _build_domain_commit_grid(db_session, handle, today, cutoff, "mist")
221 total = sum(grid)
222 assert total == 2, (
223 f"_build_domain_commit_grid('mist') must count only mist-domain commits; "
224 f"got total={total} (expected 2)"
225 )
226
227
228 # ---------------------------------------------------------------------------
229 # 4. Regression — existing domains still present
230 # ---------------------------------------------------------------------------
231
232 class TestMistCanvasRegression:
233 @pytest.mark.asyncio
234 async def test_mist_and_code_both_shown_when_active(
235 self, db_session: AsyncSession
236 ) -> None:
237 """When a user has active mist and code repos, both domains appear."""
238 from musehub.services.musehub_profile import build_activity_canvas
239
240 handle = _handle()
241 ts = datetime.now(tz=timezone.utc) - timedelta(days=2)
242
243 # Seed a code repo with commits
244 code_repo = _make_repo(handle, f"code-{secrets.token_hex(4)}", "code", ts)
245 db_session.add(code_repo)
246 db_session.add(db.MusehubCommit(
247 commit_id=long_id(secrets.token_hex(32)),
248 repo_id=code_repo.repo_id, branch="main", parent_ids=[],
249 author=handle, message="init", timestamp=ts,
250 ))
251
252 # Seed a mist repo with commits
253 await _seed_mist_repo_with_commits(db_session, handle, n_commits=1)
254 await db_session.commit()
255
256 domains = await build_activity_canvas(db_session, handle)
257 domain_names = {d.domain for d in domains}
258 assert "mist" in domain_names, f"mist missing from {domain_names}"
259 assert "code" in domain_names, f"code missing from {domain_names}"
260
261 @pytest.mark.asyncio
262 async def test_canvas_only_includes_active_domains(
263 self, db_session: AsyncSession
264 ) -> None:
265 """Canvas returns only domains with real activity — no phantom zero rows."""
266 from musehub.services.musehub_profile import build_activity_canvas
267
268 handle = _handle()
269 # No repos seeded — canvas must be empty (no phantom domains)
270 domains = await build_activity_canvas(db_session, handle)
271 assert domains == [], (
272 f"Expected empty canvas for user with no activity; got {[d.domain for d in domains]}"
273 )
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago