diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-app.js | 36 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_video_default_season.py | 107 |
2 files changed, 133 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 2fa22d0..19b0f0f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -73,6 +73,28 @@ function buildSeasons(episodes) { .map(([season, seasonEpisodes]) => ({ season, episodes: seasonEpisodes })); } +// The season a show's detail modal opens on. +// +// Deliberately not the representative episode's. `repEntry` is picked for its +// *thumbnail* — `episodes.find((e) => e.thumb_hash)` in the poster grid, so +// the card has a fallback frame when TMDB has no poster — which makes its +// season an accident of which files the node has managed to thumbnail so far. +// Found live on a show whose early seasons had none: the modal opened on +// season 6. The two uses of "representative" were never the same thing, and +// only one of them is about what the reader is looking at. +// +// Specials first is not what anyone means by the beginning of a show, so +// season 0 wins only when it is all there is. +function defaultSeason(show) { + if (!show || !show.seasons.length) return null; + // The lowest number rather than the first entry: `buildSeasons` does sort + // ascending, but reading the answer off that ordering makes this quietly + // depend on a caller keeping it, and there is nothing to gain by that. + const numbers = show.seasons.map((s) => s.season); + const real = numbers.filter((n) => n !== 0); + return Math.min(...(real.length ? real : numbers)); +} + function groupVideoEntries(entries, videoRoot) { const movies = []; const showsByTitle = new Map(); @@ -686,17 +708,11 @@ function VideoDetailModal({ setRematching(false); }, [rematching, repEntry, transportRef]); - // Reset whenever a different file/show is opened in this same modal - // instance — repEntry/show change identity, selectedSeason must not - // silently keep pointing at whatever the previous show's season 4 was. + // Reset whenever a different show is opened in this same modal instance — + // `show` changes identity, and selectedSeason must not silently keep + // pointing at whatever the previous show's season 4 was. const [selectedSeason, setSelectedSeason] = useState(null); - useEffect(() => { - if (!show) { setSelectedSeason(null); return; } - const preferred = repEntry.season != null && show.seasons.some((s) => s.season === repEntry.season) - ? repEntry.season - : (show.seasons.find((s) => s.season !== 0) || show.seasons[0]).season; - setSelectedSeason(preferred); - }, [show, repEntry]); + useEffect(() => { setSelectedSeason(defaultSeason(show)); }, [show]); const showMultiSeason = Boolean(show && show.seasons.length > 1); const seasonMeta = useSeasonMeta( diff --git a/packages/meshbay-hub/tests/test_video_default_season.py b/packages/meshbay-hub/tests/test_video_default_season.py new file mode 100644 index 0000000..898d3f5 --- /dev/null +++ b/packages/meshbay-hub/tests/test_video_default_season.py @@ -0,0 +1,107 @@ +""" +Which season a show's detail modal opens on. + +Reported live: a show with a dozen seasons opened on season 6. Every season was +in the list and none was missing — the *default* was wrong, which is the harder +kind to notice and the easier kind to shrug at. + +`VideoDetailModal` took it from `repEntry.season`. `repEntry` is the show's +"representative entry", and the poster grid picks it as +`episodes.find((e) => e.thumb_hash) || episodes[0]` — the first episode that +has a thumbnail, so the card has a fallback frame when TMDB has no poster +(`6af05ab`). The season tabs then read that same entry as "the episode the +reader is looking at" (`0b0da86`), which it never was on that path. With the +episodes sorted by (season, episode), a show whose first five seasons had no +thumbnail yet — a partial enrichment pass, or ffmpeg failing on those +particular files — hands back a season-6 episode, and the modal opens there. + +Two different meanings of "representative" that were never the same thing. The +default is now `defaultSeason(show)`, which reads the season list and nothing +else, and this holds it to that: no input here carries a thumbnail at all. + +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") + +BLOCK = re.compile(r"^function defaultSeason\(show\) \{.*?^\}", re.M | re.S) + + +@pytest.fixture(scope="module") +def source(): + m = BLOCK.search(APP.read_text()) + assert m, ("defaultSeason is no longer where this test reads it from — the " + "season a show opens on is untested until this is fixed") + return m.group(0) + + +def _default(tmp_path, source, seasons): + """`seasons` as `buildSeasons` returns them: ascending, specials first.""" + show = None if seasons is None else { + "seasons": [{"season": n, "episodes": []} for n in seasons]} + script = tmp_path / "case.js" + script.write_text(f""" + {source} + console.log(JSON.stringify(defaultSeason({json.dumps(show)}))); + """) + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_a_show_opens_on_its_first_season(tmp_path, source): + assert _default(tmp_path, source, [1, 2, 3, 4, 5, 6, 7, 8]) == 1 + + +def test_specials_do_not_become_the_default(tmp_path, source): + """`buildSeasons` sorts ascending, so season 0 is first in the list and + would win a naive `seasons[0]`. Nobody means the extras by "the start".""" + assert _default(tmp_path, source, [0, 1, 2, 3]) == 1 + + +def test_a_show_of_nothing_but_specials_opens_on_them(tmp_path, source): + """The one case where season 0 is the right answer: there is no other.""" + assert _default(tmp_path, source, [0]) == 0 + + +def test_a_show_that_starts_at_season_two_opens_there(tmp_path, source): + """Half a show on disk is ordinary. The first season *present* is the + answer, not the number 1, which would select a season that is not there + and filter the episode list down to nothing.""" + assert _default(tmp_path, source, [2, 3, 4]) == 2 + + +def test_the_answer_does_not_depend_on_which_episode_has_a_thumbnail(tmp_path, source): + """The defect, as the shape of the input. + + `defaultSeason` is handed the season list and nothing else — there is no + episode, no `thumb_hash`, no representative entry to be misled by. That is + the fix: the reported show opened on season 6 because its first five + seasons had no thumbnails yet, and no argument here can carry that. + """ + reported = list(range(1, 9)) + assert _default(tmp_path, source, reported) == 1 + assert _default(tmp_path, source, list(reversed(reported))) == 1, ( + "the first *season*, not the first entry in whatever order the list " + "arrived in") + + +@pytest.mark.parametrize("show", [None, []]) +def test_no_seasons_means_no_selection(tmp_path, source, show): + """A movie has no `show` at all, and an empty one must not throw: the + modal renders no picker and `selectedSeason` stays null.""" + assert _default(tmp_path, source, show) is None |