aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_video_detail_measured.py
blob: e72af0f25b33f0db866df70b42628f3b7735e38f (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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""
The show detail modal, measured: the season picker must not move.

Reported against a real library. Three faults, one complaint — opening a
different season of the same show made the whole modal jump:

  * a season with a twelve-line synopsis and one with a two-line synopsis put
    the season picker, and every episode under it, ten lines apart. The
    synopsis is now a constant three lines for a multi-season show — clamped
    from above and pinned from below to the same number, so the picker does
    not move at all. Five was tried first and left a short synopsis sitting
    over three blank lines;
  * the cast line was however many lines the cast was long;
  * the season picker itself was a row of pills with `overflow-x: auto`, so a
    show with a dozen seasons hid most of them behind a horizontal scrollbar
    that is close to unusable on a phone.

The fixes are geometric, so the assertions are rectangles rather than
declarations, in the manner `test_layout_measured.py` established: reading
`-webkit-line-clamp: 2` out of the stylesheet says nothing about where the
thing below it lands.

The markup here is what `video-app.js`'s `OverviewText` and `SeasonMenu`
render, class for class — `clamped` always while collapsed, `has-more` only
when the text actually overflows (measured in the browser, not counted), and
`reserved` only for a multi-season show, which is the only place a season can
change underneath the picker.
"""

import json
import shutil
import subprocess
import textwrap
from pathlib import Path

import pytest

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

SHORT_SYNOPSIS = "Two teams share a city, and one of them is lying about it."

LONG_SYNOPSIS = (
    "A courier takes a parcel across a border that closed the week before, "
    "and finds the town on the other side keeping an arrangement nobody "
    "there is willing to describe out loud. The season follows the four "
    "households that made it, the clerk who has been filing the paperwork "
    "for eleven years without reading it, and the inspector sent to find "
    "out why a road that leads nowhere is resurfaced every spring. What "
    "began as an accounting discrepancy turns into a question about who "
    "the town has been paying, and for what, and whether the answer was "
    "ever a secret or merely something no one had asked about."
)

SHORT_CAST = "A Performer, Another Performer"
LONG_CAST = ", ".join(f"Performer Number {n}" for n in range(1, 11))

SEASONS = [(0, "Specials", "4 episodes"),
           *((n, f"Season {n}", "22 episodes") for n in range(1, 13))]


def _detail(block_id, synopsis, cast, *, reserved=True, has_more=True, menu_open=False):
    """One `.video-detail` as VideoDetailModal renders it for a show."""
    overview_cls = " ".join(
        ["video-detail-overview", "clamped"]
        + (["has-more"] if has_more else [])
        + (["reserved"] if reserved else []))
    toggle = ('<button class="video-overview-toggle">… Read more</button>'
              if has_more else "")
    options = "".join(
        f'<button class="video-season-option{" active" if n == 5 else ""}">'
        f'<span class="video-season-option-name">{name}</span>'
        f'<span class="video-season-option-count">{count}</span></button>'
        for n, name, count in SEASONS)
    panel = (f'<div class="video-season-options" role="listbox">{options}</div>'
             if menu_open else "")
    # Each block sits in a container of whole-pixel height. Stacked in normal
    # flow they start at fractional offsets — a modal's own content height is
    # not an integer number of device pixels — and two identical layouts an
    # eighth of a pixel apart round to tops one pixel apart, which looks like
    # a defect in the thing being measured and is not one.
    return f"""
    <div style="position: relative; height: 760px">
    <div id="{block_id}" class="video-detail">
      <div class="video-top-bar">
        <span class="video-title">A Show With Several Seasons</span>
        <button class="video-close">x</button>
      </div>
      <div class="video-detail-body">
        <p class="video-detail-source">File: shows/S05/e01.mkv &middot; TMDB #0000</p>
        <div class="video-overview-wrap">
          <p class="{overview_cls}">{toggle}{synopsis}</p>
        </div>
        <p class="video-detail-facts">
          &#9733; 8.4 &middot; Mystery, Drama &middot; 1993 &middot; Director: A Creator
        </p>
        <p class="video-detail-cast">{cast}</p>
        <div class="video-admin-actions">
          <button class="admin-btn video-fix-match">Fix match&hellip;</button>
          <button class="admin-btn">Re-match</button>
        </div>
        <div class="video-season-menu">
          <button class="video-season-trigger">
            <span class="video-season-current">Season 5 &middot; 1997</span>
            <svg class="icon video-season-caret" viewBox="0 0 24 24"></svg>
          </button>
          {panel}
        </div>
        <div class="video-season-list">
          <div class="video-season">
            <button class="video-episode-row">
              <span class="video-episode-label">S05E01 &middot; An Episode</span>
              <span class="video-episode-meta">44min 1280x720</span>
            </button>
          </div>
        </div>
      </div>
    </div>
    </div>
    """


FRAGMENT = textwrap.dedent(f"""
    <!-- One line of the same text at the same size: every height below is
         asserted in these, not in pixels pinned to a font stack. -->
    <div id="oneline" class="video-detail"><div class="video-detail-body">
      <div class="video-overview-wrap"><p class="video-detail-overview">One</p></div>
      <p class="video-detail-cast">{SHORT_CAST}</p>
    </div></div>
    """) + \
    _detail("long", LONG_SYNOPSIS, LONG_CAST) + \
    _detail("short", SHORT_SYNOPSIS, LONG_CAST, has_more=False) + \
    _detail("movie", SHORT_SYNOPSIS, SHORT_CAST, reserved=False, has_more=False) + \
    _detail("open", LONG_SYNOPSIS, LONG_CAST, menu_open=True)

WIDTHS = [320, 360, 412, 768, 1024]
SELECTORS = [
    "#oneline .video-detail-overview", "#oneline .video-detail-cast",
    "#long .video-detail-overview", "#long .video-detail-cast",
    "#long .video-season-menu", "#long .video-detail-body",
    "#long .video-overview-toggle",
    "#short .video-detail-overview",
    "#short .video-season-menu", "#short .video-detail-body",
    "#movie .video-detail-overview",
    "#open .video-season-options", "#open.video-detail",
]


@pytest.fixture(scope="module")
def measured(tmp_path_factory):
    """One browser for every width — launching one apiece cost three minutes."""
    fragment = tmp_path_factory.mktemp("videodetail") / "fragment.html"
    fragment.write_text(FRAGMENT)
    proc = subprocess.run(
        ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
         str(fragment), *SELECTORS],
        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


def _box(measured, width, selector):
    box = measured[str(width)]["boxes"][selector]
    assert box is not None, f"{selector} did not render at {width} px"
    return box


def _line(measured, width):
    """One line of synopsis, at this width."""
    return _box(measured, width, "#oneline .video-detail-overview")["height"]


@pytest.mark.parametrize("width", WIDTHS)
def test_the_season_picker_does_not_move_with_the_synopsis(measured, width):
    """The complaint itself.

    The two blocks differ in one thing only, which is the one thing that
    changes when a season is selected: the synopsis. Everything above the
    picker is show-level and identical between them. One is a single line, the
    other twelve — the widest gap a season change can produce — and the answer
    must be the same pixel, not a narrow band. Measured from each modal's own
    body, since the blocks are stacked down the page.
    """
    def offset(block):
        return (_box(measured, width, f"#{block} .video-season-menu")["top"]
                - _box(measured, width, f"#{block} .video-detail-body")["top"])

    long_off, short_off = offset("long"), offset("short")
    assert long_off == short_off, (
        f"at {width} px the season picker sits {long_off} px down for a long "
        f"synopsis and {short_off} px down for a short one — a "
        f"{abs(long_off - short_off)} px jump on every season change")


@pytest.mark.parametrize("width", WIDTHS)
def test_the_synopsis_is_three_lines_whatever_it_says(measured, width):
    """Clamped from above and pinned from below to the same three lines."""
    line = _line(measured, width)
    for block in ("long", "short"):
        h = _box(measured, width, f"#{block} .video-detail-overview")["height"]
        assert abs(h - 3 * line) <= 4, (
            f"the {block} synopsis is {h} px at {width} px, and one line is "
            f"{line} px — that is {h / line:.1f} lines, not three")


@pytest.mark.parametrize("width", WIDTHS)
def test_a_read_more_link_lands_on_the_last_line(measured, width):
    """The float trick, as geometry.

    `-webkit-line-clamp` cannot leave room after its ellipsis, so the toggle is
    floated into the last line box instead. If the float spacer is dropped the
    link goes to line one; if `clear: right` is dropped it shares a line with
    the text somewhere in the middle.
    """
    line = _line(measured, width)
    top = _box(measured, width, "#long .video-detail-overview")["top"]
    link = _box(measured, width, "#long .video-overview-toggle")
    offset = link["top"] - top
    assert 2 * line - 4 <= offset <= 3 * line, (
        f"the read-more link starts {offset} px into a three-line box of "
        f"{line} px lines at {width} px — it is not on the third line")
    assert link["offRight"] == 0, (
        f"{link['offRight']} px of the read-more link is off a {width} px screen")


@pytest.mark.parametrize("width", WIDTHS)
def test_a_short_synopsis_with_nothing_to_expand_stays_short(measured, width):
    """The spacer is scoped to `.has-more` for a reason.

    `overflow: hidden` makes the paragraph a block formatting context, so it
    contains the four-line-tall float and grows to it. A movie or a
    single-season show — nothing below it can change — must keep its natural
    height, with no floor under it either.
    """
    line = _line(measured, width)
    h = _box(measured, width, "#movie .video-detail-overview")["height"]
    assert h <= 2 * line + 4, (
        f"a two-line synopsis with no season under it occupies {h} px at "
        f"{width} px, which is {h / line:.1f} lines of mostly nothing")


@pytest.mark.parametrize("width", WIDTHS)
def test_the_cast_is_two_lines_however_long_the_cast_is(measured, width):
    """Ten names and two names must give the modal the same height."""
    ref = _box(measured, width, "#oneline .video-detail-cast")["height"]
    long_cast = _box(measured, width, "#long .video-detail-cast")["height"]
    assert long_cast <= 2 * ref + 4, (
        f"a ten-name cast takes {long_cast} px at {width} px against {ref} px "
        f"for one line — {long_cast / ref:.1f} lines, and the clamp is two")


@pytest.mark.parametrize("width", WIDTHS)
def test_the_open_season_menu_stays_inside_the_modal(measured, width):
    """It replaced a horizontally scrolling pill row; it must not overflow
    sideways in turn, and thirteen seasons must not run off the bottom."""
    panel = _box(measured, width, "#open .video-season-options")
    modal = _box(measured, width, "#open.video-detail")
    assert panel["offLeft"] == 0 and panel["offRight"] == 0, (
        f"the season menu hangs {panel['offLeft']} px off the left and "
        f"{panel['offRight']} px off the right of a {width} px screen")
    assert panel["left"] >= modal["left"] and panel["right"] <= modal["right"], (
        f"the season menu ({panel['left']}..{panel['right']}) is wider than "
        f"the modal it belongs to ({modal['left']}..{modal['right']})")
    assert panel["height"] <= 320, (
        f"thirteen seasons make a {panel['height']} px menu — it is meant to "
        "scroll inside itself, not to become the page")


@pytest.mark.parametrize("width", [320, 360, 412])
def test_the_page_does_not_scroll_sideways(measured, width):
    """The pill row's own scrollbar is gone; nothing may replace it."""
    r = measured[str(width)]
    assert r["docScrollW"] <= r["viewport"]["w"], (
        f"the document scrolls to {r['docScrollW']} px on a {width} px screen")