aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_layout_responsive.py
blob: 741e7289796cdd2bba305f8155000398e2fa98fb (plain) (blame)
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
"""
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"