gabriel / musehub public
test_proposal_detail_delta_ssr.py python
408 lines 14.6 KB
Raw
sha256:3c58668648c7323bb9f5c6881cfe6a3f14fc93fcb73b537d253732952a5bf8bf chore: bump version to 0.2.0rc12 Sonnet 4.6 patch 8 days ago
1 """TDD: Proposal detail page renders the symbol delta server-side.
2
3 The main column tells a coherent story: change fingerprint (ratio bar) →
4 file-grouped symbol delta → commits timeline. All rendered in the initial HTML,
5 no client-side JS required to see what changed.
6
7 Covers GET /{owner}/{repo_slug}/proposals/{proposal_id}:
8 test_delta_section_present_in_html
9 test_added_symbol_name_rendered_server_side
10 test_modified_symbol_name_rendered_server_side
11 test_deleted_symbol_name_rendered_server_side
12 test_file_path_rendered_as_group_header
13 test_sym_counts_rendered_server_side
14 test_ratio_bar_present_when_symbols_exist
15 test_breaking_change_marker_rendered_inline
16 test_empty_delta_renders_empty_state
17 test_op_sigil_present_for_each_entry
18 test_symbol_link_points_to_symbols_page
19 test_multiple_files_each_rendered_as_group
20 test_added_deleted_cancelled_not_rendered
21 """
22 from __future__ import annotations
23
24 from datetime import datetime, timezone
25
26 import pytest
27 from httpx import AsyncClient
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from muse.core.types import fake_id, now_utc_iso
31 from musehub.core.genesis import compute_identity_id, compute_proposal_id, compute_repo_id
32 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo
33 from musehub.db.musehub_social_models import MusehubProposal
34 from musehub.types.json_types import JSONObject
35
36
37 # ---------------------------------------------------------------------------
38 # Seed helpers
39 # ---------------------------------------------------------------------------
40
41
42 async def _make_repo(db: AsyncSession, slug: str = "delta-ssr-repo") -> str:
43 created_at = datetime.now(tz=timezone.utc)
44 owner_id = compute_identity_id(b"deltadev")
45 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
46 repo = MusehubRepo(
47 repo_id=repo_id,
48 name=slug,
49 owner="deltadev",
50 slug=slug,
51 visibility="public",
52 owner_user_id=owner_id,
53 created_at=created_at,
54 updated_at=created_at,
55 )
56 db.add(repo)
57 await db.commit()
58 await db.refresh(repo)
59 return str(repo.repo_id)
60
61
62 async def _make_proposal(
63 db: AsyncSession,
64 repo_id: str,
65 *,
66 from_branch: str = "feat/test",
67 to_branch: str = "main",
68 title: str = "Test proposal",
69 ) -> MusehubProposal:
70 author_id = compute_identity_id(b"deltadev")
71 proposal = MusehubProposal(
72 proposal_id=compute_proposal_id(repo_id, author_id, from_branch, to_branch, now_utc_iso()),
73 repo_id=repo_id,
74 proposal_number=1,
75 title=title,
76 body="",
77 state="open",
78 from_branch=from_branch,
79 to_branch=to_branch,
80 author="deltadev",
81 )
82 db.add(proposal)
83 await db.commit()
84 await db.refresh(proposal)
85 return proposal
86
87
88 async def _make_commit(
89 db: AsyncSession,
90 repo_id: str,
91 *,
92 branch: str,
93 structured_delta: JSONObject | None = None,
94 breaking_changes: list[str] | None = None,
95 message: str = "feat: update symbols",
96 ) -> str:
97 cid = fake_id(now_utc_iso())
98 row = MusehubCommit(
99 commit_id=cid,
100 branch=branch,
101 parent_ids=[],
102 message=message,
103 author="deltadev",
104 timestamp=datetime.now(timezone.utc),
105 structured_delta=structured_delta,
106 breaking_changes=breaking_changes or [],
107 )
108 db.add(row)
109 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=cid))
110 await db.commit()
111 return cid
112
113
114 def _delta_with(*ops: tuple[str, str]) -> JSONObject:
115 """Build a structured_delta from (op_type, address) pairs."""
116 by_file: dict[str, list[dict]] = {}
117 for op_type, address in ops:
118 file_path = address.split("::")[0]
119 by_file.setdefault(file_path, []).append({"op": op_type, "address": address})
120 return {
121 "ops": [
122 {"address": fp, "child_ops": cops}
123 for fp, cops in by_file.items()
124 ]
125 }
126
127
128 # ---------------------------------------------------------------------------
129 # Tests
130 # ---------------------------------------------------------------------------
131
132
133 async def test_delta_section_present_in_html(
134 client: AsyncClient,
135 db_session: AsyncSession,
136 ) -> None:
137 """The Change Map / Symbol Delta section is present in the rendered HTML."""
138 repo_id = await _make_repo(db_session, "delta-section")
139 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-section")
140 await _make_commit(db_session, repo_id, branch="feat/delta-section",
141 structured_delta=_delta_with(("insert", "src/a.py::Fn")))
142
143 resp = await client.get(f"/deltadev/delta-section/proposals/{proposal.proposal_id}")
144 assert resp.status_code == 200
145 # The delta section heading must be present server-side.
146 assert "Symbol Delta" in resp.text or "Change Map" in resp.text
147
148
149 async def test_added_symbol_name_rendered_server_side(
150 client: AsyncClient,
151 db_session: AsyncSession,
152 ) -> None:
153 """An inserted symbol's name appears in the HTML without client JS."""
154 repo_id = await _make_repo(db_session, "delta-added")
155 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-added")
156 await _make_commit(
157 db_session, repo_id,
158 branch="feat/delta-added",
159 structured_delta=_delta_with(("insert", "src/billing.py::compute_invoice_total")),
160 )
161
162 resp = await client.get(f"/deltadev/delta-added/proposals/{proposal.proposal_id}")
163 assert resp.status_code == 200
164 assert "compute_invoice_total" in resp.text
165
166
167 async def test_modified_symbol_name_rendered_server_side(
168 client: AsyncClient,
169 db_session: AsyncSession,
170 ) -> None:
171 """A replaced symbol's name appears in the HTML."""
172 repo_id = await _make_repo(db_session, "delta-modified")
173 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-modified")
174 await _make_commit(
175 db_session, repo_id,
176 branch="feat/delta-modified",
177 structured_delta=_delta_with(("replace", "src/auth.py::validate_session")),
178 )
179
180 resp = await client.get(f"/deltadev/delta-modified/proposals/{proposal.proposal_id}")
181 assert resp.status_code == 200
182 assert "validate_session" in resp.text
183
184
185 async def test_deleted_symbol_name_rendered_server_side(
186 client: AsyncClient,
187 db_session: AsyncSession,
188 ) -> None:
189 """A deleted symbol's name appears in the HTML."""
190 repo_id = await _make_repo(db_session, "delta-deleted")
191 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-deleted")
192 await _make_commit(
193 db_session, repo_id,
194 branch="feat/delta-deleted",
195 structured_delta=_delta_with(("delete", "src/legacy.py::old_compute")),
196 )
197
198 resp = await client.get(f"/deltadev/delta-deleted/proposals/{proposal.proposal_id}")
199 assert resp.status_code == 200
200 assert "old_compute" in resp.text
201
202
203 async def test_file_path_rendered_as_group_header(
204 client: AsyncClient,
205 db_session: AsyncSession,
206 ) -> None:
207 """The file path appears as a group header in the delta section."""
208 repo_id = await _make_repo(db_session, "delta-file-header")
209 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-file-header")
210 await _make_commit(
211 db_session, repo_id,
212 branch="feat/delta-file-header",
213 structured_delta=_delta_with(("insert", "musehub/services/billing.py::process_payment")),
214 )
215
216 resp = await client.get(f"/deltadev/delta-file-header/proposals/{proposal.proposal_id}")
217 assert resp.status_code == 200
218 assert "musehub/services/billing.py" in resp.text
219
220
221 async def test_sym_counts_rendered_server_side(
222 client: AsyncClient,
223 db_session: AsyncSession,
224 ) -> None:
225 """Added/modified/deleted symbol counts appear as numbers in the HTML."""
226 repo_id = await _make_repo(db_session, "delta-counts")
227 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-counts")
228 await _make_commit(
229 db_session, repo_id,
230 branch="feat/delta-counts",
231 structured_delta=_delta_with(
232 ("insert", "src/a.py::NewFn"),
233 ("replace", "src/b.py::ChangedFn"),
234 ("delete", "src/c.py::OldFn"),
235 ),
236 )
237
238 resp = await client.get(f"/deltadev/delta-counts/proposals/{proposal.proposal_id}")
239 assert resp.status_code == 200
240 body = resp.text
241 # Each bucket count of 1 must appear somewhere in the HTML.
242 assert "1" in body
243
244
245 async def test_ratio_bar_present_when_symbols_exist(
246 client: AsyncClient,
247 db_session: AsyncSession,
248 ) -> None:
249 """The ratio bar element is present in the HTML when the delta is non-empty."""
250 repo_id = await _make_repo(db_session, "delta-ratio")
251 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-ratio")
252 await _make_commit(
253 db_session, repo_id,
254 branch="feat/delta-ratio",
255 structured_delta=_delta_with(("insert", "src/a.py::Fn")),
256 )
257
258 resp = await client.get(f"/deltadev/delta-ratio/proposals/{proposal.proposal_id}")
259 assert resp.status_code == 200
260 # The ratio bar CSS class must be present.
261 assert "prd-delta-ratio" in resp.text
262
263
264 async def test_breaking_change_marker_rendered_inline(
265 client: AsyncClient,
266 db_session: AsyncSession,
267 ) -> None:
268 """A breaking change has its marker rendered inline next to the symbol."""
269 repo_id = await _make_repo(db_session, "delta-breaking")
270 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-breaking")
271 await _make_commit(
272 db_session, repo_id,
273 branch="feat/delta-breaking",
274 structured_delta=_delta_with(("delete", "src/api.py::public_endpoint")),
275 breaking_changes=["src/api.py::public_endpoint"],
276 )
277
278 resp = await client.get(f"/deltadev/delta-breaking/proposals/{proposal.proposal_id}")
279 assert resp.status_code == 200
280 body = resp.text
281 assert "public_endpoint" in body
282 # Breaking marker must appear near the symbol — check for the CSS class.
283 assert "prd-breaking" in body
284
285
286 async def test_empty_delta_renders_empty_state(
287 client: AsyncClient,
288 db_session: AsyncSession,
289 ) -> None:
290 """No commits with deltas → empty state message in the delta section."""
291 repo_id = await _make_repo(db_session, "delta-empty")
292 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-empty")
293 # Commit with no structured_delta.
294 await _make_commit(db_session, repo_id, branch="feat/delta-empty", structured_delta=None)
295
296 resp = await client.get(f"/deltadev/delta-empty/proposals/{proposal.proposal_id}")
297 assert resp.status_code == 200
298 assert "prd-delta-empty" in resp.text
299
300
301 async def test_op_sigil_present_for_each_entry(
302 client: AsyncClient,
303 db_session: AsyncSession,
304 ) -> None:
305 """Each symbol entry has an op sigil element (prd-op-sigil)."""
306 repo_id = await _make_repo(db_session, "delta-sigil")
307 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-sigil")
308 await _make_commit(
309 db_session, repo_id,
310 branch="feat/delta-sigil",
311 structured_delta=_delta_with(("insert", "src/a.py::Fn")),
312 )
313
314 resp = await client.get(f"/deltadev/delta-sigil/proposals/{proposal.proposal_id}")
315 assert resp.status_code == 200
316 assert "prd-op-sigil" in resp.text
317
318
319 async def test_symbol_link_points_to_symbols_page(
320 client: AsyncClient,
321 db_session: AsyncSession,
322 ) -> None:
323 """Each symbol name is a link to /symbols?q=<name>."""
324 repo_id = await _make_repo(db_session, "delta-link")
325 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-link")
326 await _make_commit(
327 db_session, repo_id,
328 branch="feat/delta-link",
329 structured_delta=_delta_with(("insert", "src/a.py::find_user")),
330 )
331
332 resp = await client.get(f"/deltadev/delta-link/proposals/{proposal.proposal_id}")
333 assert resp.status_code == 200
334 assert "symbols?q=find_user" in resp.text
335
336
337 async def test_multiple_files_each_rendered_as_group(
338 client: AsyncClient,
339 db_session: AsyncSession,
340 ) -> None:
341 """When symbols span multiple files, each file gets its own group header."""
342 repo_id = await _make_repo(db_session, "delta-multi-file")
343 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-multi-file")
344 await _make_commit(
345 db_session, repo_id,
346 branch="feat/delta-multi-file",
347 structured_delta=_delta_with(
348 ("insert", "src/billing.py::invoice"),
349 ("replace", "src/auth.py::login"),
350 ),
351 )
352
353 resp = await client.get(f"/deltadev/delta-multi-file/proposals/{proposal.proposal_id}")
354 assert resp.status_code == 200
355 body = resp.text
356 assert "src/billing.py" in body
357 assert "src/auth.py" in body
358
359
360 async def test_added_then_deleted_symbol_not_rendered(
361 client: AsyncClient,
362 db_session: AsyncSession,
363 ) -> None:
364 """A symbol inserted and deleted in the same proposal nets to zero — not rendered."""
365 from datetime import timedelta
366 repo_id = await _make_repo(db_session, "delta-cancel")
367 proposal = await _make_proposal(db_session, repo_id, from_branch="feat/delta-cancel")
368 t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
369 t2 = t1 + timedelta(hours=1)
370
371 cid1 = fake_id("c1-cancel")
372 cid2 = fake_id("c2-cancel")
373 row1 = MusehubCommit(
374 commit_id=cid1,
375 branch="feat/delta-cancel",
376 parent_ids=[],
377 message="add then remove",
378 author="deltadev",
379 timestamp=t1,
380 structured_delta=_delta_with(("insert", "src/x.py::ephemeral_fn")),
381 breaking_changes=[],
382 )
383 row2 = MusehubCommit(
384 commit_id=cid2,
385 branch="feat/delta-cancel",
386 parent_ids=[],
387 message="remove ephemeral",
388 author="deltadev",
389 timestamp=t2,
390 structured_delta=_delta_with(("delete", "src/x.py::ephemeral_fn")),
391 breaking_changes=[],
392 )
393 db_session.add(row1)
394 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=cid1))
395 db_session.add(row2)
396 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=cid2))
397 await db_session.commit()
398
399 resp = await client.get(f"/deltadev/delta-cancel/proposals/{proposal.proposal_id}")
400 assert resp.status_code == 200
401 # Symbol that was added then deleted (net zero) must not appear in delta output.
402 # It may appear in commits timeline, but not in the symbol delta section.
403 # We check via the sigil — if it appeared in the delta it'd have a prd-op-sigil.
404 # The symbol name alone isn't sufficient since it could appear in commit messages.
405 body = resp.text
406 # Count occurrences in the delta section — there should be none with op sigil.
407 # Simplest check: the delta section must report 0 total.
408 assert 'data-sym-total="0"' in body or "prd-delta-empty" in body
File History 1 commit
sha256:8b0fb5814ab41a08af1f212c956bd08fe74190c2818ba5c503848fda6e33e216 chore: bump version to 0.2.0rc12 Sonnet 4.6 patch 8 days ago