diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_layout_responsive.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_layout_responsive.py | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py new file mode 100644 index 0000000..741e728 --- /dev/null +++ b/packages/meshbay-hub/tests/test_layout_responsive.py @@ -0,0 +1,159 @@ +""" +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. That is weak evidence and it is what is +available: there is no browser in this suite, so a layout cannot be measured +here, only its inputs pinned. What they 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 ─────────────────────────────────────────────────── + +APP = STATIC / "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): + for event in ("resize", "orientationchange"): + assert f"addEventListener('{event}', fit)" in app + assert "visualViewport?.addEventListener('resize', fit)" in app + assert "removeEventListener('resize', fit)" in app, "the listener must be released" + + +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" |