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
|
"""
Scrolling a library does not cost you the controls that steer it.
Files, Videos, Music and Photos are read by scrolling, and everything that says
*what* is being read — which application, where in the tree, which filter,
which column sorts — used to leave with the first screenful. Those controls pin
under the navigation bar now, in three bands: the group's tab bar (the search
field, on the Search page), the application's own toolbar, and the file table's
column heads.
Two of the three offsets are `calc()` over a height nothing declares: the
toolbar wraps to three rows at a phone width, grows a field while a folder is
being named, and loses its filter on the Search page, so sticky.js measures it
and publishes `--chrome-h` / `--toolbar-h`. That is exactly the arrangement
CLAUDE.md has been bitten by twice — two subtractions in different files, each
correct on its own, the page a few pixels wrong at every window size and
nothing in either file to show it. A number in a stylesheet cannot be read to
find that out. So this scrolls the shipped pages in a real browser and measures
the rectangles: every band pinned, edge to edge, nothing overlapping, nothing
showing between.
Three widths, for three different reasons. At 1100 the toolbar is one row; at
420 it is three, and a stack that only ever adds up on a desktop is the whole
class of fault this is written against; 390 is an actual handset. And both
engines: Firefox is half of MeshBay's readers, `position: sticky` is exactly
the kind of thing engines disagree about, and the first report that a band did
not pin at all came from a phone.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
HARNESS = Path(__file__).parent / "harness" / "sticky_header_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")
ENGINES = ["chrome"] + (["firefox"] if shutil.which("firefox") else [])
# A rectangle read back from the browser is rounded, and a border can land
# either side of a half pixel. One pixel of slack, never more — the faults this
# exists for are tens of pixels.
SLACK = 1
# Eleven views (seven in a group, four on the Search page) at four widths.
EXPECTED_CASES = 44
@pytest.fixture(scope="module", params=ENGINES)
def measured(request):
proc = subprocess.run(["python3", str(HARNESS), "--engine", request.param],
capture_output=True, text=True, timeout=900)
assert proc.returncode == 0, (
f"probe failed in {request.param}: {proc.stdout}{proc.stderr}")
cases = json.loads(proc.stdout)
assert cases, f"the probe measured nothing in {request.param}"
return cases
def test_every_view_was_reached(measured):
"""
A case that never got to its application reports an error rather than an
empty measurement — and an empty measurement would satisfy every assertion
below by having nothing to assert on.
"""
broken = [f"{c.get('engine')} {c['name']}: {c['error']}"
f"\n console: {c.get('logs')}"
f"\n on screen: {c.get('text')}"
for c in measured if "error" in c]
assert not broken, "\n".join(broken)
assert len(measured) == EXPECTED_CASES, (
f"expected {EXPECTED_CASES} measurements, got {len(measured)}")
def test_media_toolbars_were_measured_with_their_page_arrows(measured):
"""
Videos and Music put previous/next arrows in the pinned toolbar once a
library is longer than a page (`pager.js`). The fixture sets a page smaller
than its library so every measurement above covers the toolbar with them;
this fails the fixture if that stops being true.
"""
media = [c for c in measured if c["name"].split(" @")[0].endswith(("videos", "music"))]
assert media, "no Videos or Music case was measured"
bare = [c["name"] for c in media if not c.get("pager")]
assert not bare, "measured without page arrows: " + ", ".join(bare)
def test_the_page_really_scrolled(measured):
"""
Everything below compares a scrolled page with an unscrolled one. A view
that fits its window compares it with itself and proves nothing — so this
fails the fixture, not the layout, before a silent pass can happen.
"""
flat = [c["name"] for c in measured if c["scrollY"] <= 0]
assert not flat, "nothing scrolled, so nothing was tested: " + ", ".join(flat)
def test_the_bands_stack_under_the_navigation_bar(measured):
"""
From the top: the navigation bar, then each band, each starting exactly one
gap below the one above it — the gap being the upper band's own
`margin-bottom`, which it keeps when it pins and paints as a ring. Land
short of it and two bands come into contact, which is what the first
version shipped and what was reported: two rounded panels flush against
each other in Files, and the row of controls glued to the tab bar in
Videos, Music and Photos. Land past it and a stripe of the list shows
through between them.
"""
faults = []
for case in measured:
after = case["after"]
edge = after[".nav"]["bottom"]
for selector in case["bands"]:
band = after[selector]
if abs(band["top"] - edge) > SLACK:
faults.append(
f"{case['engine']} {case['name']}: {selector} pinned at "
f"y={band['top']}, expected y={edge}")
edge = band["bottom"] + band["gap"]
assert not faults, "\n".join(faults)
def test_a_band_keeps_the_gap_it_has_in_the_flow(measured):
"""
Pinning must not change the spacing. Measured against the same page before
it was scrolled: the distance from a band to the next one down is the same
whether it is pinned or sitting in the flow, so nothing shifts at the
moment it pins.
"""
faults = []
for case in measured:
for upper, lower in zip(case["bands"], case["bands"][1:]):
flow = case["before"][lower]["top"] - case["before"][upper]["bottom"]
pinned = case["after"][lower]["top"] - case["after"][upper]["bottom"]
if abs(flow - pinned) > SLACK:
faults.append(
f"{case['engine']} {case['name']}: {upper} → {lower} is "
f"{flow}px apart in the flow and {pinned}px pinned")
assert not faults, "\n".join(faults)
def test_bands_stay_pinned_at_the_bottom_of_the_page(measured):
"""
A sticky element only sticks inside its own parent's box, so a band whose
containing block ends before the page does comes unstuck partway down —
invisible to any test that scrolls a fixed amount. Scrolled to the very
end, every band is still where it was.
"""
faults = []
for case in measured:
for selector in case["bands"]:
if abs(case["bottom"][selector]["top"]
- case["after"][selector]["top"]) > SLACK:
faults.append(
f"{case['engine']} {case['name']}: {selector} is at "
f"y={case['after'][selector]['top']} partway down and "
f"y={case['bottom'][selector]['top']} at the end")
assert not faults, "\n".join(faults)
def test_each_band_stays_whole(measured):
"""
Pinned and *entire*: a band clipped by the navigation bar above it, or with
its own content spilling out of the rectangle it reserved, is not visible
just because its top edge is in the right place.
"""
faults = []
for case in measured:
for selector in case["bands"]:
before, after = case["before"][selector], case["after"][selector]
if after["height"] <= 0:
faults.append(f"{case['name']}: {selector} has no height at all")
elif abs(after["height"] - before["height"]) > SLACK:
faults.append(
f"{case['name']}: {selector} is {after['height']}px pinned "
f"but {before['height']}px in the flow — pinning resized it")
if after["top"] < case["after"][".nav"]["bottom"] - SLACK:
faults.append(
f"{case['name']}: {selector} runs up behind the navigation bar")
assert not faults, "\n".join(faults)
def test_the_list_is_what_moves(measured):
"""
The bands hold still and the content goes past them — not the other way
round, and not everything holding still because the page never moved. The
content is checked to have travelled by the full scroll: a band that
dragged its list along with it would show up here as a short journey.
"""
faults = []
for case in measured:
selector = case["content"]
before, after = case["before"][selector], case["after"][selector]
travelled = before["top"] - after["top"]
if abs(travelled - case["scrollY"]) > SLACK:
faults.append(
f"{case['name']}: {selector} moved {travelled}px while the window "
f"scrolled {case['scrollY']}px")
assert not faults, "\n".join(faults)
def test_the_offsets_come_from_the_measurement(measured):
"""
The published heights are the bands' own, not a number that happens to
agree at one width. This is the assertion that fails if sticky.js stops
observing — a stale `--toolbar-h` still stacks perfectly at the width it
was measured at, and only at that one.
"""
faults = []
for case in measured:
bands = case["bands"]
upper = case["after"][bands[0]]
chrome = upper["height"] + upper["gap"]
if case["chromeH"] != f"{chrome}px":
faults.append(f"{case['engine']} {case['name']}: --chrome-h is "
f"{case['chromeH']!r}, {bands[0]} is {upper['height']}px "
f"tall over a {upper['gap']}px gap")
# Only Files pins anything below its toolbar, so only Files publishes
# a toolbar height; the others leave the property withdrawn.
if ".file-table th" in bands:
mid = case["after"][bands[1]]
toolbar = mid["height"] + mid["gap"]
if case["toolbarH"] != f"{toolbar}px":
faults.append(f"{case['engine']} {case['name']}: --toolbar-h is "
f"{case['toolbarH']!r}, {bands[1]} is "
f"{mid['height']}px tall over a {mid['gap']}px gap")
elif case["toolbarH"] not in ("", "0px"):
faults.append(f"{case['engine']} {case['name']}: --toolbar-h left "
f"behind as {case['toolbarH']!r} by a view with no table")
assert not faults, "\n".join(faults)
def test_no_view_scrolls_sideways(measured):
"""
Nothing here may be wider than the window.
This is the fault that was reported from a phone, and it presented as the
sticky header not working at all — including the navigation bar, which had
been `position: sticky` since long before any of this. That is the tell: on
Android a document wider than the screen leaves everything pinned attached
to a viewport the reader can no longer see, so a header that is doing
exactly what it was told looks like a header that was never pinned.
The cause was a table column, and the reason no measurement here found it
first is worth keeping: the fixture said `note-007.txt` and `un groupe`,
which fit any screen. A fixture narrower than real data tests the fixture.
It now carries the names a music library actually has, and walks into a
folder, which is where the rows are directories — and a directory's name
cell was the one that had no wrapping rule on it.
"""
faults = []
for case in measured:
if case["overflowX"] > 0:
faults.append(
f"{case['engine']} {case['name']}: the page is "
f"{case['overflowX']}px wider than its window"
+ (f" — widest: {'; '.join(case['widest'])}" if case["widest"] else ""))
assert not faults, "\n".join(faults)
def test_the_group_column_is_dropped_at_phone_widths(measured):
"""
Search's group column goes at phone widths, where there is no room for it
and the file name is what the screen is short of — the same treatment the
type and date columns already get.
Nothing is lost with it, which is why it was the column to drop: the Search
page re-roots every result under a folder named after its group, so a
reader is always inside exactly one group and the breadcrumb above the
table names it. The column repeated that on every row.
Both halves are asserted, and the wide half is the one that matters: a
column that stopped rendering altogether would satisfy "hidden on a phone"
perfectly.
"""
present = {c["name"]: (c["width"], c["groupColumn"]) for c in measured
if c["groupColumn"] != "absent"}
assert present, "no case rendered a group column at all"
wide = {n: g for n, (w, g) in present.items() if w > 768}
narrow = {n: g for n, (w, g) in present.items() if w <= 768}
assert wide and narrow, f"need both sides of the breakpoint, got {present}"
assert all(g == "shown" for g in wide.values()), (
f"the group column is missing on a wide screen: {wide}")
assert all(g == "hidden" for g in narrow.values()), (
f"the group column still takes room on a phone: {narrow}")
def test_the_toolbar_wraps_at_a_phone_width(measured):
"""
The reason the heights are measured rather than written down. If the
toolbar were the same height at 420 as at 1100, the whole mechanism would
be arithmetic nobody needs — and this test would be the one to say so
before the next reader replaces it with a constant.
"""
tall = {c["view"]: c["after"][".file-toolbar"]["height"]
for c in measured if c["width"] == 420 and ".file-toolbar" in c["bands"]}
wide = {c["view"]: c["after"][".file-toolbar"]["height"]
for c in measured if c["width"] == 1100 and ".file-toolbar" in c["bands"]}
assert tall and tall.keys() == wide.keys()
for view in tall:
assert tall[view] > wide[view], (
f"{view}: the file toolbar is {tall[view]}px at 420 and "
f"{wide[view]}px at 1100 — it no longer wraps, and the column heads "
f"could pin against a constant")
|