gabriel / musehub public
test_proposal_reimagination_phase5.py python
582 lines 23.2 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
1 """Phase 5 — API Surface tests (issue #37).
2
3 Tier 1 — Unit (pure / no DB)
4 ProposalResponse new fields:
5 - blocked_by / is_blocked / blocks present and default to empty/False
6 - latest_simulations defaults to empty dict
7
8 ProposalListEntry new fields:
9 - merge_strategy field present and defaults to overlay
10 - simulation_conflict_count field present and defaults to None
11
12 ProposalListFilters new fields:
13 - is_draft filter field present
14 - merge_strategy filter field present
15
16 Tier 5 — Integration (DB)
17 get_proposal enrichment:
18 - blocked_by populated when unmerged dependency exists
19 - is_blocked True when proposal has live dependency
20 - is_blocked False when all dependencies are merged
21 - latest_simulations populated after run_simulation called
22
23 list_proposals filters:
24 - proposal_type filter returns only matching type
25 - is_draft=True returns only draft proposals
26 - is_draft=False returns only non-draft proposals
27 - merge_strategy filter returns only matching strategy
28
29 _enrich_one multi-domain risk:
30 - dimensional_risk dict drives domain_risk when populated
31 - active_domains contains all non-zero dimensional_risk domains
32 - aggregate_risk_score uses dimensional_risk values
33
34 enrich_proposal_list_batch simulation column:
35 - simulation_conflict_count populated from prefetched conflict_scan
36 - simulation_conflict_count is None before any simulation run
37 """
38
39 from __future__ import annotations
40
41 import os
42 from datetime import datetime, timezone
43
44 import pytest
45 from muse.core.types import blob_id, fake_id, short_id
46 from sqlalchemy.ext.asyncio import AsyncSession
47
48 from musehub.types.json_types import StrDict
49 from musehub.models.musehub import (
50 ProposalListEntry,
51 ProposalListFilters,
52 ProposalResponse,
53 )
54
55
56 # ─────────────────────────────────────────────────────────────────────────────
57 # Helpers
58 # ─────────────────────────────────────────────────────────────────────────────
59
60
61 def _now() -> datetime:
62 return datetime.now(tz=timezone.utc)
63
64
65 def _uid() -> str:
66 return short_id(blob_id(os.urandom(16)), strip=True)
67
68
69 def _oid(label: int | str) -> str:
70 return fake_id(str(label))
71
72
73 # ─────────────────────────────────────────────────────────────────────────────
74 # Tier 1 — model shape
75 # ─────────────────────────────────────────────────────────────────────────────
76
77
78 class TestProposalResponseShape:
79 def test_blocked_by_defaults_empty(self) -> None:
80 r = ProposalResponse(
81 proposal_id=fake_id("p1"),
82 proposal_number=1,
83 title="t",
84 body="",
85 state="open",
86 from_branch="feat/x",
87 to_branch="dev",
88 created_at=_now(),
89 )
90 assert r.blocked_by == []
91 assert r.blocks == []
92 assert r.is_blocked is False
93
94 def test_latest_simulations_defaults_empty(self) -> None:
95 r = ProposalResponse(
96 proposal_id=fake_id("p1"),
97 proposal_number=1,
98 title="t",
99 body="",
100 state="open",
101 from_branch="feat/x",
102 to_branch="dev",
103 created_at=_now(),
104 )
105 assert r.latest_simulations == {}
106
107
108 class TestProposalListEntryShape:
109 def _entry(self, **kwargs: typing.Any) -> ProposalListEntry:
110 defaults = dict(
111 proposal_id=fake_id("le"),
112 proposal_number=1,
113 title="t",
114 state="open",
115 from_branch="feat/x",
116 to_branch="dev",
117 created_at=_now(),
118 )
119 defaults.update(kwargs)
120 return ProposalListEntry(**defaults)
121
122 def test_merge_strategy_defaults_overlay(self) -> None:
123 e = self._entry()
124 assert e.merge_strategy == "overlay"
125
126 def test_simulation_conflict_count_defaults_none(self) -> None:
127 e = self._entry()
128 assert e.simulation_conflict_count is None
129
130 def test_merge_strategy_round_trips(self) -> None:
131 e = self._entry(merge_strategy="weave")
132 assert e.merge_strategy == "weave"
133
134
135 class TestProposalListFiltersShape:
136 def test_is_draft_defaults_none(self) -> None:
137 f = ProposalListFilters()
138 assert f.is_draft is None
139
140 def test_merge_strategy_defaults_none(self) -> None:
141 f = ProposalListFilters()
142 assert f.merge_strategy is None
143
144 def test_is_draft_true_accepted(self) -> None:
145 f = ProposalListFilters(is_draft=True)
146 assert f.is_draft is True
147
148 def test_merge_strategy_list_accepted(self) -> None:
149 f = ProposalListFilters(merge_strategy=["weave", "phased"])
150 assert f.merge_strategy == ["weave", "phased"]
151
152
153 # ─────────────────────────────────────────────────────────────────────────────
154 # Integration helpers
155 # ─────────────────────────────────────────────────────────────────────────────
156
157
158 async def _make_repo(session: AsyncSession) -> str:
159 from musehub.core.genesis import compute_identity_id, compute_repo_id
160 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
161 from musehub.db.musehub_social_models import MusehubProposal
162
163 owner = "p5test"
164 slug = f"p5-{_uid()}"
165 owner_id = compute_identity_id(owner.encode())
166 created_at = _now()
167 repo = MusehubRepo(
168 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
169 name=slug,
170 owner=owner,
171 slug=slug,
172 visibility="public",
173 owner_user_id=owner_id,
174 description="",
175 tags=[],
176 created_at=created_at,
177 )
178 session.add(repo)
179 await session.flush()
180 return repo.repo_id
181
182
183 async def _make_branch_with_commit(
184 session: AsyncSession,
185 repo_id: str,
186 branch_name: str,
187 manifest: StrDict,
188 ) -> str:
189 from musehub.core.genesis import compute_branch_id
190 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
191 from musehub.db.musehub_social_models import MusehubProposal
192 from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
193 from musehub.services.musehub_snapshot import upsert_snapshot_entries
194
195 created_at = _now()
196 snapshot_id = compute_snapshot_id(manifest)
197 await upsert_snapshot_entries(session, repo_id, snapshot_id, manifest)
198 commit_id = compute_commit_id(
199 [], snapshot_id, f"init {branch_name}", created_at.isoformat(),
200 author="p5test", signer_public_key="",
201 )
202 commit = MusehubCommit(
203 commit_id=commit_id,
204 branch=branch_name,
205 parent_ids=[],
206 message=f"init {branch_name}",
207 author="p5test",
208 timestamp=created_at,
209 snapshot_id=snapshot_id,
210 )
211 session.add(commit)
212 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
213 branch = MusehubBranch(
214 branch_id=compute_branch_id(repo_id, branch_name),
215 repo_id=repo_id,
216 name=branch_name,
217 head_commit_id=commit_id,
218 )
219 session.add(branch)
220 await session.flush()
221 return commit_id
222
223
224 async def _make_proposal(
225 session: AsyncSession,
226 repo_id: str,
227 *,
228 from_branch: str = "feat/x",
229 to_branch: str = "dev",
230 proposal_type: str = "state_merge",
231 is_draft: bool = False,
232 merge_strategy: str = "overlay",
233 ) -> str:
234 from musehub.services.musehub_proposals import create_proposal
235 p = await create_proposal(
236 session,
237 repo_id=repo_id,
238 title="p5 proposal",
239 from_branch=from_branch,
240 to_branch=to_branch,
241 author="p5test",
242 author_identity_id=fake_id("p5-identity"),
243 proposal_type=proposal_type,
244 is_draft=is_draft,
245 merge_strategy=merge_strategy,
246 )
247 return p.proposal_id
248
249
250 # ─────────────────────────────────────────────────────────────────────────────
251 # Tier 5 — get_proposal enrichment
252 # ─────────────────────────────────────────────────────────────────────────────
253
254
255 class TestGetProposalEnrichment:
256 @pytest.mark.asyncio
257 async def test_blocked_by_populated_when_dep_exists(
258 self, db_session: AsyncSession
259 ) -> None:
260 from musehub.services.musehub_proposals import get_proposal
261 from musehub.services.proposal_dag import create_dependency_edges
262
263 repo_id = await _make_repo(db_session)
264 await _make_branch_with_commit(db_session, repo_id, "dev", {})
265 await _make_branch_with_commit(db_session, repo_id, "feat/dep", {})
266 await _make_branch_with_commit(db_session, repo_id, "feat/main", {})
267
268 dep_id = await _make_proposal(
269 db_session, repo_id, from_branch="feat/dep", to_branch="dev"
270 )
271 main_id = await _make_proposal(
272 db_session, repo_id, from_branch="feat/main", to_branch="dev"
273 )
274 await create_dependency_edges(db_session, main_id, [dep_id])
275
276 result = await get_proposal(db_session, repo_id, main_id)
277 assert result is not None
278 assert result.is_blocked is True
279 assert len(result.blocked_by) == 1
280
281 @pytest.mark.asyncio
282 async def test_is_blocked_false_when_dep_merged(
283 self, db_session: AsyncSession
284 ) -> None:
285 from musehub.services.musehub_proposals import get_proposal
286 from musehub.services.proposal_dag import create_dependency_edges
287 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
288 from musehub.db.musehub_social_models import MusehubProposal
289
290 repo_id = await _make_repo(db_session)
291 await _make_branch_with_commit(db_session, repo_id, "dev", {})
292 await _make_branch_with_commit(db_session, repo_id, "feat/dep", {})
293 await _make_branch_with_commit(db_session, repo_id, "feat/main", {})
294
295 dep_id = await _make_proposal(
296 db_session, repo_id, from_branch="feat/dep", to_branch="dev"
297 )
298 main_id = await _make_proposal(
299 db_session, repo_id, from_branch="feat/main", to_branch="dev"
300 )
301 await create_dependency_edges(db_session, main_id, [dep_id])
302
303 # Mark dep as merged
304 dep_row = await db_session.get(MusehubProposal, dep_id)
305 assert dep_row is not None
306 dep_row.state = "merged"
307 await db_session.flush()
308
309 result = await get_proposal(db_session, repo_id, main_id)
310 assert result is not None
311 assert result.is_blocked is False
312 assert result.blocked_by == []
313
314 @pytest.mark.asyncio
315 async def test_latest_simulations_populated_after_run(
316 self, db_session: AsyncSession
317 ) -> None:
318 from musehub.services.musehub_proposals import get_proposal, run_simulation
319
320 repo_id = await _make_repo(db_session)
321 await _make_branch_with_commit(db_session, repo_id, "dev", {"a.py": _oid(1)})
322 await _make_branch_with_commit(
323 db_session, repo_id, "feat/x", {"a.py": _oid(2)}
324 )
325 proposal_id = await _make_proposal(db_session, repo_id)
326 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
327
328 result = await get_proposal(db_session, repo_id, proposal_id)
329 assert result is not None
330 assert "conflict_scan" in result.latest_simulations
331 assert "conflict_count" in result.latest_simulations["conflict_scan"]["result"]
332
333 @pytest.mark.asyncio
334 async def test_latest_simulations_empty_before_run(
335 self, db_session: AsyncSession
336 ) -> None:
337 from musehub.services.musehub_proposals import get_proposal
338
339 repo_id = await _make_repo(db_session)
340 await _make_branch_with_commit(db_session, repo_id, "dev", {})
341 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
342 proposal_id = await _make_proposal(db_session, repo_id)
343
344 result = await get_proposal(db_session, repo_id, proposal_id)
345 assert result is not None
346 assert result.latest_simulations == {}
347
348
349 # ─────────────────────────────────────────────────────────────────────────────
350 # Tier 5 — list_proposals filters
351 # ─────────────────────────────────────────────────────────────────────────────
352
353
354 class TestListProposalsFilters:
355 @pytest.mark.asyncio
356 async def test_proposal_type_filter(self, db_session: AsyncSession) -> None:
357 from musehub.services.musehub_proposals import list_proposals
358
359 repo_id = await _make_repo(db_session)
360 await _make_branch_with_commit(db_session, repo_id, "dev", {})
361 await _make_branch_with_commit(db_session, repo_id, "feat/a", {})
362 await _make_branch_with_commit(db_session, repo_id, "feat/b", {})
363
364 await _make_proposal(
365 db_session, repo_id,
366 from_branch="feat/a", proposal_type="midi_evolution"
367 )
368 await _make_proposal(
369 db_session, repo_id,
370 from_branch="feat/b", proposal_type="state_merge"
371 )
372
373 resp = await list_proposals(
374 db_session, repo_id,
375 filters=ProposalListFilters(proposal_type=["midi_evolution"], state="all"),
376 )
377 assert resp.total == 1
378 assert resp.proposals[0].proposal_type.value == "midi_evolution"
379
380 @pytest.mark.asyncio
381 async def test_is_draft_true_filter(self, db_session: AsyncSession) -> None:
382 from musehub.services.musehub_proposals import list_proposals
383
384 repo_id = await _make_repo(db_session)
385 await _make_branch_with_commit(db_session, repo_id, "dev", {})
386 await _make_branch_with_commit(db_session, repo_id, "feat/a", {})
387 await _make_branch_with_commit(db_session, repo_id, "feat/b", {})
388
389 await _make_proposal(db_session, repo_id, from_branch="feat/a", is_draft=True)
390 await _make_proposal(db_session, repo_id, from_branch="feat/b", is_draft=False)
391
392 resp = await list_proposals(
393 db_session, repo_id,
394 filters=ProposalListFilters(is_draft=True, state="all"),
395 )
396 assert resp.total == 1
397 assert resp.proposals[0].is_draft is True
398
399 @pytest.mark.asyncio
400 async def test_is_draft_false_filter(self, db_session: AsyncSession) -> None:
401 from musehub.services.musehub_proposals import list_proposals
402
403 repo_id = await _make_repo(db_session)
404 await _make_branch_with_commit(db_session, repo_id, "dev", {})
405 await _make_branch_with_commit(db_session, repo_id, "feat/a", {})
406 await _make_branch_with_commit(db_session, repo_id, "feat/b", {})
407
408 await _make_proposal(db_session, repo_id, from_branch="feat/a", is_draft=True)
409 await _make_proposal(db_session, repo_id, from_branch="feat/b", is_draft=False)
410
411 resp = await list_proposals(
412 db_session, repo_id,
413 filters=ProposalListFilters(is_draft=False, state="all"),
414 )
415 assert resp.total == 1
416 assert resp.proposals[0].is_draft is False
417
418 @pytest.mark.asyncio
419 async def test_merge_strategy_filter(self, db_session: AsyncSession) -> None:
420 from musehub.services.musehub_proposals import list_proposals
421
422 repo_id = await _make_repo(db_session)
423 await _make_branch_with_commit(db_session, repo_id, "dev", {})
424 await _make_branch_with_commit(db_session, repo_id, "feat/a", {})
425 await _make_branch_with_commit(db_session, repo_id, "feat/b", {})
426
427 await _make_proposal(
428 db_session, repo_id, from_branch="feat/a", merge_strategy="weave"
429 )
430 await _make_proposal(
431 db_session, repo_id, from_branch="feat/b", merge_strategy="overlay"
432 )
433
434 resp = await list_proposals(
435 db_session, repo_id,
436 filters=ProposalListFilters(merge_strategy=["weave"], state="all"),
437 )
438 assert resp.total == 1
439
440
441 # ─────────────────────────────────────────────────────────────────────────────
442 # Tier 5 — _enrich_one multi-domain risk
443 # ─────────────────────────────────────────────────────────────────────────────
444
445
446 class TestEnrichOneMultiDomainRisk:
447 @pytest.mark.asyncio
448 async def test_dimensional_risk_drives_domain_risk(
449 self, db_session: AsyncSession
450 ) -> None:
451 from musehub.services.musehub_proposals import (
452 enrich_proposal_list_batch,
453 list_proposals,
454 )
455 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
456 from musehub.db.musehub_social_models import MusehubProposal
457
458 repo_id = await _make_repo(db_session)
459 await _make_branch_with_commit(db_session, repo_id, "dev", {})
460 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
461 proposal_id = await _make_proposal(db_session, repo_id)
462
463 # Manually set dimensional_risk on the ORM row
464 row = await db_session.get(MusehubProposal, proposal_id)
465 assert row is not None
466 row.dimensional_risk = {"code": 0.6, "midi": 0.3}
467 await db_session.flush()
468
469 # Fetch the row and enrich it
470 rows = list(
471 (
472 await db_session.execute(
473 __import__("sqlalchemy").select(MusehubProposal).where(
474 MusehubProposal.proposal_id == proposal_id
475 )
476 )
477 ).scalars()
478 )
479 entries = await enrich_proposal_list_batch(rows, db_session)
480 assert len(entries) == 1
481 entry = entries[0]
482 assert "code" in entry.domain_risk
483 assert "midi" in entry.domain_risk
484 assert entry.domain_risk["code"] == pytest.approx(0.6)
485 assert entry.domain_risk["midi"] == pytest.approx(0.3)
486 assert "code" in entry.active_domains
487 assert "midi" in entry.active_domains
488
489 @pytest.mark.asyncio
490 async def test_aggregate_risk_uses_weighted_mean(
491 self, db_session: AsyncSession
492 ) -> None:
493 from musehub.services.musehub_proposals import enrich_proposal_list_batch
494 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
495 from musehub.db.musehub_social_models import MusehubProposal
496
497 repo_id = await _make_repo(db_session)
498 await _make_branch_with_commit(db_session, repo_id, "dev", {})
499 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
500 proposal_id = await _make_proposal(db_session, repo_id)
501
502 row = await db_session.get(MusehubProposal, proposal_id)
503 assert row is not None
504 row.dimensional_risk = {"code": 0.8}
505 await db_session.flush()
506
507 rows = list(
508 (
509 await db_session.execute(
510 __import__("sqlalchemy").select(MusehubProposal).where(
511 MusehubProposal.proposal_id == proposal_id
512 )
513 )
514 ).scalars()
515 )
516 entries = await enrich_proposal_list_batch(rows, db_session)
517 assert entries[0].aggregate_risk_score > 0.0
518 assert entries[0].aggregate_risk_band in ("critical", "high", "medium", "low")
519
520
521 # ─────────────────────────────────────────────────────────────────────────────
522 # Tier 5 — simulation_conflict_count in list batch
523 # ─────────────────────────────────────────────────────────────────────────────
524
525
526 class TestSimulationConflictCountInBatch:
527 @pytest.mark.asyncio
528 async def test_conflict_count_none_before_run(
529 self, db_session: AsyncSession
530 ) -> None:
531 from musehub.services.musehub_proposals import enrich_proposal_list_batch
532 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
533 from musehub.db.musehub_social_models import MusehubProposal
534
535 repo_id = await _make_repo(db_session)
536 await _make_branch_with_commit(db_session, repo_id, "dev", {})
537 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
538 proposal_id = await _make_proposal(db_session, repo_id)
539
540 rows = list(
541 (
542 await db_session.execute(
543 __import__("sqlalchemy").select(MusehubProposal).where(
544 MusehubProposal.proposal_id == proposal_id
545 )
546 )
547 ).scalars()
548 )
549 entries = await enrich_proposal_list_batch(rows, db_session)
550 assert entries[0].simulation_conflict_count is None
551
552 @pytest.mark.asyncio
553 async def test_conflict_count_populated_after_run(
554 self, db_session: AsyncSession
555 ) -> None:
556 from musehub.services.musehub_proposals import (
557 enrich_proposal_list_batch,
558 run_simulation,
559 )
560 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
561 from musehub.db.musehub_social_models import MusehubProposal
562
563 repo_id = await _make_repo(db_session)
564 await _make_branch_with_commit(db_session, repo_id, "dev", {"a.py": _oid(1)})
565 await _make_branch_with_commit(
566 db_session, repo_id, "feat/x", {"a.py": _oid(2)}
567 )
568 proposal_id = await _make_proposal(db_session, repo_id)
569 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
570
571 rows = list(
572 (
573 await db_session.execute(
574 __import__("sqlalchemy").select(MusehubProposal).where(
575 MusehubProposal.proposal_id == proposal_id
576 )
577 )
578 ).scalars()
579 )
580 entries = await enrich_proposal_list_batch(rows, db_session)
581 assert entries[0].simulation_conflict_count is not None
582 assert isinstance(entries[0].simulation_conflict_count, int)
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 10 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago