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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
|
"""
Layouts measured in a browser instead of read out of the stylesheet.
`test_layout_responsive.py` says, in its own docstring, that a layout cannot be
measured here because there is no browser in the suite. There is one now —
Chrome is what the video work has been verified against — and the difference
matters: `width: 330px` in a rule tells you nothing about whether the thing
lands on the screen. That depends on where its anchor sits, which depends on
everything to its right.
The transfers panel is the case that proved it. 330 px wide, anchored to the
right edge of its button — but that button is not at the right edge of the
screen, the bell and the user menu come after it. Measured before the fix:
320 px viewport -> panel at -138..192, 138 px off the left
360 px -> -98..232
412 px -> -46..284
What is cut off is the left-hand side, which is where the file names are, so
what was left on screen was a strip of progress bars belonging to nothing —
reported as "on mobile you only see the right-hand side, without the content".
Every assertion here is a rectangle, not a declaration.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
HARNESS = Path(__file__).parent / "harness" / "layout_probe.py"
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
pytestmark = pytest.mark.skipif(
shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(),
reason="Chrome or the SPA stylesheet is not available")
# The nav as `Nav()` renders it: the transfers widget is not last — the bell and
# the user menu follow it, which is the whole reason the panel hangs off.
NAV = textwrap.dedent("""
<nav class="nav">
<div class="nav-left">
<button class="nav-hamburger">☰</button>
<a class="nav-brand" href="#/">MeshBay</a>
</div>
<div class="nav-right">
<div class="transfer-wrap">
<button class="nav-notif transfer-btn">↓</button>
<div class="transfer-panel">
<div class="transfer-head">Transfers<button class="btn-secondary">Clear</button></div>
<div class="transfer-item">
<div class="transfer-line">
<span class="transfer-kind">↓</span>
<span class="transfer-name">Some Saga S03E01 - A Long Enough Title.mp4</span>
<button class="transfer-cancel">✕</button>
</div>
<div class="dl-progress"><div class="dl-fill" style="width:42%"></div></div>
<div class="transfer-meta"><span>210 MB / 493 MB</span><span>3.1 MB/s</span></div>
</div>
</div>
</div>
<a class="nav-notif nav-search" href="#/search">
<svg class="icon" viewBox="0 0 24 24"></svg></a>
<a class="nav-notif" href="#/">🔔</a>
<div class="user-menu-wrap"><button class="user-menu-trigger">
<span class="user-menu-avatar">S</span><span class="user-menu-name">someone</span>
</button></div>
</div>
</nav>
""")
WIDTHS = [320, 360, 412, 768, 1024]
SELECTORS = [".transfer-panel", ".transfer-name"]
@pytest.fixture(scope="module")
def measured(tmp_path_factory):
"""One browser for every width, because launching one apiece cost the
suite three minutes."""
fragment = tmp_path_factory.mktemp("layout") / "fragment.html"
fragment.write_text(NAV, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
str(fragment), *SELECTORS],
capture_output=True, text=True, timeout=180)
assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
out = json.loads(proc.stdout)
assert "error" not in out, f"no measurement: {out}"
for w in WIDTHS:
assert out[str(w)]["viewport"]["w"] == w, (
f"asked for {w} px and measured at {out[str(w)]['viewport']['w']}")
return out
def _box(measured, width: int, selector: str) -> dict:
return measured[str(width)]["boxes"][selector]
@pytest.mark.parametrize("width", [320, 360, 412])
def test_the_transfers_panel_fits_a_phone(measured, width):
"""The reported defect, as a rectangle."""
box = _box(measured, width, ".transfer-panel")
assert box is not None, "the panel did not render"
assert box["offLeft"] == 0, (
f"{box['offLeft']} px of the panel is off the left of a {width} px "
"screen — and the left is where the file names are")
assert box["offRight"] == 0, (
f"{box['offRight']} px of the panel is off the right of a {width} px screen")
@pytest.mark.parametrize("width", [320, 360])
def test_the_file_name_gets_room_to_be_read(measured, width):
"""Fitting on screen is not the same as being legible.
A panel could satisfy the test above by being narrow enough to show
nothing. The name is the one part a viewer needs.
"""
box = _box(measured, width, ".transfer-name")
assert box is not None and box["width"] >= 180, (
f"the file name has {box['width'] if box else 0} px on a {width} px "
"screen, which is not enough to tell two downloads apart")
def test_the_desktop_panel_is_untouched(measured):
"""The fix is a media query, and it must stay inside it."""
box = _box(measured, 1024, ".transfer-panel")
assert box["width"] == 330, (
f"the desktop panel is now {box['width']} px — the mobile rule has "
"escaped its breakpoint")
assert box["offLeft"] == 0 and box["offRight"] == 0
def test_it_does_not_stretch_across_a_tablet(measured):
"""Pinned to both edges, a panel would be 750 px wide at 768."""
box = _box(measured, 768, ".transfer-panel")
assert box["width"] <= 440, (
f"{box['width']} px of panel on a tablet, which is a list of two "
"filenames stretched over most of the screen")
@pytest.mark.parametrize("width", [320, 360, 412])
def test_the_page_does_not_scroll_sideways(measured, width):
"""The other half of "it fits": nothing pushed the document wider."""
r = measured[str(width)]
assert r["docScrollW"] <= r["viewport"]["w"], (
f"the document scrolls to {r['docScrollW']} px on a {width} px screen")
# ── The grouped panel (§8.2) ────────────────────────────────────────────────
#
# The panel gained groups, a header summary and a waiting row. Every one of
# those can push something off a 320 px screen, and none of it can be seen by
# reading the stylesheet: what decides where the panel lands is the button it
# hangs from, which is not at the right edge. That is the defect this file was
# written for, and it comes back with any change to the header's width.
GROUPED = textwrap.dedent("""
<nav class="nav">
<div class="nav-left">
<button class="nav-hamburger">☰</button>
<a class="nav-brand" href="#/">MeshBay</a>
</div>
<div class="nav-right">
<div class="transfer-wrap">
<button class="nav-notif transfer-btn">↓</button>
<div class="transfer-panel">
<div class="transfer-head">
<span class="transfer-head-title">Transfers</span>
<span class="transfer-head-summary">2 running · 3 waiting</span>
</div>
<div class="transfer-group">
<div class="transfer-group-head">Running</div>
<div class="transfer-item transfer-running">
<div class="transfer-line">
<span class="transfer-kind">↓</span>
<span class="transfer-name">Some Saga S03E01 - A Long Enough Title.mp4</span>
<button class="transfer-cancel">✕</button>
</div>
<div class="dl-progress"><div class="dl-fill" style="width:42%"></div></div>
<div class="transfer-meta"><span>210 MB / 493 MB</span>
<span>3.1 MB/s · 4 min left</span></div>
</div>
</div>
<div class="transfer-group">
<div class="transfer-group-head">Waiting</div>
<div class="transfer-item transfer-queued">
<div class="transfer-line">
<span class="transfer-kind">↓</span>
<span class="transfer-name">Another File With A Long Name.mkv</span>
<button class="transfer-cancel">✕</button>
</div>
<div class="dl-progress dl-waiting"></div>
<div class="transfer-meta"><span>Waiting — your slots are busy</span>
<span>1.2 GB</span></div>
</div>
</div>
<div class="transfer-group">
<div class="transfer-group-head"><span>Finished</span>
<button class="btn-secondary">Clear finished</button></div>
<div class="transfer-item transfer-done">
<div class="transfer-line">
<span class="transfer-kind">↓</span>
<span class="transfer-name">A Finished File.mkv</span>
</div>
</div>
</div>
</div>
</div>
<a class="nav-notif nav-search" href="#/search">
<svg class="icon" viewBox="0 0 24 24"></svg></a>
<a class="nav-notif" href="#/">🔔</a>
<div class="user-menu-wrap"><button class="user-menu-trigger">
<span class="user-menu-avatar">S</span><span class="user-menu-name">someone</span>
</button></div>
</div>
</nav>
""")
GROUPED_SELECTORS = [".transfer-panel", ".transfer-head", ".transfer-head-summary",
".transfer-group-head", ".transfer-name",
".transfer-item.transfer-queued .dl-progress",
".transfer-group-head .btn-secondary"]
@pytest.fixture(scope="module")
def grouped(tmp_path_factory):
fragment = tmp_path_factory.mktemp("grouped") / "fragment.html"
fragment.write_text(GROUPED, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
str(fragment), *GROUPED_SELECTORS],
capture_output=True, text=True, timeout=180)
assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
out = json.loads(proc.stdout)
assert "error" not in out, f"no measurement: {out}"
return out
def test_the_grouped_panel_stays_on_a_phone_screen(grouped):
for width in WIDTHS:
box = grouped[str(width)]["boxes"][".transfer-panel"]
assert box["offLeft"] == 0, (
f"at {width} px the panel hangs {box['offLeft']} px off the left — "
"which is where the file names are")
assert box["offRight"] == 0, (
f"at {width} px the panel hangs {box['offRight']} px off the right")
def test_the_file_name_is_on_screen_in_every_group(grouped):
for width in WIDTHS:
box = grouped[str(width)]["boxes"][".transfer-name"]
assert box["offLeft"] == 0 and box["offRight"] == 0, (
f"at {width} px a file name is cut off: {box}")
assert box["width"] > 40, "the name column collapsed to nothing"
def test_the_header_summary_does_not_push_the_header_taller(grouped):
"""It is the one part of the header that grows with what is happening. If
it wraps, the header changes height as transfers come and go and every row
below it moves — on the narrowest screen, repeatedly."""
for width in WIDTHS:
head = grouped[str(width)]["boxes"][".transfer-head"]
summary = grouped[str(width)]["boxes"][".transfer-head-summary"]
assert head["height"] <= 48, (
f"at {width} px the header is {head['height']} px tall — it wrapped")
assert summary["height"] <= 24, (
f"at {width} px the summary wrapped to {summary['height']} px")
def test_clearing_finished_rows_stays_inside_the_panel(grouped):
"""The button lives in the finished group's head. In the panel header it
pushed the panel into a sideways scroll, or wrapped the header."""
for width in WIDTHS:
boxes = grouped[str(width)]["boxes"]
button = boxes[".transfer-group-head .btn-secondary"]
assert button["right"] <= boxes[".transfer-panel"]["right"], (
f"at {width} px the clear button sticks out of the panel: {button}")
def test_the_waiting_bar_is_as_wide_as_a_progress_bar(grouped):
"""A waiting row has no inner fill element — the stripes are on the track
itself. Getting that wrong renders a zero-width bar, which reads as a
transfer stuck at 0% rather than one that has not started."""
for width in WIDTHS:
bar = grouped[str(width)]["boxes"][
".transfer-item.transfer-queued .dl-progress"]
assert bar["width"] > 100, (
f"at {width} px the waiting bar is {bar['width']} px wide")
assert bar["height"] >= 3, "the waiting bar has no height"
# ── The whole nav on a phone ────────────────────────────────────────────────
#
# Search moved from the sidebar to the nav, beside the bell. With the user's
# name in the menu trigger, a 20-character handle already put the trigger 79 px
# off a 320 px screen, and 159 px once search and a transfer were there too.
# The fixture is the real markup of `Nav()` and `UserMenu()`, with the icons as
# the 1em boxes `Icon()` draws.
_ICON = '<svg class="icon" viewBox="0 0 24 24"></svg>'
FULL_NAV = textwrap.dedent(f"""
<nav class="nav">
<div class="nav-left">
<button class="nav-hamburger">{_ICON}</button>
<a class="nav-brand" href="#/"><img class="nav-brand-m" alt="M">eshBay</a>
</div>
<div class="nav-right">
<div class="transfer-wrap">
<button class="nav-notif transfer-btn">{_ICON}<span class="notif-badge">2</span></button>
</div>
<a class="nav-notif nav-search" href="#/search">{_ICON}</a>
<a class="nav-notif" href="#/">{_ICON}<span class="notif-badge">3</span></a>
<div class="user-menu-wrap">
<button class="user-menu-trigger">
<span class="user-menu-avatar">A</span>
<span class="user-menu-name">a_rather_long_handle</span>
<svg class="icon user-menu-caret" viewBox="0 0 24 24"></svg>
</button>
</div>
</div>
</nav>
""")
FULL_NAV_WIDTHS = [320, 360, 412, 521, 768, 1024]
@pytest.fixture(scope="module")
def full_nav(tmp_path_factory):
fragment = tmp_path_factory.mktemp("fullnav") / "fragment.html"
fragment.write_text(FULL_NAV, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in FULL_NAV_WIDTHS),
str(fragment), ".nav-brand", ".nav-right", ".user-menu-trigger",
".user-menu-name"],
capture_output=True, text=True, timeout=180)
assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
out = json.loads(proc.stdout)
assert "error" not in out, f"no measurement: {out}"
return out
@pytest.mark.parametrize("width", FULL_NAV_WIDTHS)
def test_the_user_menu_stays_inside_the_nav(full_nav, width):
"""Off the right edge is where nobody can open it — and it holds sign-out."""
boxes = full_nav[str(width)]["boxes"]
trigger = boxes[".user-menu-trigger"]
assert trigger["right"] <= width - 16, (
f"at {width} px the user menu ends at {trigger['right']} px, past the "
"nav's own padding")
assert boxes[".nav-right"]["left"] >= boxes[".nav-brand"]["right"], (
f"at {width} px the nav's icons slide under the wordmark")
@pytest.mark.parametrize("width", FULL_NAV_WIDTHS)
def test_the_name_leaves_the_trigger_on_mobile_only(full_nav, width):
"""Avatar alone up to the mobile breakpoint; the name beside it on a desktop."""
name = full_nav[str(width)]["boxes"][".user-menu-name"]
shown = bool(name and name["width"] > 0)
assert shown == (width > 768), (
f"at {width} px the name in the user menu is "
f"{'shown' if shown else 'hidden'}")
# The photo lightbox's bar. With the file name, the zoom label, 100% and a
# delete button it was 457 px wide, and the close button sat at 397..437 px —
# off a 360 px phone, with no other way out of the lightbox on screen. The
# fixture is `Lightbox()`'s bar, cast button and copy-link included.
_ZOOM_ICON = '<svg class="icon photo-icon-btn-icon" viewBox="0 0 24 24"></svg>'
LIGHTBOX_BAR = textwrap.dedent(f"""
<div class="video-overlay photo-lightbox">
<div class="video-top-bar">
<span class="video-title">IMG_20150510_205012.JPG</span>
<div class="photo-zoom-controls">
<button class="photo-icon-btn">{_ZOOM_ICON}</button>
<span class="photo-zoom-percent">Ajusté</span>
<button class="photo-icon-btn">{_ZOOM_ICON}</button>
<button class="photo-icon-btn active">{_ZOOM_ICON}</button>
<button class="photo-icon-btn photo-icon-btn-text">100%</button>
</div>
<button class="video-close">{_ICON}</button>
<div class="cast-wrapper"><button class="video-close cast-btn">{_ICON}</button></div>
<button class="video-close">{_ICON}</button>
<button class="video-close lightbox-close">{_ICON}</button>
</div>
</div>
""")
LIGHTBOX_WIDTHS = [320, 360, 412, 768, 1024]
@pytest.fixture(scope="module")
def lightbox_bar(tmp_path_factory):
fragment = tmp_path_factory.mktemp("lightbox") / "fragment.html"
fragment.write_text(LIGHTBOX_BAR, encoding="utf-8")
proc = subprocess.run(
["python3", str(HARNESS), ",".join(str(w) for w in LIGHTBOX_WIDTHS),
str(fragment), ".lightbox-close", ".video-title"],
capture_output=True, text=True, timeout=180)
assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
out = json.loads(proc.stdout)
assert "error" not in out, f"no measurement: {out}"
return out
@pytest.mark.parametrize("width", LIGHTBOX_WIDTHS)
def test_the_lightbox_close_button_is_on_screen(lightbox_bar, width):
close = lightbox_bar[str(width)]["boxes"][".lightbox-close"]
assert close["right"] <= width, (
f"at {width} px the lightbox's close button ends at {close['right']} px")
def test_the_file_name_stays_in_the_desktop_lightbox(lightbox_bar):
assert lightbox_bar["1024"]["boxes"][".video-title"]["width"] > 200
|