aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_season_panel_placement.py
blob: 8c04ea73efba20cc3e4ba552a8fe2b30522b2068 (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
"""
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