gabriel / musehub public
test_musehub_ui_issue_list_enhanced.py python
688 lines 21.8 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago
1 """Tests for the SSR issue list page — reference HTMX implementation (issue #555).
2
3 Covers server-side rendering, HTMX fragment responses, filters, tabs, and
4 pagination. All assertions target Jinja2-rendered content in the HTML
5 response body, not JavaScript function definitions.
6
7 Test areas:
8 Basic rendering
9 - test_issue_list_page_returns_200
10 - test_issue_list_no_auth_required
11 - test_issue_list_unknown_repo_404
12
13 SSR content — issue data rendered on server
14 - test_issue_list_renders_issue_title_server_side
15 - test_issue_list_filter_form_has_hx_get
16 - test_issue_list_filter_form_has_hx_target
17
18 Open/closed tab counts
19 - test_issue_list_tab_open_has_hx_get
20 - test_issue_list_open_closed_counts_in_tabs
21
22 State filter
23 - test_issue_list_state_filter_closed_shows_closed_only
24
25 Label filter
26 - test_issue_list_label_filter_narrows_issues
27
28 HTMX fragment
29 - test_issue_list_htmx_request_returns_fragment
30 - test_issue_list_fragment_contains_issue_title
31 - test_issue_list_fragment_empty_state_when_no_issues
32
33 Pagination
34 - test_issue_list_pagination_renders_next_link
35
36 Right sidebar
37 - test_issue_list_right_sidebar_present
38 - test_issue_list_labels_summary_heading_present
39 - test_issue_list_labels_summary_list_present
40
41 Filter sidebar
42 - test_issue_list_filter_sidebar_present
43 - test_issue_list_label_chip_container_present
44 - test_issue_list_filter_assignee_select_present
45 - test_issue_list_filter_author_input_present
46 - test_issue_list_sort_radio_group_present
47 - test_issue_list_sort_radio_buttons_present
48
49 Template selector / new-issue flow (minimal JS)
50 - test_issue_list_template_picker_present
51 - test_issue_list_template_grid_present
52 - test_issue_list_template_cards_present
53 - test_issue_list_show_template_picker_js_present
54 - test_issue_list_select_template_js_present
55 - test_issue_list_issue_templates_const_present
56 - test_issue_list_new_issue_btn_calls_template
57 - test_issue_list_templates_back_btn_present
58 - test_issue_list_blank_template_defined
59 - test_issue_list_bug_template_defined
60
61 Bulk toolbar structure
62 - test_issue_list_bulk_toolbar_present
63 - test_issue_list_bulk_count_present
64 - test_issue_list_bulk_label_select_present
65 - test_issue_list_issue_row_checkbox_present
66 - test_issue_list_toggle_issue_select_js_present
67 - test_issue_list_deselect_all_js_present
68 - test_issue_list_update_bulk_toolbar_js_present
69 - test_issue_list_bulk_close_js_present
70 - test_issue_list_bulk_reopen_js_present
71 - test_issue_list_bulk_assign_label_js_present
72 """
73 from __future__ import annotations
74
75 import pytest
76 from httpx import AsyncClient
77 from sqlalchemy.ext.asyncio import AsyncSession
78
79 from datetime import datetime, timezone
80
81 from muse.core.types import now_utc_iso
82 from musehub.core.genesis import compute_identity_id, compute_issue_id, compute_repo_id
83 from musehub.db.musehub_models import MusehubIssue, MusehubRepo
84
85
86 # ---------------------------------------------------------------------------
87 # Helpers
88 # ---------------------------------------------------------------------------
89
90
91 async def _make_repo(
92 db: AsyncSession,
93 owner: str = "beatmaker",
94 slug: str = "grooves",
95 ) -> str:
96 """Seed a public repo and return its repo_id string."""
97 owner_id = compute_identity_id(owner.encode())
98 created_at = datetime.now(tz=timezone.utc)
99 repo = MusehubRepo(
100 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
101 name=slug,
102 owner=owner,
103 slug=slug,
104 visibility="public",
105 owner_user_id=owner_id,
106 created_at=created_at,
107 updated_at=created_at,
108 )
109 db.add(repo)
110 await db.commit()
111 await db.refresh(repo)
112 return str(repo.repo_id)
113
114
115 async def _make_issue(
116 db: AsyncSession,
117 repo_id: str,
118 *,
119 number: int = 1,
120 title: str = "Bass too loud",
121 state: str = "open",
122 labels: list[str] | None = None,
123 author: str = "beatmaker",
124 ) -> MusehubIssue:
125 """Seed an issue and return it."""
126 author_id = compute_identity_id(author.encode())
127 issue = MusehubIssue(
128 issue_id=compute_issue_id(repo_id, author_id, now_utc_iso()),
129 repo_id=repo_id,
130 number=number,
131 title=title,
132 body="Issue body.",
133 state=state,
134 labels=labels or [],
135 author=author,
136 )
137 db.add(issue)
138 await db.commit()
139 await db.refresh(issue)
140 return issue
141
142
143 async def _get_page(
144 client: AsyncClient,
145 owner: str = "beatmaker",
146 slug: str = "grooves",
147 **params: str,
148 ) -> str:
149 """Fetch the issue list page and return its text body."""
150 resp = await client.get(f"/{owner}/{slug}/issues", params=params)
151 assert resp.status_code == 200
152 return resp.text
153
154
155 # ---------------------------------------------------------------------------
156 # Basic page rendering
157 # ---------------------------------------------------------------------------
158
159
160 async def test_issue_list_page_returns_200(
161 client: AsyncClient,
162 db_session: AsyncSession,
163 ) -> None:
164 """GET /{owner}/{slug}/issues returns 200 HTML."""
165 await _make_repo(db_session)
166 response = await client.get("/beatmaker/grooves/issues")
167 assert response.status_code == 200
168 assert "text/html" in response.headers["content-type"]
169
170
171 async def test_issue_list_no_auth_required(
172 client: AsyncClient,
173 db_session: AsyncSession,
174 ) -> None:
175 """Issue list page renders without authentication."""
176 await _make_repo(db_session)
177 response = await client.get("/beatmaker/grooves/issues")
178 assert response.status_code == 200
179
180
181 async def test_issue_list_unknown_repo_404(
182 client: AsyncClient,
183 db_session: AsyncSession,
184 ) -> None:
185 """Unknown owner/slug returns 404."""
186 response = await client.get("/nobody/norepo/issues")
187 assert response.status_code == 404
188
189
190 # ---------------------------------------------------------------------------
191 # SSR content — issue data is rendered server-side
192 # ---------------------------------------------------------------------------
193
194
195 async def test_issue_list_renders_issue_title_server_side(
196 client: AsyncClient,
197 db_session: AsyncSession,
198 ) -> None:
199 """Seeded issue title appears in SSR HTML without JS execution."""
200 repo_id = await _make_repo(db_session)
201 await _make_issue(db_session, repo_id, title="Kick drum too punchy")
202 body = await _get_page(client)
203 assert "Kick drum too punchy" in body
204
205
206 async def test_issue_list_filter_form_has_hx_get(
207 client: AsyncClient,
208 db_session: AsyncSession,
209 ) -> None:
210 """Filter form carries hx-get attribute for HTMX partial updates."""
211 await _make_repo(db_session)
212 body = await _get_page(client)
213 assert "hx-get" in body
214
215
216 async def test_issue_list_filter_form_has_hx_target(
217 client: AsyncClient,
218 db_session: AsyncSession,
219 ) -> None:
220 """Filter form targets #issue-rows for HTMX swaps."""
221 await _make_repo(db_session)
222 body = await _get_page(client)
223 assert 'hx-target="#issue-rows"' in body or "hx-target='#issue-rows'" in body
224
225
226 # ---------------------------------------------------------------------------
227 # Open/closed tab counts
228 # ---------------------------------------------------------------------------
229
230
231 async def test_issue_list_tab_open_has_hx_get(
232 client: AsyncClient,
233 db_session: AsyncSession,
234 ) -> None:
235 """Open tab link carries hx-get for HTMX navigation."""
236 await _make_repo(db_session)
237 body = await _get_page(client)
238 assert "state=open" in body
239 assert "hx-get" in body
240
241
242 async def test_issue_list_open_closed_counts_in_tabs(
243 client: AsyncClient,
244 db_session: AsyncSession,
245 ) -> None:
246 """Tab badges reflect the actual open and closed issue counts from the DB."""
247 repo_id = await _make_repo(db_session)
248 for i in range(3):
249 await _make_issue(db_session, repo_id, number=i + 1, state="open")
250 for i in range(2):
251 await _make_issue(db_session, repo_id, number=i + 4, state="closed")
252 body = await _get_page(client)
253 assert ">3<" in body or ">3 <" in body or "3</span>" in body
254 assert ">2<" in body or ">2 <" in body or "2</span>" in body
255
256
257 # ---------------------------------------------------------------------------
258 # State filter
259 # ---------------------------------------------------------------------------
260
261
262 async def test_issue_list_state_filter_closed_shows_closed_only(
263 client: AsyncClient,
264 db_session: AsyncSession,
265 ) -> None:
266 """?state=closed returns only closed issues in the rendered HTML."""
267 repo_id = await _make_repo(db_session)
268 await _make_issue(db_session, repo_id, number=1, title="UniqueOpenTitle", state="open")
269 await _make_issue(db_session, repo_id, number=2, title="UniqueClosedTitle", state="closed")
270 body = await _get_page(client, state="closed")
271 assert "UniqueClosedTitle" in body
272 assert "UniqueOpenTitle" not in body
273
274
275 # ---------------------------------------------------------------------------
276 # Label filter
277 # ---------------------------------------------------------------------------
278
279
280 async def test_issue_list_label_filter_narrows_issues(
281 client: AsyncClient,
282 db_session: AsyncSession,
283 ) -> None:
284 """?label=bug returns only issues labelled 'bug'."""
285 repo_id = await _make_repo(db_session)
286 await _make_issue(db_session, repo_id, number=1, title="Bug: kick too loud", labels=["bug"])
287 await _make_issue(db_session, repo_id, number=2, title="Feature: add reverb", labels=["feature"])
288 body = await _get_page(client, label="bug")
289 assert "Bug: kick too loud" in body
290 assert "Feature: add reverb" not in body
291
292
293 # ---------------------------------------------------------------------------
294 # HTMX fragment
295 # ---------------------------------------------------------------------------
296
297
298 async def test_issue_list_htmx_request_returns_fragment(
299 client: AsyncClient,
300 db_session: AsyncSession,
301 ) -> None:
302 """HX-Request: true returns a bare fragment — no <html> wrapper."""
303 await _make_repo(db_session)
304 resp = await client.get(
305 "/beatmaker/grooves/issues",
306 headers={"HX-Request": "true"},
307 )
308 assert resp.status_code == 200
309 assert "<html" not in resp.text
310
311
312 async def test_issue_list_fragment_contains_issue_title(
313 client: AsyncClient,
314 db_session: AsyncSession,
315 ) -> None:
316 """HTMX fragment contains the seeded issue title."""
317 repo_id = await _make_repo(db_session)
318 await _make_issue(db_session, repo_id, title="Synth pad too bright")
319 resp = await client.get(
320 "/beatmaker/grooves/issues",
321 headers={"HX-Request": "true"},
322 )
323 assert resp.status_code == 200
324 assert "Synth pad too bright" in resp.text
325
326
327 async def test_issue_list_fragment_empty_state_when_no_issues(
328 client: AsyncClient,
329 db_session: AsyncSession,
330 ) -> None:
331 """Fragment returns an empty-state block when no issues match filters."""
332 repo_id = await _make_repo(db_session)
333 await _make_issue(db_session, repo_id, number=1, title="Open issue", state="open")
334 resp = await client.get(
335 "/beatmaker/grooves/issues",
336 params={"state": "closed"},
337 headers={"HX-Request": "true"},
338 )
339 assert resp.status_code == 200
340 # Template renders isl-empty block for empty state
341 assert "isl-empty" in resp.text
342
343
344 # ---------------------------------------------------------------------------
345 # Pagination
346 # ---------------------------------------------------------------------------
347
348
349 async def test_issue_list_pagination_renders_next_link(
350 client: AsyncClient,
351 db_session: AsyncSession,
352 ) -> None:
353 """When total issues exceed per_page, a Next pagination link appears."""
354 repo_id = await _make_repo(db_session)
355 for i in range(30):
356 await _make_issue(db_session, repo_id, number=i + 1, state="open")
357 body = await _get_page(client, per_page="25")
358 assert "Next" in body or "next" in body.lower()
359
360
361 # ---------------------------------------------------------------------------
362 # Right sidebar
363 # ---------------------------------------------------------------------------
364
365
366 async def test_issue_list_right_sidebar_present(
367 client: AsyncClient,
368 db_session: AsyncSession,
369 ) -> None:
370 """Right sidebar element is present in the SSR page."""
371 await _make_repo(db_session)
372 body = await _get_page(client)
373 assert "isl-sidebar" in body
374
375
376 async def test_issue_list_labels_summary_heading_present(
377 client: AsyncClient,
378 db_session: AsyncSession,
379 ) -> None:
380 """Labels sidebar section is rendered server-side."""
381 await _make_repo(db_session)
382 body = await _get_page(client)
383 assert "Labels" in body
384
385
386 async def test_issue_list_labels_summary_list_present(
387 client: AsyncClient,
388 db_session: AsyncSession,
389 ) -> None:
390 """Labels sidebar section contains a label list."""
391 await _make_repo(db_session)
392 body = await _get_page(client)
393 assert "Labels" in body
394
395
396 # ---------------------------------------------------------------------------
397 # Filter sidebar elements
398 # ---------------------------------------------------------------------------
399
400
401 async def test_issue_list_filter_sidebar_present(
402 client: AsyncClient,
403 db_session: AsyncSession,
404 ) -> None:
405 """Issue filter form is rendered server-side."""
406 await _make_repo(db_session)
407 body = await _get_page(client)
408 assert 'name="sort"' in body
409
410
411 async def test_issue_list_label_chip_container_present(
412 client: AsyncClient,
413 db_session: AsyncSession,
414 ) -> None:
415 """Label filter select is present in the filter bar."""
416 await _make_repo(db_session)
417 body = await _get_page(client)
418 assert 'name="sort"' in body
419
420
421
422
423 async def test_issue_list_filter_assignee_select_present(
424 client: AsyncClient,
425 db_session: AsyncSession,
426 ) -> None:
427 """Assignee filter <select> appears when assignees exist; sort select always present."""
428 await _make_repo(db_session)
429 body = await _get_page(client)
430 # Sort select is always rendered; assignee select only when data is seeded
431 assert 'name="sort"' in body or "name='sort'" in body
432
433
434 async def test_issue_list_filter_author_input_present(
435 client: AsyncClient,
436 db_session: AsyncSession,
437 ) -> None:
438 """Issue filter form has filter controls (author filter via assignee or label select)."""
439 await _make_repo(db_session)
440 body = await _get_page(client)
441 assert 'name="sort"' in body
442
443
444 async def test_issue_list_sort_radio_group_present(
445 client: AsyncClient,
446 db_session: AsyncSession,
447 ) -> None:
448 """Sort filter <select> element is present (name=sort)."""
449 await _make_repo(db_session)
450 body = await _get_page(client)
451 assert 'name="sort"' in body or "name='sort'" in body
452
453
454 async def test_issue_list_sort_radio_buttons_present(
455 client: AsyncClient,
456 db_session: AsyncSession,
457 ) -> None:
458 """Radio inputs with name='sort' are present (SSR-rendered)."""
459 await _make_repo(db_session)
460 body = await _get_page(client)
461 assert 'name="sort"' in body or "name='sort'" in body
462
463
464 # ---------------------------------------------------------------------------
465 # Template selector / new-issue flow (minimal JS retained)
466 # ---------------------------------------------------------------------------
467
468
469 async def test_issue_list_template_picker_present(
470 client: AsyncClient,
471 db_session: AsyncSession,
472 ) -> None:
473 """template-picker element is present in the page HTML."""
474 await _make_repo(db_session)
475 body = await _get_page(client)
476 assert "template-picker" in body
477
478
479 async def test_issue_list_template_grid_present(
480 client: AsyncClient,
481 db_session: AsyncSession,
482 ) -> None:
483 """Template picker container is rendered server-side."""
484 await _make_repo(db_session)
485 body = await _get_page(client)
486 assert "isl-template-picker" in body
487
488
489 async def test_issue_list_template_cards_present(
490 client: AsyncClient,
491 db_session: AsyncSession,
492 ) -> None:
493 """Template picker card class is present (SSR-rendered template cards)."""
494 await _make_repo(db_session)
495 body = await _get_page(client)
496 assert "isl-tp-card" in body
497
498
499 async def test_issue_list_show_template_picker_js_present(
500 client: AsyncClient,
501 db_session: AsyncSession,
502 ) -> None:
503 """Template picker panel is rendered server-side."""
504 await _make_repo(db_session)
505 body = await _get_page(client)
506 assert "Choose a template" in body
507
508
509 async def test_issue_list_select_template_js_present(
510 client: AsyncClient,
511 db_session: AsyncSession,
512 ) -> None:
513 """Template cards use data-action="select-template" (selectTemplate moved to issue-list.ts)."""
514 await _make_repo(db_session)
515 body = await _get_page(client)
516 assert "select-template" in body
517
518
519 async def test_issue_list_issue_templates_const_present(
520 client: AsyncClient,
521 db_session: AsyncSession,
522 ) -> None:
523 """ISSUE_TEMPLATES is in app.js (TypeScript module); page dispatches issue-list module."""
524 await _make_repo(db_session)
525 body = await _get_page(client)
526 # ISSUE_TEMPLATES moved to app.js; verify page dispatch JSON and template picker HTML
527 assert '"page": "issue-list"' in body
528 assert "template-picker" in body
529
530
531 async def test_issue_list_new_issue_btn_calls_template(
532 client: AsyncClient,
533 db_session: AsyncSession,
534 ) -> None:
535 """New Issue button opens template picker via data-action (showTemplatePicker moved to issue-list.ts)."""
536 await _make_repo(db_session)
537 body = await _get_page(client)
538 assert "New Issue" in body
539 assert "Choose a template" in body
540
541
542 async def test_issue_list_templates_back_btn_present(
543 client: AsyncClient,
544 db_session: AsyncSession,
545 ) -> None:
546 """Template picker is rendered in the new issue flow."""
547 await _make_repo(db_session)
548 body = await _get_page(client)
549 assert "template-picker" in body
550
551
552 async def test_issue_list_blank_template_defined(
553 client: AsyncClient,
554 db_session: AsyncSession,
555 ) -> None:
556 """'blank' template id is present in ISSUE_TEMPLATES."""
557 await _make_repo(db_session)
558 body = await _get_page(client)
559 assert "'blank'" in body or '"blank"' in body
560
561
562 async def test_issue_list_bug_template_defined(
563 client: AsyncClient,
564 db_session: AsyncSession,
565 ) -> None:
566 """'bug' template id is present in ISSUE_TEMPLATES."""
567 await _make_repo(db_session)
568 body = await _get_page(client)
569 assert "'bug'" in body or '"bug"' in body
570
571
572 # ---------------------------------------------------------------------------
573 # Bulk toolbar structure (SSR-rendered, JS-activated)
574 # ---------------------------------------------------------------------------
575
576
577 async def test_issue_list_bulk_toolbar_present(
578 client: AsyncClient,
579 db_session: AsyncSession,
580 ) -> None:
581 """bulk-toolbar element is rendered in the page HTML."""
582 await _make_repo(db_session)
583 body = await _get_page(client)
584 assert "bulk-toolbar" in body
585
586
587 async def test_issue_list_bulk_count_present(
588 client: AsyncClient,
589 db_session: AsyncSession,
590 ) -> None:
591 """bulk-count element is present."""
592 await _make_repo(db_session)
593 body = await _get_page(client)
594 assert "bulk-count" in body
595
596
597 async def test_issue_list_bulk_label_select_present(
598 client: AsyncClient,
599 db_session: AsyncSession,
600 ) -> None:
601 """bulk-label-select element is present."""
602 await _make_repo(db_session)
603 body = await _get_page(client)
604 assert "bulk-label-select" in body
605
606
607 async def test_issue_list_issue_row_checkbox_present(
608 client: AsyncClient,
609 db_session: AsyncSession,
610 ) -> None:
611 """issue-row-check CSS class is present (checkbox for bulk selection)."""
612 repo_id = await _make_repo(db_session)
613 await _make_issue(db_session, repo_id, title="Has checkbox")
614 body = await _get_page(client)
615 assert "issue-row-check" in body
616
617
618 async def test_issue_list_toggle_issue_select_js_present(
619 client: AsyncClient,
620 db_session: AsyncSession,
621 ) -> None:
622 """toggleIssueSelect() is in app.js (TypeScript module); page renders bulk toolbar."""
623 await _make_repo(db_session)
624 body = await _get_page(client)
625 # Function moved to app.js; verify bulk toolbar HTML element is present
626 assert "bulk-toolbar" in body
627
628
629 async def test_issue_list_deselect_all_js_present(
630 client: AsyncClient,
631 db_session: AsyncSession,
632 ) -> None:
633 """Deselect action uses data-bulk-action="deselect" (deselectAll moved to issue-list.ts)."""
634 await _make_repo(db_session)
635 body = await _get_page(client)
636 assert 'data-bulk-action="deselect"' in body
637
638
639 async def test_issue_list_update_bulk_toolbar_js_present(
640 client: AsyncClient,
641 db_session: AsyncSession,
642 ) -> None:
643 """Page renders bulk action buttons (isl-bulk-btn with data-bulk-action attributes)."""
644 await _make_repo(db_session)
645 body = await _get_page(client)
646 assert "isl-bulk-btn" in body
647 assert "data-bulk-action" in body
648
649
650 async def test_issue_list_bulk_close_js_present(
651 client: AsyncClient,
652 db_session: AsyncSession,
653 ) -> None:
654 """Close bulk action uses data-bulk-action="close" (bulkClose moved to issue-list.ts)."""
655 await _make_repo(db_session)
656 body = await _get_page(client)
657 assert 'data-bulk-action="close"' in body
658
659
660 async def test_issue_list_bulk_reopen_js_present(
661 client: AsyncClient,
662 db_session: AsyncSession,
663 ) -> None:
664 """Reopen bulk action uses data-bulk-action="reopen" (bulkReopen moved to issue-list.ts)."""
665 await _make_repo(db_session)
666 body = await _get_page(client)
667 assert 'data-bulk-action="reopen"' in body
668
669
670 async def test_issue_list_bulk_assign_label_js_present(
671 client: AsyncClient,
672 db_session: AsyncSession,
673 ) -> None:
674 """Assign label uses data-bulk-action="assign-label" (bulkAssignLabel moved to issue-list.ts)."""
675 await _make_repo(db_session)
676 body = await _get_page(client)
677 assert 'data-bulk-action="assign-label"' in body
678
679
680 async def test_issue_list_full_page_contains_html_wrapper(
681 client: AsyncClient,
682 db_session: AsyncSession,
683 ) -> None:
684 """Direct browser navigation (no HX-Request) returns a full HTML page with <html> tag."""
685 await _make_repo(db_session)
686 resp = await client.get("/beatmaker/grooves/issues")
687 assert resp.status_code == 200
688 assert "<html" in resp.text
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago