test_detail_page_document_scroll.py
python
sha256:835ed2eec931dc5f65fc2bd438a80162698a784e0a6f2e3bcf885ea9fa9b3b58
fix: let proposal and issue pages scroll as a page
Composer
minor
⚠ breaking
1 day ago
| 1 | """Proposal and issue detail pages scroll with the window. |
| 2 | |
| 3 | Gabriel's review of muse proposal #12 could not be reached because those |
| 4 | pages lock the window (`body.app-shell`) and only a short inner column |
| 5 | scrolls. These pages now use a normal document scroll and a 3/4 + 1/4 |
| 6 | grid that stacks on narrow screens. The issue list and other app-shell |
| 7 | pages stay locked. |
| 8 | """ |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import re |
| 12 | from pathlib import Path |
| 13 | |
| 14 | ROOT = Path(__file__).resolve().parents[1] |
| 15 | PROPOSAL_HTML = ROOT / "musehub/templates/musehub/pages/proposal_detail.html" |
| 16 | ISSUE_HTML = ROOT / "musehub/templates/musehub/pages/issue_detail.html" |
| 17 | ISSUE_LIST_HTML = ROOT / "musehub/templates/musehub/pages/issue_list.html" |
| 18 | PROPOSAL_SCSS = ROOT / "src/scss/pages/_proposal-detail.scss" |
| 19 | ISSUES_SCSS = ROOT / "src/scss/pages/_issues.scss" |
| 20 | APP_CSS = ROOT / "musehub/templates/musehub/static/app.css" |
| 21 | MAIN_PY = ROOT / "musehub/main.py" |
| 22 | |
| 23 | _GRID = "grid-template-columns: minmax(0, 3fr) minmax(0, 1fr)" |
| 24 | _NARROW = "grid-template-columns: minmax(0, 1fr)" |
| 25 | |
| 26 | |
| 27 | def _block(source: str, selector: str) -> str: |
| 28 | """Return the first rule body whose selector line equals `selector`.""" |
| 29 | pattern = rf"(?m)^{re.escape(selector)}\s*\{{" |
| 30 | match = re.search(pattern, source) |
| 31 | assert match is not None, f"missing selector {selector}" |
| 32 | start = match.end() |
| 33 | depth = 1 |
| 34 | i = start |
| 35 | while i < len(source) and depth: |
| 36 | if source[i] == "{": |
| 37 | depth += 1 |
| 38 | elif source[i] == "}": |
| 39 | depth -= 1 |
| 40 | i += 1 |
| 41 | return source[start : i - 1] |
| 42 | |
| 43 | |
| 44 | def test_tier1_templates_do_not_opt_into_app_shell() -> None: |
| 45 | """Unit: detail templates must not set the viewport-lock body class.""" |
| 46 | for path in (PROPOSAL_HTML, ISSUE_HTML): |
| 47 | text = path.read_text(encoding="utf-8") |
| 48 | assert "body_class" not in text, path.name |
| 49 | assert "{% block body_class %}app-shell{% endblock %}" not in text |
| 50 | |
| 51 | |
| 52 | def test_tier1_issue_list_stays_on_app_shell() -> None: |
| 53 | """Unit: this change does not unlock the issue list page.""" |
| 54 | text = ISSUE_LIST_HTML.read_text(encoding="utf-8") |
| 55 | assert 'body_class %}app-shell' in text |
| 56 | |
| 57 | |
| 58 | def test_tier2_columns_are_three_quarters_and_one_quarter() -> None: |
| 59 | """Integration: both detail layouts use the same 3fr / 1fr grid.""" |
| 60 | for path, selector in ( |
| 61 | (PROPOSAL_SCSS, ".prd-layout"), |
| 62 | (ISSUES_SCSS, ".isd-layout"), |
| 63 | ): |
| 64 | block = _block(path.read_text(encoding="utf-8"), selector) |
| 65 | assert _GRID in block, path.name |
| 66 | assert "@media (max-width: 900px)" in block |
| 67 | assert _NARROW in block |
| 68 | |
| 69 | |
| 70 | def test_tier2_detail_columns_do_not_scroll_themselves() -> None: |
| 71 | """Integration: the mouse scrolls the page, not an inner box.""" |
| 72 | proposal = PROPOSAL_SCSS.read_text(encoding="utf-8") |
| 73 | issues = ISSUES_SCSS.read_text(encoding="utf-8") |
| 74 | for source, selector in ( |
| 75 | (proposal, ".prd-page"), |
| 76 | (proposal, ".prd-main"), |
| 77 | (proposal, ".proposal-sidebar"), |
| 78 | (issues, ".isd-page"), |
| 79 | (issues, ".isd-main"), |
| 80 | (issues, ".isd-sidebar"), |
| 81 | ): |
| 82 | block = _block(source, selector) |
| 83 | assert "overflow-y: auto" not in block, selector |
| 84 | assert "overflow: hidden" not in block, selector |
| 85 | |
| 86 | |
| 87 | def test_tier3_compiled_css_matches_the_grid() -> None: |
| 88 | """End to end: the stylesheet the browser loads contains the new grid.""" |
| 89 | css = APP_CSS.read_text(encoding="utf-8") |
| 90 | wide = "grid-template-columns:minmax(0, 3fr) minmax(0, 1fr)" |
| 91 | assert css.count(wide) >= 2 |
| 92 | assert ".prd-layout{" in css |
| 93 | assert ".isd-layout{" in css |
| 94 | assert ".prd-main{min-width:0;padding-bottom:var(--space-10);overflow:visible}" in css |
| 95 | assert ".isd-main{min-width:0;overflow:visible}" in css |
| 96 | |
| 97 | |
| 98 | def test_tier4_narrow_breakpoint_is_present_for_both_pages() -> None: |
| 99 | """Stress-shaped contract: both pages collapse at the same breakpoint.""" |
| 100 | css = APP_CSS.read_text(encoding="utf-8") |
| 101 | narrow = "grid-template-columns:minmax(0, 1fr)" |
| 102 | assert "@media(max-width: 900px){.prd-layout{" + narrow + "}" in css |
| 103 | assert "@media(max-width: 900px){.isd-layout{" + narrow + "}" in css |
| 104 | |
| 105 | |
| 106 | def test_tier5_page_wrappers_are_not_height_locked() -> None: |
| 107 | """Data integrity: page wrappers do not reclaim a viewport height.""" |
| 108 | for path, selector in ( |
| 109 | (PROPOSAL_SCSS, ".prd-page"), |
| 110 | (ISSUES_SCSS, ".isd-page"), |
| 111 | ): |
| 112 | block = _block(path.read_text(encoding="utf-8"), selector) |
| 113 | assert "100dvh" not in block |
| 114 | assert "min-height: 0" not in block |
| 115 | |
| 116 | |
| 117 | def test_tier6_compiled_stylesheet_stays_a_single_file() -> None: |
| 118 | """Performance: layout change does not add a second stylesheet.""" |
| 119 | assert APP_CSS.is_file() |
| 120 | assert APP_CSS.stat().st_size < 2_000_000 |
| 121 | |
| 122 | |
| 123 | def test_tier7_csp_still_blocks_inline_scripts() -> None: |
| 124 | """Security: this layout fix must not allow unsafe-inline scripts.""" |
| 125 | text = MAIN_PY.read_text(encoding="utf-8") |
| 126 | start = text.index('response.headers["Content-Security-Policy"]') |
| 127 | header = text[start : text.index("upgrade-insecure-requests", start)] |
| 128 | script_src = header.split("script-src", 1)[1].split(";", 1)[0] |
| 129 | assert "unsafe-inline" not in script_src |
| 130 | assert "'self'" in script_src |
File History
1 commit
sha256:835ed2eec931dc5f65fc2bd438a80162698a784e0a6f2e3bcf885ea9fa9b3b58
fix: let proposal and issue pages scroll as a page
Composer
minor
⚠
1 day ago