gabriel / musehub public
test_detail_page_document_scroll.py python
130 lines 5.1 KB
Raw
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