""" 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"