gabriel / musehub public
test_merge_strategy_integrity.py python
222 lines 9.5 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 2 days ago
1 """TDD — merge_strategy integrity: never NULL, shown post-merge.
2
3 Covers:
4 Service — create_proposal never stores NULL merge_strategy
5 Service — update_proposal never writes NULL merge_strategy
6 Migration — migration 0070 renames legacy state_* values to canonical names
7 Template — strategy row shown for merged proposals; canonical names only
8 """
9
10 from __future__ import annotations
11
12 import datetime
13 import importlib
14 import pathlib
15 import sys
16 import types
17
18 import pytest
19 import sqlalchemy as sa
20 from sqlalchemy.ext.asyncio import AsyncSession
21
22 from musehub.models.musehub import MergeStrategy
23
24 # ---------------------------------------------------------------------------
25 # Service layer — create_proposal
26 # ---------------------------------------------------------------------------
27
28
29 class TestCreateProposalMergeStrategyDefault:
30
31 @pytest.mark.asyncio
32 async def test_create_defaults_to_overlay(self, db_session: AsyncSession) -> None:
33 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch
34 from musehub.services.musehub_proposals import create_proposal
35
36 repo_id = await _make_repo(db_session)
37 await _make_branch(db_session, repo_id, "feat/x", {})
38 result = await create_proposal(
39 db_session,
40 repo_id=repo_id,
41 title="No strategy given",
42 from_branch="feat/x",
43 to_branch="main",
44 )
45 assert result.merge_strategy == "overlay"
46
47 @pytest.mark.asyncio
48 async def test_create_explicit_strategy_stored(self, db_session: AsyncSession) -> None:
49 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch
50 from musehub.services.musehub_proposals import create_proposal
51
52 repo_id = await _make_repo(db_session)
53 await _make_branch(db_session, repo_id, "feat/y", {})
54 result = await create_proposal(
55 db_session,
56 repo_id=repo_id,
57 title="Explicit strategy",
58 from_branch="feat/y",
59 to_branch="main",
60 merge_strategy="replay",
61 )
62 assert result.merge_strategy == "replay"
63
64 @pytest.mark.asyncio
65 async def test_create_cherry_pick_strategy_stored(self, db_session: AsyncSession) -> None:
66 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch
67 from musehub.services.musehub_proposals import create_proposal
68
69 repo_id = await _make_repo(db_session)
70 await _make_branch(db_session, repo_id, "feat/z", {})
71 result = await create_proposal(
72 db_session,
73 repo_id=repo_id,
74 title="Cherry pick proposal",
75 from_branch="feat/z",
76 to_branch="main",
77 merge_strategy="cherry_pick",
78 )
79 assert result.merge_strategy == "cherry_pick"
80
81
82 # ---------------------------------------------------------------------------
83 # Service layer — update_proposal
84 # ---------------------------------------------------------------------------
85
86
87 class TestUpdateProposalMergeStrategyIntegrity:
88
89 @pytest.mark.asyncio
90 async def test_update_strategy_changes_value(self, db_session: AsyncSession) -> None:
91 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal
92 from musehub.services.musehub_proposals import update_proposal
93
94 repo_id = await _make_repo(db_session)
95 await _make_branch(db_session, repo_id, "feat/a", {})
96 proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/a")
97 updated = await update_proposal(
98 db_session, repo_id, proposal_id, merge_strategy="weave"
99 )
100 assert updated is not None
101 assert updated.merge_strategy == "weave"
102
103 @pytest.mark.asyncio
104 async def test_update_none_strategy_preserves_existing(self, db_session: AsyncSession) -> None:
105 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal
106 from musehub.services.musehub_proposals import update_proposal
107
108 repo_id = await _make_repo(db_session)
109 await _make_branch(db_session, repo_id, "feat/b", {})
110 proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/b", merge_strategy="replay")
111 updated = await update_proposal(
112 db_session, repo_id, proposal_id, merge_strategy=None, title="new title"
113 )
114 assert updated is not None
115 assert updated.merge_strategy == "replay"
116
117 @pytest.mark.asyncio
118 async def test_update_strategy_never_writes_null(self, db_session: AsyncSession) -> None:
119 from tests.test_proposal_reimagination_phase5 import _make_repo, _make_branch_with_commit as _make_branch, _make_proposal
120 from musehub.services import musehub_proposals
121 from musehub.db.musehub_social_models import MusehubProposal
122
123 repo_id = await _make_repo(db_session)
124 await _make_branch(db_session, repo_id, "feat/c", {})
125 proposal_id = await _make_proposal(db_session, repo_id, from_branch="feat/c")
126
127 await musehub_proposals.update_proposal(
128 db_session, repo_id, proposal_id, merge_strategy=None, title="updated"
129 )
130
131 row = (await db_session.execute(
132 sa.select(MusehubProposal).where(
133 MusehubProposal.proposal_id == proposal_id
134 )
135 )).scalar_one()
136 assert row.merge_strategy is not None
137 assert row.merge_strategy == "overlay"
138
139
140 # ---------------------------------------------------------------------------
141 # Migration 0050 — backfill NULLs
142 # ---------------------------------------------------------------------------
143
144
145 class TestMigration0070CanonicalNames:
146
147 def test_migration_file_exists(self) -> None:
148 path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0070_backfill_merge_strategy_canonical.py"
149 assert path.exists(), "Migration 0070_backfill_merge_strategy_canonical.py must exist"
150
151 def test_migration_has_correct_revision(self) -> None:
152 path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0070_backfill_merge_strategy_canonical.py"
153 content = path.read_text()
154 assert 'revision = "0070"' in content
155 assert 'down_revision = "0069"' in content
156
157 def test_migration_renames_all_four_aliases(self) -> None:
158 """Migration upgrade() must UPDATE all four legacy alias values."""
159 path = pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0070_backfill_merge_strategy_canonical.py"
160 content = path.read_text()
161 for old, new in [("state_overlay", "overlay"), ("state_weave", "weave"),
162 ("state_rebase", "replay"), ("domain_selective", "selective")]:
163 assert old in content, f"Migration must handle {old!r}"
164 assert new in content, f"Migration must set {new!r}"
165
166 def test_migration_importable(self) -> None:
167 spec = importlib.util.spec_from_file_location(
168 "migration_0070",
169 pathlib.Path(__file__).parent.parent / "alembic" / "versions" / "0070_backfill_merge_strategy_canonical.py",
170 )
171 assert spec is not None
172 mod = importlib.util.module_from_spec(spec)
173 spec.loader.exec_module(mod) # type: ignore[union-attr]
174 assert hasattr(mod, "upgrade")
175 assert hasattr(mod, "downgrade")
176
177
178 # ---------------------------------------------------------------------------
179 # Template — strategy always shown
180 # ---------------------------------------------------------------------------
181
182
183 class TestStrategyShownPostMerge:
184
185 def _template_source(self) -> str:
186 path = pathlib.Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "pages" / "proposal_detail.html"
187 return path.read_text()
188
189 def test_no_legacy_alias_in_template(self) -> None:
190 source = self._template_source()
191 for alias in ("state_overlay", "state_weave", "state_rebase", "domain_selective"):
192 assert alias not in source, f"Legacy alias {alias!r} must not appear in template"
193
194 def test_strategy_gated_on_merged_state(self) -> None:
195 source = self._template_source()
196 # Strategy display should be conditional on merged state
197 assert "state == 'merged'" in source
198
199 def test_strategy_row_references_merge_strategy(self) -> None:
200 source = self._template_source()
201 assert "proposal.merge_strategy" in source
202
203 def _macro_source(self) -> str:
204 path = pathlib.Path(__file__).parent.parent / "musehub" / "templates" / "musehub" / "fragments" / "proposal_rows.html"
205 return path.read_text()
206
207 def test_strategy_label_macro_covers_overlay(self) -> None:
208 source = self._macro_source()
209 assert "overlay" in source, "strategy_label macro must handle 'overlay'"
210
211 def test_strategy_label_macro_covers_cherry_pick(self) -> None:
212 source = self._macro_source()
213 assert "cherry_pick" in source, "strategy_label macro must handle cherry_pick"
214
215 def test_strategy_label_macro_has_fallback(self) -> None:
216 source = self._macro_source()
217 assert "else" in source, "strategy_label macro must have an else fallback so nothing renders blank"
218
219 def test_no_legacy_aliases_in_macro(self) -> None:
220 source = self._macro_source()
221 for alias in ("state_overlay", "state_weave", "state_rebase", "domain_selective"):
222 assert alias not in source, f"Legacy alias {alias!r} must not appear in macro"
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 2 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 11 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 13 days ago