gabriel / musehub public
test_proposal_reimagination_phase4.py python
643 lines 27.0 KB
Raw
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor ⚠ breaking 10 days 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 # The default "overlay" strategy auto-resolves (prefer_theirs); surfacing a
132 # three-way conflict requires an escalate strategy ("recursive").
133 r = simulate_conflict_scan(to, frm, ancestor_manifest=anc, strategy="recursive")
134 assert r["conflict_count"] == 1
135 assert "shared.py" in r["conflicting_files"]
136
137 def test_no_conflict_when_only_one_side_changed(self) -> None:
138 anc = {"shared.py": _oid(0), "other.py": _oid(1)}
139 to = {"shared.py": _oid(0), "other.py": _oid(2)} # shared unchanged
140 frm = {"shared.py": _oid(3)} # from changed shared
141 r = simulate_conflict_scan(to, frm, ancestor_manifest=anc)
142 assert r["conflict_count"] == 0
143
144 def test_conflicts_by_domain_groups_paths(self) -> None:
145 anc = {"src/a.py": _oid(0), "tracks/b.mid": _oid(1)}
146 to = {"src/a.py": _oid(2), "tracks/b.mid": _oid(3)}
147 frm = {"src/a.py": _oid(4), "tracks/b.mid": _oid(5)}
148 r = simulate_conflict_scan(to, frm, ancestor_manifest=anc, strategy="recursive")
149 assert r["conflict_count"] == 2
150 assert "code" in r["conflicts_by_domain"]
151 assert "midi" in r["conflicts_by_domain"]
152 assert "src/a.py" in r["conflicts_by_domain"]["code"]
153 assert "tracks/b.mid" in r["conflicts_by_domain"]["midi"]
154
155 def test_files_added_count(self) -> None:
156 to = {"a.py": _oid(1)}
157 frm = {"a.py": _oid(2), "new.py": _oid(3)} # new.py added
158 r = simulate_conflict_scan(to, frm)
159 assert r["files_added"] == 1
160
161 def test_strategy_used_reflected(self) -> None:
162 r = simulate_conflict_scan({}, {"x.py": _oid(1)}, strategy="overlay")
163 assert r["strategy_used"] == "overlay"
164
165 def test_domains_affected_lists_changed_domains(self) -> None:
166 frm = {"tracks/beat.mid": _oid(1), "src/main.py": _oid(2)}
167 r = simulate_conflict_scan({}, frm)
168 assert "midi" in r["domains_affected"]
169 assert "code" in r["domains_affected"]
170
171 def test_empty_manifests_zero_conflicts(self) -> None:
172 r = simulate_conflict_scan({}, {})
173 assert r["conflict_count"] == 0
174 assert r["files_added"] == 0
175 assert r["files_modified"] == 0
176 assert r["files_removed"] == 0
177
178
179 # ─────────────────────────────────────────────────────────────────────────────
180 # Tier 1 — simulate_risk_projection
181 # ─────────────────────────────────────────────────────────────────────────────
182
183
184 class TestRiskProjection:
185 def test_zero_risk_when_nothing_changes(self) -> None:
186 manifest = {"a.py": _oid(1)}
187 r = simulate_risk_projection(manifest, manifest)
188 assert r["overall_projected_risk"] == 0.0
189 assert r["risk_band"] == "low"
190 assert r["files_changed_count"] == 0
191
192 def test_risk_band_low_for_small_change(self) -> None:
193 to = {f"f{i}.py": _oid(i) for i in range(20)}
194 frm = dict(to)
195 frm["f0.py"] = _oid(99) # one file changed
196 r = simulate_risk_projection(to, frm)
197 assert r["risk_band"] == "low"
198
199 def test_risk_band_increases_with_conflicts(self) -> None:
200 anc = {"a.py": _oid(0)}
201 to = {"a.py": _oid(1)}
202 frm = {"a.py": _oid(2)}
203 r = simulate_risk_projection(to, frm, ancestor_manifest=anc)
204 # conflict_ratio = 1.0 → risk component is 0.4 * change_ratio + 0.4 * 1.0
205 # at minimum the conflict component pushes risk above 0
206 assert r["overall_projected_risk"] > 0
207
208 def test_existing_risk_factored_in(self) -> None:
209 to = {"a.py": _oid(1)}
210 frm = {"a.py": _oid(2)}
211 r_no_prior = simulate_risk_projection(to, frm)
212 r_with_prior = simulate_risk_projection(
213 to, frm, current_dimensional_risk={"code": 0.9}
214 )
215 # Adding existing high risk should increase or maintain overall projected risk
216 assert r_with_prior["overall_projected_risk"] >= r_no_prior["overall_projected_risk"]
217
218 def test_risk_delta_positive_when_risk_increases(self) -> None:
219 to = {"a.py": _oid(1)}
220 frm = {"a.py": _oid(2)}
221 r = simulate_risk_projection(to, frm, current_dimensional_risk={"code": 0.0})
222 # Changing a file with zero prior risk → delta > 0
223 assert r["risk_delta"] >= 0.0
224
225 def test_domains_affected_populated(self) -> None:
226 to = {"src/main.py": _oid(1)}
227 frm = {"src/main.py": _oid(2), "tracks/beat.mid": _oid(3)}
228 r = simulate_risk_projection(to, frm)
229 assert "code" in r["domains_affected"]
230 assert "midi" in r["domains_affected"]
231
232 def test_files_changed_count(self) -> None:
233 to = {"a.py": _oid(1), "b.py": _oid(2)}
234 frm = {"a.py": _oid(3), "b.py": _oid(2)} # only a changed
235 r = simulate_risk_projection(to, frm)
236 assert r["files_changed_count"] == 1
237
238 def test_conflict_count_matches_merge_result(self) -> None:
239 anc = {"a.py": _oid(0), "b.py": _oid(1)}
240 to = {"a.py": _oid(2), "b.py": _oid(3)}
241 frm = {"a.py": _oid(4), "b.py": _oid(5)}
242 # Conflicts only surface under an escalate strategy (overlay prefers theirs).
243 r = simulate_risk_projection(to, frm, ancestor_manifest=anc, strategy="recursive")
244 assert r["conflict_count"] == 2
245
246
247 # ─────────────────────────────────────────────────────────────────────────────
248 # Tier 1 — simulate_dependency_order
249 # ─────────────────────────────────────────────────────────────────────────────
250
251
252 class TestDependencyOrder:
253 def test_single_node_no_deps(self) -> None:
254 dag = _dag(depends_on={"p1": set()})
255 r = simulate_dependency_order(dag)
256 assert r["cycle_detected"] is False
257 assert r["node_count"] == 1
258 assert "p1" in r["order"]
259
260 def test_linear_chain_correct_order(self) -> None:
261 # p3 depends on p2, p2 depends on p1
262 dag = _dag(depends_on={"p2": {"p1"}, "p3": {"p2"}})
263 r = simulate_dependency_order(dag)
264 assert r["cycle_detected"] is False
265 order = r["order"]
266 assert order.index("p1") < order.index("p2") < order.index("p3")
267
268 def test_two_independent_nodes_in_phase_1(self) -> None:
269 dag = _dag(depends_on={"p1": set(), "p2": set()})
270 r = simulate_dependency_order(dag)
271 assert len(r["phases"]) == 1
272 assert set(r["phases"][0]) == {"p1", "p2"}
273
274 def test_node_with_dep_in_phase_2(self) -> None:
275 dag = _dag(depends_on={"p1": set(), "p2": {"p1"}})
276 r = simulate_dependency_order(dag)
277 assert len(r["phases"]) == 2
278 assert "p1" in r["phases"][0]
279 assert "p2" in r["phases"][1]
280
281 def test_cycle_detected(self) -> None:
282 # p1 → p2 → p1
283 dag = _dag(depends_on={"p1": {"p2"}, "p2": {"p1"}})
284 r = simulate_dependency_order(dag)
285 assert r["cycle_detected"] is True
286 assert len(r["cycle_ids"]) > 0
287 assert r["order"] == []
288 assert r["phases"] == []
289
290 def test_merged_deps_excluded_from_blocking(self) -> None:
291 # p2 depends on p1; p1 is already merged → p2 has no live blocks
292 dag = _dag(depends_on={"p2": {"p1"}}, merged_ids={"p1"})
293 r = simulate_dependency_order(dag)
294 assert r["cycle_detected"] is False
295 # p2 is the only unmerged node
296 assert "p2" in r["order"]
297
298 def test_phase_count_equals_len_phases(self) -> None:
299 dag = _dag(depends_on={"p1": set(), "p2": {"p1"}, "p3": {"p2"}})
300 r = simulate_dependency_order(dag)
301 assert r["phase_count"] == len(r["phases"])
302
303 def test_empty_dag_returns_empty(self) -> None:
304 dag = ProposalDag(
305 depends_on={},
306 required_by={},
307 nodes=set(),
308 merged_ids=set(),
309 number_by_id={},
310 )
311 r = simulate_dependency_order(dag)
312 assert r["order"] == []
313 assert r["phases"] == []
314 assert r["cycle_detected"] is False
315
316
317 # ─────────────────────────────────────────────────────────────────────────────
318 # Tier 2 — compute_simulation_id
319 # ─────────────────────────────────────────────────────────────────────────────
320
321
322 class TestComputeSimulationId:
323 def test_deterministic(self) -> None:
324 from musehub.core.genesis import compute_simulation_id
325 a = compute_simulation_id("prop-1", "conflict_scan", "sha256:abc")
326 b = compute_simulation_id("prop-1", "conflict_scan", "sha256:abc")
327 assert a == b
328
329 def test_different_type_different_id(self) -> None:
330 from musehub.core.genesis import compute_simulation_id
331 a = compute_simulation_id("prop-1", "conflict_scan", "sha256:abc")
332 b = compute_simulation_id("prop-1", "risk_projection", "sha256:abc")
333 assert a != b
334
335 def test_different_commit_different_id(self) -> None:
336 from musehub.core.genesis import compute_simulation_id
337 a = compute_simulation_id("prop-1", "conflict_scan", "sha256:aaa")
338 b = compute_simulation_id("prop-1", "conflict_scan", "sha256:bbb")
339 assert a != b
340
341 def test_sha256_prefixed(self) -> None:
342 from musehub.core.genesis import compute_simulation_id
343 sid = compute_simulation_id("prop-1", "conflict_scan", "sha256:abc")
344 assert sid.startswith("sha256:")
345
346
347 # ─────────────────────────────────────────────────────────────────────────────
348 # Tier 5 — Integration (DB)
349 # ─────────────────────────────────────────────────────────────────────────────
350
351
352 async def _make_repo(session: AsyncSession) -> str:
353 from musehub.core.genesis import compute_identity_id, compute_repo_id
354 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
355 from musehub.db.musehub_social_models import MusehubProposalSimulation
356
357 owner = "simtest"
358 slug = f"sim-{_uid()}"
359 owner_id = compute_identity_id(owner.encode())
360 created_at = _now()
361 repo = MusehubRepo(
362 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
363 name=slug,
364 owner=owner,
365 slug=slug,
366 visibility="public",
367 owner_user_id=owner_id,
368 description="",
369 tags=[],
370 created_at=created_at,
371 )
372 session.add(repo)
373 await session.flush()
374 return repo.repo_id
375
376
377 async def _make_branch_with_commit(
378 session: AsyncSession,
379 repo_id: str,
380 branch_name: str,
381 manifest: StrDict,
382 ) -> str:
383 """Create branch + commit + snapshot. Returns commit_id."""
384 from musehub.core.genesis import compute_branch_id, compute_identity_id
385 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
386 from musehub.db.musehub_social_models import MusehubProposalSimulation
387 from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
388 from musehub.services.musehub_snapshot import upsert_snapshot_entries
389
390 created_at = _now()
391 snapshot_id = compute_snapshot_id(manifest)
392 await upsert_snapshot_entries(session, repo_id, snapshot_id, manifest)
393
394 commit_id = compute_commit_id(
395 [], snapshot_id, f"init {branch_name}", created_at.isoformat(),
396 author="simtest", signer_public_key="",
397 )
398 commit = MusehubCommit(
399 commit_id=commit_id,
400 branch=branch_name,
401 parent_ids=[],
402 message=f"init {branch_name}",
403 author="simtest",
404 timestamp=created_at,
405 snapshot_id=snapshot_id,
406 )
407 session.add(commit)
408 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
409
410 branch = MusehubBranch(
411 branch_id=compute_branch_id(repo_id, branch_name),
412 repo_id=repo_id,
413 name=branch_name,
414 head_commit_id=commit_id,
415 )
416 session.add(branch)
417 await session.flush()
418 return commit_id
419
420
421 async def _make_proposal(
422 session: AsyncSession,
423 repo_id: str,
424 from_branch: str = "feat/x",
425 to_branch: str = "dev",
426 ) -> str:
427 """Create a proposal; returns proposal_id."""
428 from musehub.services.musehub_proposals import create_proposal
429 p = await create_proposal(
430 session,
431 repo_id=repo_id,
432 title="sim test proposal",
433 from_branch=from_branch,
434 to_branch=to_branch,
435 author="simtest",
436 author_identity_id=fake_id("simtest-identity"),
437 )
438 return p.proposal_id
439
440
441 class TestRunSimulation:
442 @pytest.mark.asyncio
443 async def test_conflict_scan_runs(self, db_session: AsyncSession) -> None:
444 from musehub.services.musehub_proposals import run_simulation
445
446 repo_id = await _make_repo(db_session)
447 await _make_branch_with_commit(
448 db_session, repo_id, "dev", {"a.py": _oid(1)}
449 )
450 await _make_branch_with_commit(
451 db_session, repo_id, "feat/x", {"a.py": _oid(2), "b.py": _oid(3)}
452 )
453 proposal_id = await _make_proposal(db_session, repo_id)
454
455 result = await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
456
457 assert result.simulation_type == "conflict_scan"
458 assert result.proposal_id == proposal_id
459 assert "conflict_count" in result.result
460 assert result.is_stale is False
461 assert result.simulation_id.startswith("sha256:")
462
463 @pytest.mark.asyncio
464 async def test_risk_projection_runs(self, db_session: AsyncSession) -> None:
465 from musehub.services.musehub_proposals import run_simulation
466
467 repo_id = await _make_repo(db_session)
468 await _make_branch_with_commit(db_session, repo_id, "dev", {"a.py": _oid(1)})
469 await _make_branch_with_commit(
470 db_session, repo_id, "feat/x", {"a.py": _oid(2)}
471 )
472 proposal_id = await _make_proposal(db_session, repo_id)
473
474 result = await run_simulation(db_session, repo_id, proposal_id, "risk_projection")
475
476 assert result.simulation_type == "risk_projection"
477 assert "overall_projected_risk" in result.result
478 assert "risk_band" in result.result
479
480 @pytest.mark.asyncio
481 async def test_dependency_order_runs(self, db_session: AsyncSession) -> None:
482 from musehub.services.musehub_proposals import run_simulation
483
484 repo_id = await _make_repo(db_session)
485 await _make_branch_with_commit(db_session, repo_id, "dev", {})
486 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
487 proposal_id = await _make_proposal(db_session, repo_id)
488
489 result = await run_simulation(db_session, repo_id, proposal_id, "dependency_order")
490
491 assert result.simulation_type == "dependency_order"
492 assert "order" in result.result
493 assert "phases" in result.result
494 assert result.result["cycle_detected"] is False
495
496 @pytest.mark.asyncio
497 async def test_rerun_upserts_row(self, db_session: AsyncSession) -> None:
498 from musehub.services.musehub_proposals import run_simulation
499 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubRepo
500 from musehub.db.musehub_social_models import MusehubProposalSimulation
501 from sqlalchemy import select, func
502
503 repo_id = await _make_repo(db_session)
504 await _make_branch_with_commit(db_session, repo_id, "dev", {})
505 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
506 proposal_id = await _make_proposal(db_session, repo_id)
507
508 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
509 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
510
511 count = (
512 await db_session.execute(
513 select(func.count()).select_from(MusehubProposalSimulation).where(
514 MusehubProposalSimulation.proposal_id == proposal_id,
515 MusehubProposalSimulation.simulation_type == "conflict_scan",
516 )
517 )
518 ).scalar_one()
519 assert count == 1 # exactly one row — upsert not insert
520
521 @pytest.mark.asyncio
522 async def test_unknown_type_raises(self, db_session: AsyncSession) -> None:
523 from musehub.services.musehub_proposals import run_simulation
524
525 repo_id = await _make_repo(db_session)
526 await _make_branch_with_commit(db_session, repo_id, "dev", {})
527 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
528 proposal_id = await _make_proposal(db_session, repo_id)
529
530 with pytest.raises(ValueError, match="Unknown simulation_type"):
531 await run_simulation(db_session, repo_id, proposal_id, "not_a_type")
532
533
534 class TestGetSimulation:
535 @pytest.mark.asyncio
536 async def test_returns_none_before_run(self, db_session: AsyncSession) -> None:
537 from musehub.services.musehub_proposals import get_simulation
538
539 repo_id = await _make_repo(db_session)
540 await _make_branch_with_commit(db_session, repo_id, "dev", {})
541 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
542 proposal_id = await _make_proposal(db_session, repo_id)
543
544 result = await get_simulation(db_session, repo_id, proposal_id, "conflict_scan")
545 assert result is None
546
547 @pytest.mark.asyncio
548 async def test_returns_cached_after_run(self, db_session: AsyncSession) -> None:
549 from musehub.services.musehub_proposals import get_simulation, run_simulation
550
551 repo_id = await _make_repo(db_session)
552 await _make_branch_with_commit(db_session, repo_id, "dev", {})
553 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
554 proposal_id = await _make_proposal(db_session, repo_id)
555
556 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
557 result = await get_simulation(db_session, repo_id, proposal_id, "conflict_scan")
558
559 assert result is not None
560 assert result.simulation_type == "conflict_scan"
561 assert result.is_stale is False
562
563 @pytest.mark.asyncio
564 async def test_is_stale_when_branch_advances(self, db_session: AsyncSession) -> None:
565 from musehub.services.musehub_proposals import get_simulation, run_simulation
566 from musehub.core.genesis import compute_branch_id
567 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
568 from musehub.db.musehub_social_models import MusehubProposalSimulation
569 from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
570 from musehub.services.musehub_snapshot import upsert_snapshot_entries
571
572 repo_id = await _make_repo(db_session)
573 await _make_branch_with_commit(db_session, repo_id, "dev", {})
574 await _make_branch_with_commit(
575 db_session, repo_id, "feat/x", {"a.py": _oid(1)}
576 )
577 proposal_id = await _make_proposal(db_session, repo_id)
578
579 # Run simulation with initial commit
580 await run_simulation(db_session, repo_id, proposal_id, "conflict_scan")
581
582 # Advance from_branch to a new commit
583 new_manifest = {"a.py": _oid(99), "extra.py": _oid(100)}
584 new_snapshot = compute_snapshot_id(new_manifest)
585 await upsert_snapshot_entries(db_session, repo_id, new_snapshot, new_manifest)
586 new_commit_id = compute_commit_id(
587 [], new_snapshot, "advance", _now().isoformat(),
588 author="simtest", signer_public_key="",
589 )
590 new_commit = MusehubCommit(
591 commit_id=new_commit_id,
592 branch="feat/x",
593 parent_ids=[],
594 message="advance",
595 author="simtest",
596 timestamp=_now(),
597 snapshot_id=new_snapshot,
598 )
599 db_session.add(new_commit)
600 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=new_commit_id))
601
602 # Update branch head
603 branch_id = compute_branch_id(repo_id, "feat/x")
604 branch_row = await db_session.get(MusehubBranch, branch_id)
605 assert branch_row is not None
606 branch_row.head_commit_id = new_commit_id
607 await db_session.flush()
608
609 result = await get_simulation(db_session, repo_id, proposal_id, "conflict_scan")
610 assert result is not None
611 assert result.is_stale is True
612
613
614 class TestListSimulations:
615 @pytest.mark.asyncio
616 async def test_empty_before_any_run(self, db_session: AsyncSession) -> None:
617 from musehub.services.musehub_proposals import list_simulations
618
619 repo_id = await _make_repo(db_session)
620 await _make_branch_with_commit(db_session, repo_id, "dev", {})
621 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
622 proposal_id = await _make_proposal(db_session, repo_id)
623
624 resp = await list_simulations(db_session, repo_id, proposal_id)
625 assert resp.total == 0
626 assert resp.simulations == []
627
628 @pytest.mark.asyncio
629 async def test_all_three_types_listed(self, db_session: AsyncSession) -> None:
630 from musehub.services.musehub_proposals import list_simulations, run_simulation
631
632 repo_id = await _make_repo(db_session)
633 await _make_branch_with_commit(db_session, repo_id, "dev", {})
634 await _make_branch_with_commit(db_session, repo_id, "feat/x", {})
635 proposal_id = await _make_proposal(db_session, repo_id)
636
637 for sim_type in ("conflict_scan", "risk_projection", "dependency_order"):
638 await run_simulation(db_session, repo_id, proposal_id, sim_type)
639
640 resp = await list_simulations(db_session, repo_id, proposal_id)
641 assert resp.total == 3
642 types_returned = {s.simulation_type for s in resp.simulations}
643 assert types_returned == {"conflict_scan", "risk_projection", "dependency_order"}
File History 1 commit
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor 10 days ago