aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_video_detail_measured.py280
1 files changed, 280 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_video_detail_measured.py b/packages/meshbay-hub/tests/test_video_detail_measured.py
new file mode 100644
index 0000000..e72af0f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_video_detail_measured.py
@@ -0,0 +1,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")