aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-02 03:13:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-02 03:23:46 +0200
commit12b6dc4dd3e009f2e844d87181800aa12d07a3a6 (patch)
treec118ff1efee9f38de7579d4cb75ec6f4114e27d9 /packages/meshbay-hub/tests
parentf7917bdde37fe089485bb2b65c5504315dcc9c56 (diff)
downloadmeshbay-12b6dc4dd3e009f2e844d87181800aa12d07a3a6.tar.gz
fix(hub): steady the show detail modal, and give a series its director
Opening a different season of the same show moved everything under the synopsis, which is where the season control and the episode list are, so the thing just clicked was no longer under the pointer. - The synopsis is exactly three lines for a multi-season show, with a "read more" link floated into the third line box (-webkit-line-clamp only ever puts its ellipsis at the end of the last line and leaves no room after it). Clamped from above and pinned from below to the same number: a constant, not a range — a season summary runs two lines and the next one twelve, and a band still reads as a jump. Whether three lines is all of it depends on the modal's width, so it is measured in the browser and re-measured on a resize. - The cast is clamped to two lines. - SeasonMenu replaces SeasonTabs: the tab row scrolled sideways once a show had more seasons than fit, which is close to unusable on a phone. One trigger reading "Season 5 · 1997" and a menu of every season with its episode count, one row high whatever the season count. - media_meta_resp.director was filled from the credits crew's job == "Director", a movie shape. TMDB's aggregate tv_credits crew is routinely empty and never carries that job, so every show answered null and the modal dropped the line. It now comes from created_by on the show details. Cached show metadata keeps its null until TMDB_META_TTL_SECS expires or an operator re-matches. The facts line is joined rather than concatenated (a title with no rating used to open with " · ") and carries the show's own year next to the director; the selected season's air year moved onto the picker. test_video_detail_measured.py asserts rectangles through layout_probe.py, not declarations: the picker's offset inside its own modal body is the same pixel either way, the synopsis and cast heights, where the read-more link lands, and the open menu at 320 px. Each measured block sits in a whole-pixel-height container, or two identical layouts an eighth of a pixel apart round to tops one pixel apart. test_tmdb_show_director.py covers the credit. docs/mediacenter.md §10.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
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")