summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_video_detail_measured.py
blob: 1ac858603927b23f296252e329fabc2858c57f92 (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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
"""
The show detail modal, measured: nothing above the episode list may move.

Reported against a real library, three times running, because the jump had
more than one cause and fixing one left the others. Opening a different season
of the same show moved the season picker and everything under it, so the
control just clicked was no longer under the pointer.

  * The synopsis was however many lines TMDB wrote — two for one season,
    twelve for the next.
  * The cast line was however long the cast was.
  * The episode list is however many episodes the season has, and it lived in
    the same scroll box as everything else, so a thirteen-episode season made
    a taller modal than a six-episode one.
  * `.video-overlay` centres its child, so that taller modal also started
    *higher* up the screen: the whole dialog rose.
  * A season that overflowed showed a scrollbar, which narrowed the content by
    a scrollbar's width, which re-wrapped the file path at the top and shifted
    everything below it by a line.

The last three are the ones a fixed-height synopsis cannot help with, and they
are why this file measures the modal's own rectangle and not just its text.

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 season menu's own panel is not here: it is `position: fixed`, placed by
`placeSeasonPanel()` rather than by the stylesheet, and
`test_season_panel_placement.py` holds that arithmetic to the room it is
given.

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

import json
import re
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."
)

CAST = ", ".join(f"Performer Number {n}" for n in range(1, 11))

# A path long enough to sit near the wrap boundary, which is what made a
# scrollbar's width visible as a one-line shift of everything below it.
SOURCE = ("File: shows/A.Show.With.Several.Seasons/S01/"
          "A.Show.With.Several.Seasons.S01E01.FRENCH.720p.BluRay.x264-grp.mkv")

# The modal is `min(calc(100vh - 100px), 760px)` tall and top-anchored 60px
# down, so a block of this height holds one whole overlay at the probe's 740px
# iframe height with room to spare.
BLOCK_H = 800


def _episodes(n):
    return "".join(f"""
      <button class="video-episode-row">
        <span class="video-episode-label">S05E{i:02d} &middot; An Episode</span>
        <span class="video-episode-meta">44min 1280x720</span>
      </button>""" for i in range(1, n + 1))


def _detail(block_id, synopsis, *, episodes=6, steady=True, has_more=True):
    """One `.video-detail` inside a real `.video-overlay`, as it is rendered.

    The overlay matters: it is what centres the modal, and the centring is
    half of why the dialog rose. `position: absolute` (the rule says `fixed`)
    keeps `inset: 0` but scopes it to the sized block below, so several of
    these can be measured in one pass.

    Each block gets a whole-pixel height. Stacked in normal flow they would
    start at fractional offsets — a modal's 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 reads as a defect in the thing
    being measured and is not one.
    """
    overview_cls = " ".join(
        ["video-detail-overview", "clamped"]
        + (["has-more"] if has_more else [])
        + (["reserved"] if steady else []))
    toggle = ('<button class="video-overview-toggle">… Read more</button>'
              if has_more else "")
    return f"""
    <div style="position: relative; height: {BLOCK_H}px">
    <div id="{block_id}-ov" class="video-overlay" style="position: absolute">
    <div id="{block_id}" class="video-detail{' video-detail-steady' if steady else ''}">
      <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">{SOURCE}
          <span class="video-detail-tmdbref"> &middot; TMDB #0000</span></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>
        </div>
        <div class="video-season-list">
          <div class="video-season">{_episodes(episodes)}</div>
        </div>
      </div>
    </div>
    </div>
    </div>
    """


FRAGMENT = textwrap.dedent("""
    <!-- 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">A Performer, Another Performer</p>
    </div></div>
    """) + \
    _detail("long", LONG_SYNOPSIS) + \
    _detail("short", SHORT_SYNOPSIS, has_more=False) + \
    _detail("many", SHORT_SYNOPSIS, episodes=24, has_more=False) + \
    _detail("movie", SHORT_SYNOPSIS, episodes=1, steady=False, has_more=False)

WIDTHS = [320, 360, 412, 768, 1024]

# The three blocks whose season picker must land on the same pixel: a long and
# a short synopsis, and a short and a long season.
STEADY = ["long", "short", "many"]

SELECTORS = [
    "#oneline .video-detail-overview", "#oneline .video-detail-cast",
    "#long .video-overview-toggle", "#long .video-detail-cast",
    "#movie.video-detail", "#movie .video-detail-overview",
] + [
    sel.format(b) for b in STEADY for sel in
    ("#{0}.video-detail", "#{0}-ov.video-overlay", "#{0} .video-detail-body",
     "#{0} .video-season-menu", "#{0} .video-detail-overview",
     "#{0} .video-detail-source", "#{0} .video-season-list")
]


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


def _picker_offset(measured, width, block):
    """How far the season picker sits inside its own modal body."""
    return (_box(measured, width, f"#{block} .video-season-menu")["top"]
            - _box(measured, width, f"#{block} .video-detail-body")["top"])


@pytest.mark.parametrize("width", WIDTHS)
def test_the_season_picker_does_not_move_between_seasons(measured, width):
    """The complaint itself, over every way one season differs from another.

    `long` and `short` differ only in the length of the synopsis; `short` and
    `many` differ only in the number of episodes. Both are things a season
    change varies, and neither may move the picker by a pixel.
    """
    offsets = {b: _picker_offset(measured, width, b) for b in STEADY}
    assert len(set(offsets.values())) == 1, (
        f"at {width} px the season picker sits at {offsets} px inside its own "
        "modal body — a jump on every season change")


@pytest.mark.parametrize("width", WIDTHS)
def test_the_modal_itself_does_not_move_or_resize_between_seasons(measured, width):
    """The half a fixed-height synopsis cannot reach.

    `.video-overlay` centres its child, so a season with four times the
    episodes built a taller modal that started higher up the screen — the
    title bar and the close button moved too, not just the picker.
    Top-anchored and one height for every season, the modal is the same
    rectangle whatever the season holds.
    """
    boxes = {b: _box(measured, width, f"#{b}.video-detail") for b in STEADY}
    # Against each block's own overlay: the blocks are stacked down the page,
    # so absolute tops differ by construction and say nothing.
    tops = {b: box["top"] - _box(measured, width, f"#{b}-ov.video-overlay")["top"]
            for b, box in boxes.items()}
    heights = {b: box["height"] for b, box in boxes.items()}
    assert len(set(tops.values())) == 1, (
        f"at {width} px the modal's top edge is at {tops} — the dialog moves "
        "under the pointer when the season does")
    assert len(set(heights.values())) == 1, (
        f"at {width} px the modal is {heights} px tall — one season's episode "
        "count is deciding the size of the dialog")


@pytest.mark.parametrize("width", WIDTHS)
def test_only_the_episode_list_absorbs_the_episode_count(measured, width):
    """Where the difference is supposed to go instead.

    A twenty-four-episode season has to overflow something. It must be the
    list, which scrolls inside a modal of fixed size, and not the modal.
    """
    for block in STEADY:
        lst = _box(measured, width, f"#{block} .video-season-list")
        modal = _box(measured, width, f"#{block}.video-detail")
        assert lst["top"] + lst["height"] <= modal["top"] + modal["height"] + 1, (
            f"the {block} episode list runs past the bottom of its own modal "
            f"at {width} px — it is not the thing scrolling")


@pytest.mark.parametrize("width", WIDTHS)
def test_the_file_path_wraps_the_same_way_for_every_season(measured, width):
    """One line of the jump, from a different direction.

    This is the assertion, but not the whole guard: see
    `test_the_episode_list_reserves_its_scrollbar` below for why the scrollbar
    half of it cannot be measured here.
    """
    heights = {b: _box(measured, width, f"#{b} .video-detail-source")["height"]
               for b in STEADY}
    assert len(set(heights.values())) == 1, (
        f"at {width} px the file path is {heights} px tall depending on the "
        "season — something above it is changing how it wraps")


def test_the_episode_list_reserves_its_scrollbar():
    """A declaration, deliberately, and the one place in this file that is.

    A season that overflows its list draws a scrollbar; one that does not,
    does not. That is a scrollbar's width of content, which re-wraps the file
    path above and shifts everything below by a line — the same jump by a
    different route, and it is visible in the reported screenshots.

    It cannot be measured here. Headless Chrome gives the probe overlay
    scrollbars, which take no width, so the geometric test above passes with
    or without the rule. Reading the declaration is worth less than a
    rectangle — `test_layout_responsive.py` says so at length — but it is
    worth more than a test that cannot fail.
    """
    css = (STATIC / "style.css").read_text()
    rule = re.search(
        r"\.video-detail\.video-detail-steady \.video-season-list \{([^}]*)\}", css)
    assert rule, "the steady episode-list rule is gone"
    assert "scrollbar-gutter: stable" in rule.group(1), (
        "without a reserved gutter a scrolling season is a scrollbar narrower "
        "than a non-scrolling one, and everything above the list re-wraps")


@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 STEADY:
        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_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_a_movie_is_not_given_a_show_sized_modal(measured, width):
    """None of this is scoped to every video.

    A movie and a single-season show have no season to switch to. A fixed
    height and a floor under the synopsis would buy them nothing but empty
    space, so neither is applied and the modal is content-sized as before.
    """
    line = _line(measured, width)
    h = _box(measured, width, "#movie .video-detail-overview")["height"]
    assert h <= 2 * line + 4, (
        f"a one-line synopsis with no season under it occupies {h} px at "
        f"{width} px, which is {h / line:.1f} lines of mostly nothing")
    movie = _box(measured, width, "#movie.video-detail")["height"]
    steady = _box(measured, width, "#short.video-detail")["height"]
    assert movie < steady, (
        f"a one-episode movie modal is {movie} px against a show's {steady} px "
        "— the show-only fixed height has escaped its scope")


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