diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_sticky_header.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_sticky_header.py | 297 |
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") |