aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_page_does_not_scroll.py
blob: 9f983af94bcf1b55c349cdabab5cb3f7f0621e62 (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
"""
A page whose content fits the window must not offer a scrollbar.

Twice now. First the sign-in card: `.layout` and `.page-center` each reserved
`100vh - 52px` and the second sat inside the first's 24px padding, so the
document was 48px too tall at every window size. Then the chat tab: the panel is
sized from JS to `viewport - top - 16`, which puts its bottom 16px above the
fold — but `.main` adds 24px of padding below it, so the document came out
**exactly 8px too tall, at every window size**, which is what "there is always a
scrollbar" means.

Neither is visible in the stylesheet. Both are one subtraction against another,
in different files, and the only way to see them is to measure the document
against the window — which is what this does, running the real `fit()` lifted
out of `app.js` rather than a copy of it.

The group fragment carries `.sticky-chrome`, as `group-page.js` does: the tab
bar is `position: sticky` inside it, and a sticky element that is measured
without the class it pins under is measured in its old, ordinary flow.
"""

import json
import shutil
import subprocess
from pathlib import Path

import pytest

HARNESS = Path(__file__).parent / "harness" / "scroll_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")

HEIGHTS = [700, 900, 1200]

NAV_AND_SIDEBAR = """
<nav class="nav">
  <div class="nav-left"><button class="nav-hamburger">&#9776;</button>
    <a class="nav-brand" href="#/">MeshBay</a></div>
  <div class="nav-right"><a class="nav-notif" href="#/">&#128276;</a>
    <div class="user-menu"><button class="nav-btn">someone</button></div></div>
</nav>
"""

CHAT_TAB = NAV_AND_SIDEBAR + """
<div class="layout">
  <aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
  <main class="main">
    <div class="sticky-chrome">
    <div class="group-header"><h2>a group</h2></div>
    <div class="group-tabs">
      <button class="group-tab active">Chat</button>
      <button class="group-tab">Files</button>
      <button class="group-tab">Settings</button>
    </div>
    <div class="chat-panel">
      <div class="chat-messages"><p>hello</p></div>
      <div class="chat-composer"><input type="text" /><button class="admin-btn">Send</button></div>
    </div>
    </div>
  </main>
</div>
"""

SHORT_PAGE = NAV_AND_SIDEBAR + """
<div class="layout">
  <aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
  <main class="main">
    <h2>a group</h2>
    <div class="settings-section"><p>not much here</p></div>
  </main>
</div>
"""


def _measure(fragment: str, tmp_path: Path) -> dict:
    path = tmp_path / "fragment.html"
    path.write_text(fragment, encoding="utf-8")
    proc = subprocess.run(
        ["python3", str(HARNESS), str(path), ",".join(str(h) for h in HEIGHTS)],
        capture_output=True, text=True, timeout=180)
    assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
    out = json.loads(proc.stdout)
    assert "error" not in out, f"no measurement: {out}"
    return out


@pytest.fixture(scope="module")
def chat(tmp_path_factory):
    return _measure(CHAT_TAB, tmp_path_factory.mktemp("chat"))


@pytest.fixture(scope="module")
def short(tmp_path_factory):
    return _measure(SHORT_PAGE, tmp_path_factory.mktemp("short"))


@pytest.mark.parametrize("height", HEIGHTS)
def test_the_chat_tab_fits_its_window(chat, height):
    r = chat[str(height)]
    assert r["overflow"] <= 0, (
        f"the document is {r['overflow']}px taller than the {height}px window — "
        f"a scrollbar on the chat tab. Past the fold: {r['past']}")


@pytest.mark.parametrize("height", HEIGHTS)
def test_nothing_on_the_chat_tab_hangs_below_the_fold(chat, height):
    """The composer is the one that matters: a chat you cannot type in."""
    assert chat[str(height)]["past"] == []


@pytest.mark.parametrize("height", HEIGHTS)
def test_the_chat_panel_uses_the_room_it_has(chat, height):
    """The correction must not overshoot. The panel should end just above the
    fold, not halfway up the page — a 240px chat in a 1200px window would pass
    every assertion above and be useless."""
    panel = chat[str(height)]["panel"]
    assert panel, "no chat panel in the measurement"
    gap = height - panel["bottom"]
    assert 0 <= gap <= 40, (
        f"the panel ends {gap}px above the fold at {height}px")


@pytest.mark.parametrize("height", HEIGHTS)
def test_a_short_page_does_not_scroll_either(short, height):
    """The control: without this, a chat panel shrunk to nothing would pass."""
    r = short[str(height)]
    assert r["overflow"] <= 0, f"{r['overflow']}px of overflow with no content"