summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_sticky_header.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_sticky_header.py')
-rw-r--r--packages/meshbay-hub/tests/test_sticky_header.py297
1 files changed, 297 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_sticky_header.py b/packages/meshbay-hub/tests/test_sticky_header.py
new file mode 100644
index 0000000..32b8124
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_sticky_header.py
@@ -0,0 +1,297 @@
+"""
+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_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")