gabriel / musehub public
test_musehub_ui_issue_detail_ssr.py python
659 lines 19.7 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 144 days ago
1 """Tests for the SSR issue detail page — HTMX SSR + comment threading (issue #568).
2
3 Covers server-side rendering of issue body, comment thread, HTMX fragment
4 responses, status action buttons, sidebar, and 404 handling.
5
6 Test areas:
7 Basic rendering
8 - test_issue_detail_renders_title_server_side
9 - test_issue_detail_unknown_number_404
10
11 SSR body content
12 - test_issue_detail_renders_body_markdown
13 - test_issue_detail_empty_body_shows_placeholder
14
15 Comments
16 - test_issue_detail_renders_comments_server_side
17 - test_issue_detail_no_comments_shows_placeholder
18
19 HTMX attributes
20 - test_issue_detail_comment_form_has_hx_post
21 - test_issue_detail_close_button_has_hx_post
22 - test_issue_detail_reopen_button_has_hx_post
23
24 HTMX fragment
25 - test_issue_detail_htmx_request_returns_comment_fragment
26 """
27 from __future__ import annotations
28
29 import pytest
30 from httpx import AsyncClient
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from datetime import datetime, timezone
34
35 from musehub.core.genesis import compute_comment_id, compute_identity_id, compute_issue_id, compute_release_id, compute_repo_id
36 from musehub.db.musehub_models import MusehubCommit, MusehubIssue, MusehubIssueComment, MusehubRelease, MusehubRepo
37 from musehub.types.json_types import StrDict
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44
45 async def _make_repo(
46 db: AsyncSession,
47 owner: str = "songwriter",
48 slug: str = "melodies",
49 ) -> str:
50 """Seed a public repo and return its repo_id string."""
51 owner_id = compute_identity_id(owner.encode())
52 created_at = datetime.now(tz=timezone.utc)
53 repo = MusehubRepo(
54 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
55 name=slug,
56 owner=owner,
57 slug=slug,
58 visibility="public",
59 owner_user_id=owner_id,
60 created_at=created_at,
61 updated_at=created_at,
62 )
63 db.add(repo)
64 await db.commit()
65 await db.refresh(repo)
66 return str(repo.repo_id)
67
68
69 async def _make_issue(
70 db: AsyncSession,
71 repo_id: str,
72 *,
73 number: int = 1,
74 title: str = "Verse needs a bridge",
75 body: str = "The verse feels incomplete.",
76 state: str = "open",
77 author: str = "songwriter",
78 labels: list[str] | None = None,
79 symbol_anchors: list[str] | None = None,
80 ) -> MusehubIssue:
81 """Seed an issue and return it."""
82 author_id = compute_identity_id(author.encode())
83 now = datetime.now(tz=timezone.utc)
84 issue = MusehubIssue(
85 issue_id=compute_issue_id(repo_id, author_id, now.isoformat()),
86 repo_id=repo_id,
87 number=number,
88 title=title,
89 body=body,
90 state=state,
91 labels=labels or [],
92 symbol_anchors=symbol_anchors or [],
93 author=author,
94 )
95 db.add(issue)
96 await db.commit()
97 await db.refresh(issue)
98 return issue
99
100
101 async def _make_comment(
102 db: AsyncSession,
103 issue_id: str,
104 repo_id: str,
105 *,
106 author: str = "producer",
107 body: str = "Good point.",
108 parent_id: str | None = None,
109 ) -> MusehubIssueComment:
110 """Seed a comment and return it."""
111 author_id = compute_identity_id(author.encode())
112 comment = MusehubIssueComment(
113 comment_id=compute_comment_id(issue_id, author_id, datetime.now(tz=timezone.utc).isoformat()),
114 issue_id=issue_id,
115 repo_id=repo_id,
116 author=author,
117 body=body,
118 parent_id=parent_id,
119 )
120 db.add(comment)
121 await db.commit()
122 await db.refresh(comment)
123 return comment
124
125
126 async def _get_detail(
127 client: AsyncClient,
128 number: int = 1,
129 owner: str = "songwriter",
130 slug: str = "melodies",
131 headers: StrDict | None = None,
132 ) -> tuple[int, str]:
133 """Fetch the issue detail page; return (status_code, body_text)."""
134 resp = await client.get(
135 f"/{owner}/{slug}/issues/{number}",
136 headers=headers or {},
137 )
138 return resp.status_code, resp.text
139
140
141 # ---------------------------------------------------------------------------
142 # Basic rendering
143 # ---------------------------------------------------------------------------
144
145
146 async def test_issue_detail_renders_title_server_side(
147 client: AsyncClient,
148 db_session: AsyncSession,
149 ) -> None:
150 """Issue title appears in the HTML rendered on the server."""
151 repo_id = await _make_repo(db_session)
152 await _make_issue(db_session, repo_id, title="Chorus hook is off-key")
153
154 status, body = await _get_detail(client)
155
156 assert status == 200
157 assert "Chorus hook is off-key" in body
158
159
160 async def test_issue_detail_unknown_number_404(
161 client: AsyncClient,
162 db_session: AsyncSession,
163 ) -> None:
164 """A non-existent issue number returns 404."""
165 await _make_repo(db_session)
166
167 resp = await client.get("/songwriter/melodies/issues/999")
168 assert resp.status_code == 404
169
170
171 # ---------------------------------------------------------------------------
172 # SSR body content
173 # ---------------------------------------------------------------------------
174
175
176 async def test_issue_detail_renders_body_markdown(
177 client: AsyncClient,
178 db_session: AsyncSession,
179 ) -> None:
180 """Issue body with Markdown bold is rendered as <strong> in the HTML."""
181 repo_id = await _make_repo(db_session)
182 await _make_issue(db_session, repo_id, body="The **bass line** needs work.")
183
184 status, body = await _get_detail(client)
185
186 assert status == 200
187 assert "<strong>bass line</strong>" in body
188
189
190 async def test_issue_detail_empty_body_shows_placeholder(
191 client: AsyncClient,
192 db_session: AsyncSession,
193 ) -> None:
194 """An issue with empty body renders the 'No description provided' placeholder."""
195 repo_id = await _make_repo(db_session)
196 await _make_issue(db_session, repo_id, body="")
197
198 status, body = await _get_detail(client)
199
200 assert status == 200
201 assert "No description provided" in body
202
203
204 # ---------------------------------------------------------------------------
205 # Comments
206 # ---------------------------------------------------------------------------
207
208
209 async def test_issue_detail_renders_comments_server_side(
210 client: AsyncClient,
211 db_session: AsyncSession,
212 ) -> None:
213 """A seeded comment body appears in the rendered HTML."""
214 repo_id = await _make_repo(db_session)
215 issue = await _make_issue(db_session, repo_id)
216 await _make_comment(db_session, issue.issue_id, repo_id, body="Agreed, bridge it up!")
217
218 status, body = await _get_detail(client)
219
220 assert status == 200
221 assert "Agreed, bridge it up!" in body
222
223
224 async def test_issue_detail_no_comments_shows_placeholder(
225 client: AsyncClient,
226 db_session: AsyncSession,
227 ) -> None:
228 """When there are no comments the placeholder text is rendered."""
229 repo_id = await _make_repo(db_session)
230 await _make_issue(db_session, repo_id)
231
232 status, body = await _get_detail(client)
233
234 assert status == 200
235 assert "No activity yet" in body
236
237
238 # ---------------------------------------------------------------------------
239 # HTMX attributes
240 # ---------------------------------------------------------------------------
241
242
243 async def test_issue_detail_cli_card_shown(
244 client: AsyncClient,
245 db_session: AsyncSession,
246 ) -> None:
247 """The 'Act via CLI' card is rendered with a muse hub issue snippet."""
248 repo_id = await _make_repo(db_session)
249 await _make_issue(db_session, repo_id)
250
251 status, body = await _get_detail(client)
252
253 assert status == 200
254 assert "muse hub issue" in body
255
256
257 async def test_issue_detail_open_state_shows_open_badge(
258 client: AsyncClient,
259 db_session: AsyncSession,
260 ) -> None:
261 """An open issue renders an Open state badge and filed-by attribution."""
262 repo_id = await _make_repo(db_session)
263 await _make_issue(db_session, repo_id, state="open")
264
265 status, body = await _get_detail(client)
266
267 assert status == 200
268 assert "Open" in body
269 assert "filed by" in body
270
271
272 async def test_issue_detail_closed_state_shows_closed_badge(
273 client: AsyncClient,
274 db_session: AsyncSession,
275 ) -> None:
276 """A closed issue renders a Closed state badge."""
277 repo_id = await _make_repo(db_session)
278 await _make_issue(db_session, repo_id, state="closed")
279
280 status, body = await _get_detail(client)
281
282 assert status == 200
283 assert "Closed" in body
284
285
286 # ---------------------------------------------------------------------------
287 # HTMX fragment
288 # ---------------------------------------------------------------------------
289
290
291 async def test_issue_detail_htmx_request_returns_comment_fragment(
292 client: AsyncClient,
293 db_session: AsyncSession,
294 ) -> None:
295 """GET with HX-Request: true returns the comment fragment (no full page shell)."""
296 repo_id = await _make_repo(db_session)
297 issue = await _make_issue(db_session, repo_id)
298 await _make_comment(db_session, issue.issue_id, repo_id, body="Fragment comment here.")
299
300 status, body = await _get_detail(client, headers={"HX-Request": "true"})
301
302 assert status == 200
303 assert "Fragment comment here." in body
304 # Fragment must not include the full page chrome
305 assert "<html" not in body
306 assert "<!DOCTYPE" not in body
307
308
309 # ---------------------------------------------------------------------------
310 # Symbol anchors (Phase 1A)
311 # ---------------------------------------------------------------------------
312
313
314 async def test_symbol_anchors_panel_shown_when_symbol_label_present(
315 client: AsyncClient,
316 db_session: AsyncSession,
317 ) -> None:
318 """Issues with symbol_anchors set display the Symbol Anchors panel."""
319 repo_id = await _make_repo(db_session)
320 await _make_issue(
321 db_session,
322 repo_id,
323 labels=["bug"],
324 symbol_anchors=["muse/core/snapshot.py::compute_snapshot_id"],
325 )
326
327 status, body = await _get_detail(client)
328
329 assert status == 200
330 assert "Symbol Anchors" in body
331 assert "compute_snapshot_id" in body
332 assert "muse/core/snapshot.py" in body
333
334
335 async def test_symbol_labels_excluded_from_display_labels(
336 client: AsyncClient,
337 db_session: AsyncSession,
338 ) -> None:
339 """symbol_anchors appear in the anchors panel, not in the regular label list."""
340 repo_id = await _make_repo(db_session)
341 await _make_issue(
342 db_session,
343 repo_id,
344 labels=["performance"],
345 symbol_anchors=["muse/core/snapshot.py::compute_snapshot_id"],
346 )
347
348 status, body = await _get_detail(client)
349
350 assert status == 200
351 # The display label 'performance' appears in the page
352 assert "performance" in body
353 # The raw symbol anchor address does NOT appear as a label chip (only in the anchors panel)
354 assert "symbol:muse/core/snapshot.py::compute_snapshot_id" not in body
355
356
357 async def test_symbol_anchors_panel_absent_without_symbol_labels(
358 client: AsyncClient,
359 db_session: AsyncSession,
360 ) -> None:
361 """Issues with no symbol: labels do not show the Symbol Anchors panel."""
362 repo_id = await _make_repo(db_session)
363 await _make_issue(db_session, repo_id, labels=["bug", "performance"])
364
365 status, body = await _get_detail(client)
366
367 assert status == 200
368 assert "Symbol Anchors" not in body
369
370
371 async def test_act_panel_shown(
372 client: AsyncClient,
373 db_session: AsyncSession,
374 ) -> None:
375 """The CLI/MCP/REST act panel is always present on the issue detail page."""
376 repo_id = await _make_repo(db_session)
377 await _make_issue(db_session, repo_id)
378
379 status, body = await _get_detail(client)
380
381 assert status == 200
382 assert "muse hub issue comment" in body
383 assert "create_issue" in body
384 assert "/issues/" in body
385
386
387 # ---------------------------------------------------------------------------
388 # Release card — Muse-native VCS graph release tracking
389 # ---------------------------------------------------------------------------
390
391 _COMMIT_ID_A = "a" * 64
392 _COMMIT_ID_B = "b" * 64
393
394
395 async def _make_commit(
396 db: AsyncSession,
397 repo_id: str,
398 commit_id: str,
399 *,
400 message: str = "fix: resolve the issue",
401 author: str = "gabriel",
402 branch: str = "dev",
403 timestamp: datetime | None = None,
404 ) -> MusehubCommit:
405 commit = MusehubCommit(
406 commit_id=commit_id,
407 repo_id=repo_id,
408 branch=branch,
409 parent_ids=[],
410 message=message,
411 author=author,
412 timestamp=timestamp or datetime(2026, 4, 1, 12, 0, 0, tzinfo=timezone.utc),
413 )
414 db.add(commit)
415 await db.commit()
416 await db.refresh(commit)
417 return commit
418
419
420 async def _make_release(
421 db: AsyncSession,
422 repo_id: str,
423 tag: str,
424 commit_id: str,
425 *,
426 semver_major: int = 0,
427 semver_minor: int = 2,
428 semver_patch: int = 1,
429 ) -> MusehubRelease:
430 rel = MusehubRelease(
431 release_id=compute_release_id(repo_id, tag, datetime.now(tz=timezone.utc).isoformat()),
432 repo_id=repo_id,
433 tag=tag,
434 title=f"Release {tag}",
435 commit_id=commit_id,
436 semver_major=semver_major,
437 semver_minor=semver_minor,
438 semver_patch=semver_patch,
439 channel="stable",
440 )
441 db.add(rel)
442 await db.commit()
443 await db.refresh(rel)
444 return rel
445
446
447 async def test_release_card_no_commits_shows_placeholder(
448 client: AsyncClient,
449 db_session: AsyncSession,
450 ) -> None:
451 """Issue with no commit_anchors shows 'no commits linked' in the release card."""
452 repo_id = await _make_repo(db_session)
453 await _make_issue(db_session, repo_id)
454
455 status, body = await _get_detail(client)
456
457 assert status == 200
458 assert "Release" in body
459 assert "no commits linked" in body
460
461
462 async def test_release_card_shows_commit_hash_when_anchored(
463 client: AsyncClient,
464 db_session: AsyncSession,
465 ) -> None:
466 """Issue with a commit_anchor shows the short hash in the release card."""
467 repo_id = await _make_repo(db_session)
468 commit = await _make_commit(db_session, repo_id, _COMMIT_ID_A, message="fix: buffer overflow")
469 issue = await _make_issue(db_session, repo_id)
470 issue.commit_anchors = [commit.commit_id]
471 await db_session.commit()
472
473 status, body = await _get_detail(client)
474
475 assert status == 200
476 # Short hash (first 8 chars) visible in the release card
477 assert _COMMIT_ID_A[:8] in body
478 # Commit message rendered
479 assert "fix: buffer overflow" in body
480
481
482 async def test_release_card_shows_landed_tag_when_in_release(
483 client: AsyncClient,
484 db_session: AsyncSession,
485 ) -> None:
486 """When an anchor commit is in a tagged release, that release tag is shown."""
487 repo_id = await _make_repo(db_session)
488 # Anchor commit at T=1; release commit at T=2 (after) → anchor is contained.
489 anchor = await _make_commit(
490 db_session, repo_id, _COMMIT_ID_A,
491 timestamp=datetime(2026, 3, 1, tzinfo=timezone.utc),
492 )
493 rel_commit = await _make_commit(
494 db_session, repo_id, _COMMIT_ID_B,
495 timestamp=datetime(2026, 4, 1, tzinfo=timezone.utc),
496 )
497 await _make_release(db_session, repo_id, "v0.2.1", rel_commit.commit_id)
498 issue = await _make_issue(db_session, repo_id)
499 issue.commit_anchors = [anchor.commit_id]
500 await db_session.commit()
501
502 status, body = await _get_detail(client)
503
504 assert status == 200
505 assert "v0.2.1" in body
506
507
508 async def test_release_card_shows_next_tag_when_pending(
509 client: AsyncClient,
510 db_session: AsyncSession,
511 ) -> None:
512 """When commits exist but no release contains them, the next proposed tag is shown."""
513 repo_id = await _make_repo(db_session)
514 # Anchor commit is AFTER the release commit → not yet contained.
515 rel_commit = await _make_commit(
516 db_session, repo_id, _COMMIT_ID_B,
517 timestamp=datetime(2026, 3, 1, tzinfo=timezone.utc),
518 )
519 await _make_release(
520 db_session, repo_id, "v0.2.1", rel_commit.commit_id,
521 semver_major=0, semver_minor=2, semver_patch=1,
522 )
523 anchor = await _make_commit(
524 db_session, repo_id, _COMMIT_ID_A,
525 timestamp=datetime(2026, 4, 1, tzinfo=timezone.utc),
526 )
527 issue = await _make_issue(db_session, repo_id)
528 issue.commit_anchors = [anchor.commit_id]
529 await db_session.commit()
530
531 status, body = await _get_detail(client)
532
533 assert status == 200
534 # Proposed next patch release tag visible
535 assert "v0.2.2" in body
536 assert "proposed" in body
537
538
539 # ---------------------------------------------------------------------------
540 # Intelligence panel — singularity mode (Phase 2B)
541 # ---------------------------------------------------------------------------
542
543
544 async def test_intel_panel_shows_symbol_header(
545 client: AsyncClient,
546 db_session: AsyncSession,
547 ) -> None:
548 """Symbol anchors extracted from labels appear in the Symbol Anchors panel."""
549 repo_id = await _make_repo(db_session)
550 await _make_issue(
551 db_session,
552 repo_id,
553 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
554 )
555
556 status, body = await _get_detail(client)
557
558 assert status == 200
559 # Symbol name rendered in the Symbol Anchors panel (not the Intelligence panel,
560 # which only appears when intel data is indexed for the repo).
561 assert "compute_snapshot_id" in body
562
563
564 async def test_intel_panel_not_yet_indexed_hint_shown(
565 client: AsyncClient,
566 db_session: AsyncSession,
567 ) -> None:
568 """When no symbol index exists, the symbol anchor still appears in the anchors panel."""
569 repo_id = await _make_repo(db_session)
570 await _make_issue(
571 db_session,
572 repo_id,
573 labels=["symbol:muse/core/snapshot.py::build_manifest"],
574 )
575
576 status, body = await _get_detail(client)
577
578 assert status == 200
579 # The symbol address is shown in the Symbol Anchors panel; the Intelligence
580 # panel is hidden when no index exists (no placeholder text is shown).
581 assert "build_manifest" in body
582
583
584 async def test_intel_panel_shows_blast_radius_section(
585 client: AsyncClient,
586 db_session: AsyncSession,
587 ) -> None:
588 """The Intelligence panel renders the Blast radius section."""
589 repo_id = await _make_repo(db_session)
590 await _make_issue(
591 db_session,
592 repo_id,
593 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
594 )
595
596 status, body = await _get_detail(client)
597
598 assert status == 200
599 assert "Blast radius" in body
600
601
602 async def test_intel_panel_shows_open_issues_section(
603 client: AsyncClient,
604 db_session: AsyncSession,
605 ) -> None:
606 """Symbol Anchors panel renders the anchor address for each anchored symbol."""
607 repo_id = await _make_repo(db_session)
608 await _make_issue(
609 db_session,
610 repo_id,
611 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
612 )
613
614 status, body = await _get_detail(client)
615
616 assert status == 200
617 # The anchors panel lists the symbol path — Open issues section was
618 # removed from the Intelligence panel in the flattening refactor.
619 assert "snapshot.py" in body
620
621
622 async def test_intel_panel_open_issues_lists_current_issue_number(
623 client: AsyncClient,
624 db_session: AsyncSession,
625 ) -> None:
626 """The Open issues section includes the current issue's own number as #N."""
627 repo_id = await _make_repo(db_session)
628 await _make_issue(
629 db_session,
630 repo_id,
631 number=7,
632 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
633 state="open",
634 )
635
636 status, body = await _get_detail(client, number=7)
637
638 assert status == 200
639 assert "#7" in body
640
641
642 async def test_intel_placeholder_shown_when_no_intel(
643 client: AsyncClient,
644 db_session: AsyncSession,
645 ) -> None:
646 """Page renders correctly when symbol anchors exist but no intel index is built."""
647 repo_id = await _make_repo(db_session)
648 await _make_issue(
649 db_session,
650 repo_id,
651 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
652 )
653
654 status, body = await _get_detail(client)
655
656 assert status == 200
657 # Symbol anchor appears in the Symbol Anchors panel; the Intelligence panel
658 # is hidden when no index exists (the flattened design omits the placeholder).
659 assert "compute_snapshot_id" in body
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ 144 days ago