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