1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
|
"""
The rules that keep the Files toolbar inside a phone screen.
Reported from a real handset: the toolbar ran off the right edge, the download
button worst of all. The cause was arithmetic rather than subtle. At a 360 px
viewport the toolbar has 310 px of usable width, and its right-hand group asked
for 440 px:
filter 172 + Select 90 + five 30 px actions 166 + gaps 12 = 440
`.toolbar-group` had no `flex-wrap`, so that group could not break, and
`margin-left: auto` pushed the excess off the right-hand side rather than the
left — which is exactly how it was seen.
These assertions read the stylesheet, which pins the inputs to a layout without
measuring the layout. That was all there was when they were written; it is no
longer. `test_layout_measured.py` renders the real stylesheet in Chrome at a
phone width and asserts on rectangles, which is what caught the transfers panel
hanging 138 px off the left of a 320 px screen — a defect no reading of
`width: 330px` would have revealed, since it came from where the panel was
anchored rather than from how wide it was.
Prefer that for anything new. What these buy is that the four rules holding the
toolbar together cannot be removed without something saying so.
"""
import re
from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
CSS = STATIC / "style.css"
pytestmark = pytest.mark.skipif(
not CSS.exists(), reason="the SPA sources are not available")
@pytest.fixture(scope="module")
def css():
return CSS.read_text()
def _rule(css: str, selector: str) -> str:
"""The body of the first rule whose selector list starts with `selector`."""
m = re.search(r"^" + re.escape(selector) + r"[^{]*\{([^}]*)\}", css, re.M)
assert m, f"no rule found for {selector}"
return m.group(1)
@pytest.fixture(scope="module")
def mobile(css):
"""The body of the max-width: 768px block."""
i = css.index("@media (max-width: 768px)")
depth, j = 0, css.index("{", i)
for k in range(j, len(css)):
if css[k] == "{":
depth += 1
elif css[k] == "}":
depth -= 1
if depth == 0:
return css[j:k]
pytest.fail("the mobile media query is not closed")
def test_a_toolbar_group_can_break(css):
"""Without this the right-hand group is a single unbreakable 440 px row."""
assert "flex-wrap: wrap" in _rule(css, ".toolbar-group")
def test_the_filter_can_shrink(css):
"""A fixed 150 px input keeps its width and pushes everything after it out."""
assert "min-width: 0" in _rule(css, ".tb-search")
assert "min-width: 0" in _rule(css, ".tb-search input")
def test_the_action_buttons_wrap(css):
assert "flex-wrap: wrap" in _rule(css, ".tb-actions")
def test_breadcrumbs_wrap(css):
"""A deep path is the other way this row grows without limit."""
assert "flex-wrap: wrap" in _rule(css, ".breadcrumbs")
def test_the_right_hand_group_stops_being_pushed_right_on_a_phone(mobile):
"""`margin-left: auto` is what sent the overflow off-screen to the right."""
assert "margin-left: 0" in mobile
assert ".toolbar-group.right" in mobile
def test_the_groups_take_a_line_each_on_a_phone(mobile):
assert "width: 100%" in mobile
def test_the_toolbar_still_fits_a_360px_screen(css):
"""The measurements the rules above are chosen against.
Recomputed from the stylesheet rather than restated, so a change to the
button height or the toolbar padding is caught here instead of on a phone.
"""
icon_btn = _rule(css, ".tb-icon-btn")
size = int(re.search(r"width:\s*(\d+)px", icon_btn).group(1))
gap = int(re.search(r"gap:\s*(\d+)px", _rule(css, ".tb-actions")).group(1))
usable = 360 - 2 * 16 - 2 * 8 - 2 # viewport − .main − .file-toolbar − borders
actions = 5 * size + 4 * gap # play, view, download, zip, delete
assert actions <= usable, (
f"five actions need {actions}px and the toolbar offers {usable}px on a "
"360px screen — they no longer fit on their own line")
# ── The chat panel's height ───────────────────────────────────────────────────
# fit() and its constants are ChatPanel's own viewport-sizing logic, moved to
# chat-app.js in the group-page refactor.
APP = STATIC / "chat-app.js"
@pytest.fixture(scope="module")
def app():
return APP.read_text()
def test_the_chat_panel_is_measured_not_guessed(app):
"""`calc(100vh - 220px)` was wrong twice over on a phone.
`100vh` is the viewport with the URL bar *hidden*, so with it showing the
panel is already taller than the screen. And 220px is a guess at the group
header, which carries a title, a description of any length, an edit link,
a delete button and the tabs. Between them the composer ended up below the
fold and the whole page scrolled to reach it.
"""
assert "el.getBoundingClientRect().top + window.scrollY" in app, (
"the height must come from where the panel actually sits")
assert "window.visualViewport?.height || window.innerHeight" in app, (
"innerHeight alone ignores the on-screen keyboard on Android")
def test_the_measurement_survives_a_scrolled_page(app):
"""Document-relative, so the answer does not depend on the scroll offset."""
fit = app[app.index("const fit = () => {"):]
fit = fit[:fit.index("};")]
assert "window.scrollY" in fit
def test_the_panel_refits_when_the_viewport_changes(app):
# `fit` is wrapped by `fitAndPin` (which also keeps the view pinned to the
# bottom on a resize), and on the window by `refit`, which additionally
# forgets the learnt space below the panel — a real viewport change can
# mean the page under it reflowed. `visualViewport` deliberately does not
# forget: a phone fires that event constantly, and re-deriving the leftover
# there is what made fit() oscillate and weld the reader to the bottom of
# the conversation (see test_chat_scroll_up.py).
for event in ("resize", "orientationchange"):
assert f"addEventListener('{event}', refit)" in app
assert "visualViewport?.addEventListener('resize', fitAndPin)" in app
assert "removeEventListener('resize', refit)" in app, "the listener must be released"
assert "el._chatFitBelow = 0" in app, (
"a window resize must forget the learnt leftover and measure it again")
def test_the_css_floor_does_not_fight_the_measurement(css, app):
"""A min-height above the computed value would put the scrollbar back."""
panel = _rule(css, ".chat-panel")
css_floor = int(re.search(r"min-height:\s*(\d+)px", panel).group(1))
js_floor = int(re.search(r"const CHAT_MIN_HEIGHT = (\d+)", app).group(1))
assert css_floor == js_floor, (
f"CSS floor {css_floor}px and JS floor {js_floor}px disagree — the "
"larger one silently wins and the page scrolls again")
def test_the_fallback_height_uses_dvh(css):
"""The value before the measurement runs, and if it never does."""
panel = _rule(css, ".chat-panel")
assert "dvh" in panel, "vh is the URL-bar-hidden viewport and overshoots"
|