aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_season_panel_placement.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-02 09:49:41 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-02 09:49:41 +0200
commit9e3dfcb8229e0cb3d8e296acd09cf5e2acb9565e (patch)
tree70424b9745a462c16947779f14b4b51bf37f20f3 /packages/meshbay-hub/tests/test_season_panel_placement.py
parent12b6dc4dd3e009f2e844d87181800aa12d07a3a6 (diff)
downloadmeshbay-9e3dfcb8229e0cb3d8e296acd09cf5e2acb9565e.tar.gz
fix(hub): the show detail modal must not move when the season does
The previous pass fixed the synopsis and the cast, and the dialog still jumped: the episode count moves things a fixed-height synopsis cannot reach. - The body scrolled as a whole, so a thirteen-episode season pushed the modal to its max-height where a six-episode one had not. `.video-overlay` centres its child, so the taller modal also *started higher up the screen* — title bar, close button and all. `.video-detail-steady` (a multi-season show only) gives the modal a height rather than a max-height, makes the body a flex column, and hands the leftover to the episode list as the one scrolling part. A constant-height box is centred in the same place every time, so both halves settle at once. - A scrolling season draws a scrollbar where a non-scrolling one draws none, which is a scrollbar's width of content and re-wrapped the file path above it, shifting everything below by a line. `scrollbar-gutter: stable`. - The season panel was clipped by the modal's own `overflow: hidden` whenever the seasons outran the room under the picker — at a 740px viewport it wanted 320px and had 288, and the rest sat where no scroll could reach it. It is `position: fixed` now, placed by `placeSeasonPanel()`, which takes the trigger's rect and the window height, picks whichever side has more room, and caps the panel to it. Scoped to multi-season shows throughout: a movie has no season to switch to and a fixed height would buy it nothing but empty space. test_video_detail_measured.py now builds each block inside a real `.video-overlay`, since the centring is half the defect, and asserts the modal top and height as well as the picker's offset — for a long and a short synopsis and for a six- and a twenty-four-episode season. test_season_panel_placement.py runs placeSeasonPanel() in node over a rect and a window height. Two guards are declarations rather than rectangles and say so in their docstrings: headless Chrome gives the probe zero-width overlay scrollbars, so the gutter cannot be measured there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
Diffstat (limited to 'packages/meshbay-hub/tests/test_season_panel_placement.py')
-rw-r--r--packages/meshbay-hub/tests/test_season_panel_placement.py163
1 files changed, 163 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_season_panel_placement.py b/packages/meshbay-hub/tests/test_season_panel_placement.py
new file mode 100644
index 0000000..8c04ea7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_season_panel_placement.py
@@ -0,0 +1,163 @@
+"""
+Where the season menu's panel opens, as arithmetic.
+
+The panel used to be an absolutely positioned child of the picker, and the
+detail modal clips — `overflow: hidden`, for its rounded corners. A show with a
+dozen seasons opens a panel taller than the room left under the picker on
+anything but a tall window, and the seasons past the cut then sat outside the
+modal where no scroll could reach them. Measured live at a 740 px viewport: the
+panel wanted 320 px and had 288 px.
+
+So it is `position: fixed` now, and `placeSeasonPanel()` decides where. That
+makes it the only part of this menu the stylesheet does not determine, and
+`test_video_detail_measured.py` says so where the rest of the modal is
+measured. This is the half a browser cannot be pointed at: pure geometry over a
+rect and a window height, which is exactly what a node test is for.
+
+The function is read out of `video-app.js` rather than duplicated — a copy
+would keep passing after the original changed.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "video-app.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not APP.exists(),
+ reason="node or the SPA sources are not available")
+
+# The three constants and the function, taken as written. Anchored on the
+# `const SEASON_PANEL_MAX` declaration through the end of the function, so a
+# rename or a move fails loudly here rather than silently testing nothing.
+BLOCK = re.compile(
+ r"^const SEASON_PANEL_MAX = .*?^function placeSeasonPanel\(el\) \{.*?^\}",
+ re.M | re.S)
+
+
+@pytest.fixture(scope="module")
+def source():
+ m = BLOCK.search(APP.read_text())
+ assert m, ("placeSeasonPanel is no longer where this test reads it from — "
+ "the season menu's placement is untested until this is fixed")
+ return m.group(0)
+
+
+def _place(tmp_path, source, *, top, bottom, left=40, width=300, inner_height=740):
+ """Run the real function against one trigger rectangle."""
+ script = tmp_path / "case.js"
+ script.write_text(f"""
+ globalThis.window = {{ innerHeight: {inner_height} }};
+ {source}
+ const el = {{ getBoundingClientRect: () => ({{
+ top: {top}, bottom: {bottom}, left: {left}, width: {width} }}) }};
+ console.log(JSON.stringify(placeSeasonPanel(el)));
+ """)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def _px(value):
+ assert value.endswith("px"), f"expected a px length, got {value!r}"
+ return float(value[:-2])
+
+
+def test_it_opens_downwards_when_the_room_is_below(tmp_path, source):
+ """The ordinary case: the picker sits near the top of the modal."""
+ pos = _place(tmp_path, source, top=300, bottom=337)
+
+ assert pos["bottom"] == "auto"
+ assert _px(pos["top"]) == 341, "the panel should start just under the trigger"
+ assert _px(pos["maxHeight"]) == 320, (
+ "740 - 337 - 12 = 391 px of room below, so the panel takes its full "
+ f"320 px and got {pos['maxHeight']}")
+
+
+def test_it_flips_above_the_trigger_when_the_room_is_up_there(tmp_path, source):
+ """A short window, or a picker pushed down the modal.
+
+ Absolutely positioned this was the case that lost seasons off the bottom
+ of the modal with nothing able to scroll to them.
+ """
+ pos = _place(tmp_path, source, top=600, bottom=637)
+
+ assert pos["top"] == "auto"
+ assert _px(pos["bottom"]) == 740 - 600 + 4, (
+ "the panel should be pinned to just above the trigger")
+ assert _px(pos["maxHeight"]) == 320, (
+ "588 px of room above against 91 below — it should open upwards at "
+ f"full height, and got {pos['maxHeight']}")
+
+
+def test_it_never_asks_for_more_room_than_it_has(tmp_path, source):
+ """The defect itself: 320 px of seasons into the 288 px that were free.
+
+ A picker near the top of a short window — downwards is still the better
+ side, but there is nowhere near 320 px of it.
+ """
+ pos = _place(tmp_path, source, top=100, bottom=137, inner_height=300)
+
+ assert pos["bottom"] == "auto", "151 px below against 88 above"
+ assert _px(pos["maxHeight"]) == 300 - 137 - 12, (
+ "the panel must take the room it actually has, and asked for "
+ f"{pos['maxHeight']}")
+
+
+def test_it_picks_the_side_with_the_room_on_it(tmp_path, source):
+ """Not "below unless below is impossible": whichever side is roomier.
+
+ A picker two thirds of the way down a short window has 188 px under it and
+ 288 px over it. Opening downwards there would fit, and would still be the
+ smaller menu.
+ """
+ pos = _place(tmp_path, source, top=300, bottom=337, inner_height=537)
+
+ assert pos["top"] == "auto", "288 px above against 188 below"
+ assert _px(pos["maxHeight"]) == 288
+
+
+def test_a_cramped_window_still_gets_a_usable_panel(tmp_path, source):
+ """Squeezed from both sides, it keeps a floor and scrolls inside it.
+
+ Better a panel that overhangs a little and can be scrolled than one
+ collapsed to a couple of rows — this is a phone in landscape.
+ """
+ pos = _place(tmp_path, source, top=150, bottom=187, inner_height=340)
+
+ assert _px(pos["maxHeight"]) == 141, (
+ "153 px below and 138 above, so it opens downwards into the room it "
+ f"has, and got {pos['maxHeight']}")
+
+ tighter = _place(tmp_path, source, top=90, bottom=127, inner_height=180)
+ assert _px(tighter["maxHeight"]) == 120, (
+ "41 px below and 78 above is under the floor either way, so the floor "
+ f"applies, and got {tighter['maxHeight']}")
+
+
+def test_it_takes_the_trigger_s_own_left_and_width(tmp_path, source):
+ """Fixed to the viewport, it no longer inherits the picker's box — the
+ two must be lined up by hand or the menu hangs off its own trigger."""
+ pos = _place(tmp_path, source, top=300, bottom=337, left=64, width=412)
+
+ assert pos["left"] == "64px" and pos["width"] == "412px"
+
+
+def test_no_element_means_no_position(tmp_path, source):
+ """Called before the picker is mounted, it must not invent coordinates —
+ the panel renders only once there is a `pos` to render it at."""
+ script = tmp_path / "none.js"
+ script.write_text(f"""
+ globalThis.window = {{ innerHeight: 740 }};
+ {source}
+ console.log(JSON.stringify(placeSeasonPanel(null)));
+ """)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ assert json.loads(proc.stdout) is None