gabriel / musehub public
test_proposal_reimagination_phase4.py python
640 lines 26.7 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
1 """Phase 4 — Simulation Engine tests (issue #37).
2
3 Tier 1 — Unit (pure, no DB)
4 simulate_conflict_scan:
5 - no conflicts when branches don't share files
6 - conflicts surfaced when both sides changed the same file (with ancestor)
7 - conflicts_by_domain groups paths correctly
8 - files_added / files_modified / files_removed counts forwarded from strategy
9 - strategy_used reflects the chosen strategy
10
11 simulate_risk_projection:
12 - zero risk when nothing changes
13 - change ratio drives risk_band upward
14 - conflict files increase conflict_ratio component
15 - existing dimensional_risk factored in at 20% weight
16 - risk_delta positive when post-merge risk exceeds pre-merge
17 - risk_delta negative when merge reduces per-domain risk (edge case)
18 - domains_affected lists only changed domains
19
20 simulate_dependency_order:
21 - linear chain yields correct order
22 - two independent nodes appear in phase 1
23 - node with one dep lands in phase 2
24 - cycle detected returns cycle_detected=True and cycle_ids
25 - merged deps excluded from blocking (live-edge filtering)
26
27 Tier 2 — genesis
28 compute_simulation_id:
29 - deterministic for same inputs
30 - different simulation_type produces different ID
31 - different from_branch_commit_id produces different ID
32
33 Tier 5 — Integration (DB)
34 run_simulation:
35 - conflict_scan runs and returns SimulationResponse
36 - risk_projection runs and returns SimulationResponse
37 - dependency_order runs and returns SimulationResponse
38 - re-running updates the cached row (upsert semantics)
39 - unknown simulation_type raises ValueError
40
41 get_simulation:
42 - returns None when not yet run
43 - returns cached result after run
44 - is_stale=True when from_branch head advances
45
46 list_simulations:
47 - empty list before any simulations run
48 - all three types listed after running each
49 """
50
51 from __future__ import annotations
52
53 import os
54 from datetime import datetime, timezone
55 from typing import Any
56
57 import pytest
58 from muse.core.types import blob_id, fake_id, short_id
59 from sqlalchemy.ext.asyncio import AsyncSession
60
61 from musehub.types.json_types import StrDict
62 from musehub.services.proposal_simulation import (
63 simulate_conflict_scan,
64 simulate_dependency_order,
65 simulate_risk_projection,
66 )
67 from musehub.services.proposal_dag import ProposalDag
68
69
70 # ─────────────────────────────────────────────────────────────────────────────
71 # Helpers
72 # ─────────────────────────────────────────────────────────────────────────────
73
74
75 def _now() -> datetime:
76 return datetime.now(tz=timezone.utc)
77
78
79 def _uid() -> str:
80 return short_id(blob_id(os.urandom(16)), strip=True)
81
82
83 def _oid(label: int | str) -> str:
84 return fake_id(str(label))
85
86
87 def _dag(
88 *,
89 depends_on: dict[str, set[str]] | None = None,
90 merged_ids: set[str] | None = None,
91 ) -> ProposalDag:
92 dep_input = depends_on or {}
93 merged = merged_ids or set()
94 nodes: set[str] = set()
95 for pid, deps in dep_input.items():
96 nodes.add(pid)
97 nodes.update(deps)
98 # Every node must be a key in depends_on (dependency nodes have empty sets)
99 dep: dict[str, set[str]] = {n: set() for n in nodes}
100 dep.update(dep_input)
101 required_by: dict[str, set[str]] = {}
102 for pid, deps in dep.items():
103 for d in deps:
104 required_by.setdefault(d, set()).add(pid)
105 return ProposalDag(
106 depends_on=dep,
107 required_by=required_by,
108 nodes=nodes,
109 merged_ids=merged,
110 number_by_id={n: i for i, n in enumerate(sorted(nodes))},
111 )
112
113
114 # ─────────────────────────────────────────────────────────────────────────────
115 # Tier 1 — simulate_conflict_scan
116 # ─────────────────────────────────────────────────────────────────────────────
117
118
119 class TestConflictScan:
120 def test_no_conflicts_when_disjoint_files(self) -> None:
121 to = {"a.py": _oid(1)}
122 frm = {"b.py": _oid(2)}
123 r = simulate_conflict_scan(to, frm)
124 assert r["conflict_count"] == 0
125 assert r["conflicting_files"] == []
126
127 def test_conflicts_surfaced_with_ancestor(self) -> None:
128 anc = {"shared.py": _oid(0)}
129 to = {"shared.py": _oid(1)}
130 frm = {"shared.py": _oid(2)}
131 r = simulate_conflict_scan(to, frm, ancestor_manifest=anc)
132 assert r["conflict_count"] == 1
133 assert "shared.py" in r["conflicting_files"]
134
135 def test_no_conflict_when_only_one_side_changed(self) -> None:
136 anc = {"shared.py": _oid(0), "other.py": _oid(1)}
137 to = {"shared.py": _oid(0), "other.py": _oid(2)} # shared unchanged
138 frm = {"shared.py": _oid(3)} # from changed shared
139 r = simulate_conflict_scan(to, frm, ancestor_manifest=anc)
140 assert r["conflict_count"] == 0
141
142 def test_conflicts_by_domain_groups_paths(self) -> None:
143 anc = {"src/a.py": _oid(0), "tracks/b.mid": _oid(1)}
144 to = {"src/a.py": _oid(2), "tracks/b.mid": _oid(3)}
145 frm = {"src/a.py": _oid(4), "tracks/b.mid": _oid(5)}
146 r = simulate_conflict_scan(to, frm, ancestor_manifest=anc)
147 assert r["conflict_count"] == 2
148 assert "code" in r["conflicts_by_domain"]
149 assert "midi" in r["conflicts_by_domain"]
150 assert "src/a.py" in r["conflicts_by_domain"]["code"]
151 assert "tracks/b.mid" in r["conflicts_by_domain"]["midi"]
152
153 def test_files_added_count(self) -> None:
154 to = {"a.py": _oid(1)}
155 frm = {"a.py": _oid(2), "new.py": _oid(3)} # new.py added
156 r = simulate_conflict_scan(to, frm)
157 assert r["files_added"] == 1
158
159 def test_strategy_used_reflected(self) -> None:
160 r = simulate_conflict_scan({}, {"x.py": _oid(1)}, strategy="overlay")
161 assert r["strategy_used"] == "overlay"
162
163 def test_domains_affected_lists_changed_domains(self) -> None:
164 frm = {"tracks/beat.mid": _oid(1), "src/main.py": _oid(2)}
165 r = simulate_conflict_scan({}, frm)
166 assert "midi" in r["domains_affected"]
167 assert "code" in r["domains_affected"]
168
169 def test_empty_manifests_zero_conflicts(self) -> None:
170 r = simulate_conflict_scan({}, {})
171 assert r["conflict_count"] == 0
172 assert r["files_added"] == 0
173 assert r["files_modified"] == 0
174 assert r["files_removed"] == 0
175
176
177 # ─────────────────────────────────────────────────────────────────────────────
178 # Tier 1 — simulate_risk_projection
179 # ─────────────────────────────────────────────────────────────────────────────
180
181
182 class TestRiskProjection:
183 def test_zero_risk_when_nothing_changes(self) -> None:
184 manifest = {"a.py": _oid(1)}
185 r = simulate_risk_projection(manifest, manifest)
186 assert r["overall_projected_risk"] == 0.0
187 assert r["risk_band"] == "low"
188 assert r["files_changed_count"] == 0
189
190 def test_risk_band_low_for_small_change(self) -> None:
191 to = {f"f{i}.py": _oid(i) for i in range(20)}
192 frm = dict(to)
193 frm["f0.py"] = _oid(99) # one file changed
194 r = simulate_risk_projection(to, frm)
195 assert r["risk_band"] == "low"
196
197 def test_risk_band_increases_with_conflicts(self) -> None:
198 anc = {"a.py": _oid(0)}
199 to = {"a.py": _oid(1)}
200 frm = {"a.py": _oid(2)}
201 r = simulate_risk_projection(to, frm, ancestor_manifest=anc)
202 # conflict_ratio = 1.0 → risk component is 0.4 * change_ratio + 0.4 * 1.0
203 # at minimum the conflict component pushes risk above 0
204 assert r["overall_projected_risk"] > 0
205
206 def test_existing_risk_factored_in(self) -> None:
207 to = {"a.py": _oid(1)}
208 frm = {"a.py": _oid(2)}
209 r_no_prior = simulate_risk_projection(to, frm)
210 r_with_prior = simulate_risk_projection(
211 to, frm, current_dimensional_risk={"code": 0.9}
212 )
213 # Adding existing high risk should increase or maintain overall projected risk
214 assert r_with_prior["overall_projected_risk"] >= r_no_prior["overall_projected_risk"]
215
216 def test_risk_delta_positive_when_risk_increases(self) -> None:
217 to = {"a.py": _oid(1)}
218 frm = {"a.py": _oid(2)}
219 r = simulate_risk_projection(to, frm, current_dimensional_risk={"code": 0.0})
220 # Changing a file with zero prior risk → delta > 0
221 assert r["risk_delta"] >= 0.0
222
223 def test_domains_affected_populated(self) -> None:
224 to = {"src/main.py": _oid(1)}
225 frm = {"src/main.py": _oid(2), "tracks/beat.mid": _oid(3)}
226 r = simulate_risk_projection(to, frm)
227 assert "code" in r["domains_affected"]
228 assert "midi" in r["domains_affected"]
229
230 def test_files_changed_count(self) -> None:
231 to = {"a.py": _oid(1), "b.py": _oid(2)}
232 frm = {"a.py": _oid(3), "b.py": _oid(2)} # only a changed
233 r = simulate_risk_projection(to, frm)
234 assert r["files_changed_count"] == 1
235
236 def test_conflict_count_matches_merge_result(self) -> None:
237 anc = {"a.py": _oid(0), "b.py": _oid(1)}
238 to = {"a.py": _oid(2), "b.py": _oid(3)}
239 frm = {"a.py": _oid(4), "b.py": _oid(5)}
240 r = simulate_risk_projection(to, frm, ancestor_manifest=anc)
241 assert r["conflict_count"] == 2
242
243
244 # ─────────────────────────────────────────────────────────────────────────────
245 # Tier 1 — simulate_dependency_order
246 # ─────────────────────────────────────────────────────────────────────────────
247
248
249 class TestDependencyOrder:
250 def test_single_node_no_deps(self) -> None:
251 dag = _dag(depends_on={"p1": set()})
252 r = simulate_dependency_order(dag)
253 assert r["cycle_detected"] is False
254 assert r["node_count"] == 1
255 assert "p1" in r["order"]
256
257 def test_linear_chain_correct_order(self) -> None:
258 # p3 depends on p2, p2 depends on p1
259 dag = _dag(depends_on={"p2": {"p1"}, "p3": {"p2"}})
260 r = simulate_dependency_order(dag)
261 assert r["cycle_detected"] is False
262 order = r["order"]
263 assert order.index("p1") < order.index("p2") < order.index("p3")
264
265 def test_two_independent_nodes_in_phase_1(self) -> None:
266 dag = _dag(depends_on={"p1": set(), "p2": set()})
267 r = simulate_dependency_order(dag)
268 assert len(r["phases"]) == 1
269 assert set(r["phases"][0]) == {"p1", "p2"}
270
271 def test_node_with_dep_in_phase_2(self) -> None:
272 dag = _dag(depends_on={"p1": set(), "p2": {"p1"}})
273 r = simulate_dependency_order(dag)
274 assert len(r["phases"]) == 2
275 assert "p1" in r["phases"][0]
276 assert "p2" in r["phases"][1]
277
278 def test_cycle_detected(self) -> None:
279 # p1 → p2 → p1
280 dag = _dag(depends_on={"p1": {"p2"}, "p2": {"p1"}})
281 r = simulate_dependency_order(dag)
282 assert r["cycle_detected"] is True
283 assert len(r["cycle_ids"]) > 0
284 assert r["order"] == []
285 assert r["phases"] == []
286
287 def test_merged_deps_excluded_from_blocking(self) -> None:
288 # p2 depends on p1; p1 is already merged → p2 has no live blocks
289 dag = _dag(depends_on={"p2": {"p1"}}, merged_ids={"p1"})
290 r = simulate_dependency_order(dag)
291 assert r["cycle_detected"] is False
292 # p2 is the only unmerged node
293 assert "p2" in r["order"]
294
295 def test_phase_count_equals_len_phases(self) -> None:
296 dag = _dag(depends_on={"p1": set(), "p2": {"p1"}, "p3": {"p2"}})
297 r = simulate_dependency_order(dag)
298 assert r["phase_count"] == len(r["phases"])
299
300 def test_empty_dag_returns_empty(self) -> None:
301 dag = ProposalDag(
302 depends_on={},
303 required_by={},
304 nodes=set(),
305 merged_ids=set(),
306 number_by_id={},
307 )
308 r = simulate_dependency_order(dag)
309 assert r["order"] == []
310 assert r["phases"] == []
311 assert r["cycle_detected"] is False
312
313
314 # ─────────────────────────────────────────────────────────────────────────────
315 # Tier 2 — compute_simulation_id
316 # ─────────────────────────────────────────────────────────────────────────────
317
318
319 class TestComputeSimulationId:
320 def test_deterministic(self) -> None:
321 from musehub.core.genesis import compute_simulation_id
322 a = compute_simulation_id("prop-1", "conflict_scan", "sha256:abc")
323 b = compute_simulation_id("prop-1", "conflict_scan", "sha256:abc")
324 assert a == b
325
326 def test_different_type_different_id(self) -> None:
327 from musehub.core.genesis import compute_simulation_id
328 a = compute_simulation_id("prop-1", "conflict_scan", "sha256:abc")
329 b = compute_simulation_id("prop-1", "risk_projection", "sha256:abc")
330 assert a != b
331
332 def test_different_commit_different_id(self) -> None:
333 from musehub.core.genesis import compute_simulation_id
334 a = compute_simulation_id("prop-1", "conflict_scan", "sha256:aaa")
335 b = compute_simulation_id("prop-1", "conflict_scan", "sha256:bbb")
336 assert a != b
337
338 def test_sha256_prefixed(self) -> None:
339 from musehub.core.genesis import compute_simulation_id
340 sid = compute_simulation_id("prop-1", "conflict_scan", "sha256:abc")
341 assert sid.startswith("sha256:")
342
343
344 # ─────────────────────────────────────────────────────────────────────────────
345 # Tier 5 — Integration (DB)
346 # ─────────────────────────────────────────────────────────────────────────────
347
348
349 async def _make_repo(session: AsyncSession) -> str:
350 from musehub.core.genesis import compute_identity_id, compute_repo_id
351 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
352 from musehub.db.musehub_social_models import MusehubProposalSimulation
353
354 owner = "simtest"
355 slug = f"sim-{_uid()}"
356 owner_id = compute_identity_id(owner.encode())
357 created_at = _now()
358 repo = MusehubRepo(
359 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
360 name=slug,
361 owner=owner,
362 slug=slug,
363 visibility="public",
364 owner_user_id=owner_id,
365 description="",
366 tags=[],
367 created_at=created_at,
368 )
369 session.add(repo)
370 await session.flush()
371 return repo.repo_id
372
373
374 async def _make_branch_with_commit(
375 session: AsyncSession,
376 repo_id: str,
377 branch_name: str,
378 manifest: StrDict,
379 ) -> str:
380 """Create branch + commit + snapshot. Returns commit_id."""
381 from musehub.core.genesis import compute_branch_id, compute_identity_id
382 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
383 from musehub.db.musehub_social_models import MusehubProposalSimulation
384 from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
385 from musehub.services.musehub_snapshot import upsert_snapshot_entries
386
387 created_at = _now()
388 snapshot_id = compute_snapshot_id(manifest)
389 await upsert_snapshot_entries(session, repo_id, snapshot_id, manifest)
390
391 commit_id = compute_commit_id(
392 [], snapshot_id, f"init {branch_name}", created_at.isoformat(),
393 author="simtest", signer_public_key="",
394 )
395 commit = MusehubCommit(
396 commit_id=commit_id,
397 branch=branch_name,
398 parent_ids=[],
399 message=f"init {branch_name}",
400 author="simtest",
401 timestamp=created_at,
402 snapshot_id=snapshot_id,
403 )
404 session.add(commit)
405 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
406
407 branch = MusehubBranch(
408 branch_id=compute_branch_id(repo_id, branch_name),
409 repo_id=repo_id,
410 name=branch_name,
411 head_commit_id=commit_id,
412 )
413 session.add(branch)
414 await session.flush()
415 return commit_id
416
417
418 async def _make_proposal(
419 session: AsyncSession,
420 repo_id: str,
421 from_branch: str = "feat/x",
422 to_branch: str = "dev",
423 ) -> str:
424 """Create a proposal; returns proposal_id."""
425 from musehub.services.musehub_proposals import create_proposal
426 p = await create_proposal(
427 session,
428 repo_id=repo_id,
429 title="sim test proposal",
430 from_branch=from_branch,
431 to_branch=to_branch,
432 author="simtest",
433 author_identity_id=fake_id("simtest-identity"),
434 )
435 return p.proposal_id
436
437
438 class TestRunSimulation:
439 @pytest.mark.asyncio
440 async def test_conflict_scan_runs(self, db_session: AsyncSession) -> None:
441 from musehub.services.musehub_proposals import run_simulation
442
443 repo_id = await _make_repo(db_session)
444 await _make_branch_with_commit(
445 db_session, repo_id, "dev", {"a.py": _oid(1)}
446 )
447 await _make_branch_with_commit(
448 db_session, repo_id, "feat/x", {"a.py": _oid(2), "b.py": _oid(3)}
449 )
450 proposal_id = await _make_proposal(db_session, repo_id)
451
452 result = await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
453
454 assert result.simulation_type == "conflict_scan"
455 assert result.proposal_id == proposal_id
456 assert "conflict_count" in result.result
457 assert result.is_stale is False
458 assert result.simulation_id.startswith("sha256:")
459
460 @pytest.mark.asyncio
461 async def test_risk_projection_runs(self, db_session: AsyncSession) -> None:
462 from musehub.services.musehub_proposals import run_simulation
463
464 repo_id = await _make_repo(db_session)
465 await _make_branch_with_commit(db_session, repo_id, "dev", {"a.py": _oid(1)})
466 await _make_branch_with_commit(
467 db_session, repo_id, "feat/x", {"a.py": _oid(2)}
468 )
469 proposal_id = await _make_proposal(db_session, repo_id)
470
471 result = await run_simulation(db_session, repo_id, proposal_id, "risk_projection")
472
473 assert result.simulation_type == "risk_projection"
474 assert "overall_projected_risk" in result.result
475 assert "risk_band" in result.result
476
477 @pytest.mark.asyncio
478 async def test_dependency_order_runs(self, db_session: AsyncSession) -> None:
479 from musehub.services.musehub_proposals import run_simulation
480
481 repo_id = await _make_repo(db_session)
482 await _make_branch_with_commit(db_session, repo_id, "dev", {})
483 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
484 proposal_id = await _make_proposal(db_session, repo_id)
485
486 result = await run_simulation(db_session, repo_id, proposal_id, "dependency_order")
487
488 assert result.simulation_type == "dependency_order"
489 assert "order" in result.result
490 assert "phases" in result.result
491 assert result.result["cycle_detected"] is False
492
493 @pytest.mark.asyncio
494 async def test_rerun_upserts_row(self, db_session: AsyncSession) -> None:
495 from musehub.services.musehub_proposals import run_simulation
496 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
497 from musehub.db.musehub_social_models import MusehubProposalSimulation
498 from sqlalchemy import select, func
499
500 repo_id = await _make_repo(db_session)
501 await _make_branch_with_commit(db_session, repo_id, "dev", {})
502 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
503 proposal_id = await _make_proposal(db_session, repo_id)
504
505 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
506 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
507
508 count = (
509 await db_session.execute(
510 select(func.count()).select_from(MusehubProposalSimulation).where(
511 MusehubProposalSimulation.proposal_id == proposal_id,
512 MusehubProposalSimulation.simulation_type == "conflict_scan",
513 )
514 )
515 ).scalar_one()
516 assert count == 1 # exactly one row — upsert not insert
517
518 @pytest.mark.asyncio
519 async def test_unknown_type_raises(self, db_session: AsyncSession) -> None:
520 from musehub.services.musehub_proposals import run_simulation
521
522 repo_id = await _make_repo(db_session)
523 await _make_branch_with_commit(db_session, repo_id, "dev", {})
524 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
525 proposal_id = await _make_proposal(db_session, repo_id)
526
527 with pytest.raises(ValueError, match="Unknown simulation_type"):
528 await run_simulation(db_session, repo_id, proposal_id, "not_a_type")
529
530
531 class TestGetSimulation:
532 @pytest.mark.asyncio
533 async def test_returns_none_before_run(self, db_session: AsyncSession) -> None:
534 from musehub.services.musehub_proposals import get_simulation
535
536 repo_id = await _make_repo(db_session)
537 await _make_branch_with_commit(db_session, repo_id, "dev", {})
538 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
539 proposal_id = await _make_proposal(db_session, repo_id)
540
541 result = await get_simulation(db_session, repo_id, proposal_id, "conflict_scan")
542 assert result is None
543
544 @pytest.mark.asyncio
545 async def test_returns_cached_after_run(self, db_session: AsyncSession) -> None:
546 from musehub.services.musehub_proposals import get_simulation, run_simulation
547
548 repo_id = await _make_repo(db_session)
549 await _make_branch_with_commit(db_session, repo_id, "dev", {})
550 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
551 proposal_id = await _make_proposal(db_session, repo_id)
552
553 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
554 result = await get_simulation(db_session, repo_id, proposal_id, "conflict_scan")
555
556 assert result is not None
557 assert result.simulation_type == "conflict_scan"
558 assert result.is_stale is False
559
560 @pytest.mark.asyncio
561 async def test_is_stale_when_branch_advances(self, db_session: AsyncSession) -> None:
562 from musehub.services.musehub_proposals import get_simulation, run_simulation
563 from musehub.core.genesis import compute_branch_id
564 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
565 from musehub.db.musehub_social_models import MusehubProposalSimulation
566 from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
567 from musehub.services.musehub_snapshot import upsert_snapshot_entries
568
569 repo_id = await _make_repo(db_session)
570 await _make_branch_with_commit(db_session, repo_id, "dev", {})
571 await _make_branch_with_commit(
572 db_session, repo_id, "feat/x", {"a.py": _oid(1)}
573 )
574 proposal_id = await _make_proposal(db_session, repo_id)
575
576 # Run simulation with initial commit
577 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
578
579 # Advance from_branch to a new commit
580 new_manifest = {"a.py": _oid(99), "extra.py": _oid(100)}
581 new_snapshot = compute_snapshot_id(new_manifest)
582 await upsert_snapshot_entries(db_session, repo_id, new_snapshot, new_manifest)
583 new_commit_id = compute_commit_id(
584 [], new_snapshot, "advance", _now().isoformat(),
585 author="simtest", signer_public_key="",
586 )
587 new_commit = MusehubCommit(
588 commit_id=new_commit_id,
589 branch="feat/x",
590 parent_ids=[],
591 message="advance",
592 author="simtest",
593 timestamp=_now(),
594 snapshot_id=new_snapshot,
595 )
596 db_session.add(new_commit)
597 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=new_commit_id))
598
599 # Update branch head
600 branch_id = compute_branch_id(repo_id, "feat/x")
601 branch_row = await db_session.get(MusehubBranch, branch_id)
602 assert branch_row is not None
603 branch_row.head_commit_id = new_commit_id
604 await db_session.flush()
605
606 result = await get_simulation(db_session, repo_id, proposal_id, "conflict_scan")
607 assert result is not None
608 assert result.is_stale is True
609
610
611 class TestListSimulations:
612 @pytest.mark.asyncio
613 async def test_empty_before_any_run(self, db_session: AsyncSession) -> None:
614 from musehub.services.musehub_proposals import list_simulations
615
616 repo_id = await _make_repo(db_session)
617 await _make_branch_with_commit(db_session, repo_id, "dev", {})
618 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
619 proposal_id = await _make_proposal(db_session, repo_id)
620
621 resp = await list_simulations(db_session, repo_id, proposal_id)
622 assert resp.total == 0
623 assert resp.simulations == []
624
625 @pytest.mark.asyncio
626 async def test_all_three_types_listed(self, db_session: AsyncSession) -> None:
627 from musehub.services.musehub_proposals import list_simulations, run_simulation
628
629 repo_id = await _make_repo(db_session)
630 await _make_branch_with_commit(db_session, repo_id, "dev", {})
631 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
632 proposal_id = await _make_proposal(db_session, repo_id)
633
634 for sim_type in ("conflict_scan", "risk_projection", "dependency_order"):
635 await run_simulation(db_session, repo_id, proposal_id, sim_type)
636
637 resp = await list_simulations(db_session, repo_id, proposal_id)
638 assert resp.total == 3
639 types_returned = {s.simulation_type for s in resp.simulations}
640 assert types_returned == {"conflict_scan", "risk_projection", "dependency_order"}
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 9 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago