diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_season_panel_placement.py | 163 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_video_detail_measured.py | 306 |
2 files changed, 361 insertions, 108 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 diff --git a/packages/meshbay-hub/tests/test_video_detail_measured.py b/packages/meshbay-hub/tests/test_video_detail_measured.py index e72af0f..1ac8586 100644 --- a/packages/meshbay-hub/tests/test_video_detail_measured.py +++ b/packages/meshbay-hub/tests/test_video_detail_measured.py @@ -1,33 +1,45 @@ """ -The show detail modal, measured: the season picker must not move. +The show detail modal, measured: nothing above the episode list may move. -Reported against a real library. Three faults, one complaint — opening a -different season of the same show made the whole modal jump: +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. - * 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 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 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. +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 @@ -56,49 +68,65 @@ LONG_SYNOPSIS = ( "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)) +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} · An Episode</span> + <span class="video-episode-meta">44min 1280x720</span> + </button>""" for i in range(1, n + 1)) -SEASONS = [(0, "Specials", "4 episodes"), - *((n, f"Season {n}", "22 episodes") for n in range(1, 13))] +def _detail(block_id, synopsis, *, episodes=6, steady=True, has_more=True): + """One `.video-detail` inside a real `.video-overlay`, as it is rendered. -def _detail(block_id, synopsis, cast, *, reserved=True, has_more=True, menu_open=False): - """One `.video-detail` as VideoDetailModal renders it for a show.""" + 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 reserved else [])) + + (["reserved"] if steady 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 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">File: shows/S05/e01.mkv · TMDB #0000</p> + <p class="video-detail-source">{SOURCE} + <span class="video-detail-tmdbref"> · TMDB #0000</span></p> <div class="video-overview-wrap"> <p class="{overview_cls}">{toggle}{synopsis}</p> </div> <p class="video-detail-facts"> ★ 8.4 · Mystery, Drama · 1993 · Director: A Creator </p> - <p class="video-detail-cast">{cast}</p> + <p class="video-detail-cast">{CAST}</p> <div class="video-admin-actions"> <button class="admin-btn video-fix-match">Fix match…</button> <button class="admin-btn">Re-match</button> @@ -108,45 +136,45 @@ def _detail(block_id, synopsis, cast, *, reserved=True, has_more=True, menu_open <span class="video-season-current">Season 5 · 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 · An Episode</span> - <span class="video-episode-meta">44min 1280x720</span> - </button> - </div> + <div class="video-season">{_episodes(episodes)}</div> </div> </div> </div> </div> + </div> """ -FRAGMENT = textwrap.dedent(f""" +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">{SHORT_CAST}</p> + <p class="video-detail-cast">A Performer, Another Performer</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) + _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-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", + "#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") ] @@ -176,33 +204,108 @@ def _line(measured, 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_season_picker_does_not_move_with_the_synopsis(measured, width): - """The complaint itself. +def test_the_modal_itself_does_not_move_or_resize_between_seasons(measured, width): + """The half a fixed-height synopsis cannot reach. - 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. + `.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. """ - def offset(block): - return (_box(measured, width, f"#{block} .video-season-menu")["top"] - - _box(measured, width, f"#{block} .video-detail-body")["top"]) + 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") - 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_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 ("long", "short"): + 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 " @@ -230,22 +333,6 @@ def test_a_read_more_link_lands_on_the_last_line(measured, width): @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"] @@ -256,20 +343,23 @@ def test_the_cast_is_two_lines_however_long_the_cast_is(measured, width): @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") +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]) |