Files
jellytau/src/lib/components/library/seriesNavigation.test.ts
T
dtourolle 58f2506966
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
Opening a series dumped the viewer at the top of season 1, and its Play
button played nothing at all: it resolved `$libraryItems[0]` — the first
*season* by SortName — and navigated to `/player/<seasonId>`, which the
player route bounced straight back to `/library/<seasonId>`.

The backend could already answer "where is this viewer in this show":
`repository_get_next_up_episodes` has accepted a `series_id` since it was
written and no caller had ever passed one.

Backend (DR-101, DR-106)
- `repository/series_progress.rs`: `pick_current_episode` — in progress,
  else Next Up, else first unwatched, else the premiere. The third rung is
  the offline path, where Next Up is always empty. `sort_series_order` puts
  specials (season 0) after the numbered seasons.
- `repository_get_series_episodes` takes over the season fan-out and the
  flat-series fallback, which were domain knowledge living in the frontend.
- `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a
  container, also zeroes resume). Offline it refuses rather than diverging
  state the next sync would undo.

Frontend (DR-102, DR-103, DR-104, DR-107)
- Seasons collapse; only the current one is expanded, and the current
  episode is badged and scrolled into view.
- Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's
  focus view, where Play commits (ux-flows §5B.5).
- Seasons are no longer a destination: `/library/<seasonId>` redirects to
  `/library/<seriesId>#season-N`, and every inbound link follows.
- The "More Episodes" strip spans the whole series, so a season finale
  offers the next premiere instead of dead-ending (§5B.2).
- Clear-history buttons on the series hero and each season header.

Routes (DR-105)
- `/library/tv` and `/library/movies` absorb their all-titles and genres
  pages as `?view=` tabs; the four legacy routes redirect. 6 video routes
  become 2, and `/library/shows/genres` stops being the odd one out.

Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and
`libraryView.ts` so it is unit-tested rather than buried in components.
Spec: docs/specs/series-current-episode-navigation.md
2026-08-03 20:37:43 +02:00

198 lines
6.8 KiB
TypeScript

import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import {
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
seriesPlayHref,
seriesPlayLabel,
groupEpisodesBySeason,
initialExpandedSeasons,
} from "./seriesNavigation";
const SERIES = "series-1";
function ep(id: string, season: number | null, number: number | null): MediaItem {
return {
id,
name: `S${season}E${number}`,
kind: "episode",
seriesId: SERIES,
parentIndexNumber: season,
indexNumber: number,
durationMs: 1_000_000,
} as unknown as MediaItem;
}
function seasonHeader(number: number, id = `season-${number}`): MediaItem {
return {
id,
name: `Season ${number}`,
kind: "season",
seriesId: SERIES,
indexNumber: number,
} as unknown as MediaItem;
}
function withProgress(episode: MediaItem, fraction: number): MediaItem {
return {
...episode,
userData: { playbackPositionMs: (episode.durationMs ?? 0) * fraction },
} as MediaItem;
}
describe("seriesPlayHref", () => {
// The reported bug: Play resolved the first *season* child and navigated to
// /player/<seasonId>, which bounced back to the season-1 page.
it("opens the current episode's focus view, never a season or the player", () => {
const href = seriesPlayHref(SERIES, ep("s2e4", 2, 4));
expect(href).toBe("/library/series-1?episode=s2e4");
expect(href).not.toContain("/player/");
});
it("returns null for a series with no episodes so the button can hide", () => {
expect(seriesPlayHref(SERIES, null)).toBeNull();
});
});
describe("seriesPlayLabel", () => {
it("names the episode it will open", () => {
expect(seriesPlayLabel(ep("s2e4", 2, 4))).toBe("Play S2E4");
});
it("says Resume for a part-watched episode", () => {
expect(seriesPlayLabel(withProgress(ep("s2e4", 2, 4), 0.4))).toBe("Resume S2E4");
});
it("says Play for a barely-started or nearly-finished episode", () => {
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.001))).toBe("Play S1E1");
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.99))).toBe("Play S1E1");
});
it("degrades to a bare verb when the numbering is unknown", () => {
expect(seriesPlayLabel(ep("x", null, null))).toBe("Play");
expect(seriesPlayLabel(null)).toBe("Play");
});
});
describe("seasonRedirectTarget", () => {
it("sends a season to its series, anchored at that season", () => {
expect(seasonRedirectTarget(seasonHeader(3))).toBe("/library/series-1#season-3");
});
it("returns null when the series is unknown, so the caller can fall back", () => {
const orphan = { ...seasonHeader(3), seriesId: undefined } as MediaItem;
expect(seasonRedirectTarget(orphan)).toBeNull();
});
it("matches the anchor the season section renders", () => {
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
`/library/${SERIES}#${seasonAnchorId(2)}`
);
});
});
describe("episodeFocusHref", () => {
it("opens an episode inside its series (never a bare episode page)", () => {
expect(episodeFocusHref(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
});
it("falls back to the bare item page when the series is unknown", () => {
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
expect(episodeFocusHref(orphan)).toBe("/library/lone");
});
});
describe("groupEpisodesBySeason", () => {
it("groups episodes under their season headers, in season order", () => {
const seasons = [seasonHeader(2), seasonHeader(1)];
const episodes = [ep("s1e1", 1, 1), ep("s1e2", 1, 2), ep("s2e1", 2, 1)];
const grouped = groupEpisodesBySeason(seasons, episodes);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 2]);
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["s1e1", "s1e2"]);
expect(grouped[1].episodes.map((e) => e.id)).toEqual(["s2e1"]);
});
it("puts specials after the numbered seasons", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(0), seasonHeader(1)],
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
});
// A flat series: episodes hang off the series, no season folders exist.
it("synthesizes headers when the server returned no seasons", () => {
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
expect(grouped.map((g) => g.season.name)).toEqual(["Season 1", "Season 2"]);
expect(grouped.every((g) => g.season.kind === "season")).toBe(true);
});
it("names a synthesized season 0 'Specials'", () => {
const grouped = groupEpisodesBySeason([], [ep("s0e1", 0, 1)]);
expect(grouped[0].season.name).toBe("Specials");
});
it("gives synthesized headers distinct ids so keyed #each blocks are stable", () => {
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
const ids = grouped.map((g) => g.season.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("drops seasons that have no episodes", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[ep("s2e1", 2, 1)]
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
});
it("buckets season-less episodes into season 1 rather than losing them", () => {
const grouped = groupEpisodesBySeason([], [ep("lone", null, 1)]);
expect(grouped).toHaveLength(1);
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["lone"]);
});
});
describe("initialExpandedSeasons", () => {
const seasons = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[
ep("s1e1", 1, 1),
ep("s2e1", 2, 1),
ep("s2e2", 2, 2),
ep("s3e1", 3, 1),
]
);
it("expands only the season holding the current episode", () => {
const expanded = initialExpandedSeasons(seasons, "s2e2");
expect([...expanded]).toEqual(["season-2"]);
});
it("also expands the season of a ?episode= deep link", () => {
const expanded = initialExpandedSeasons(seasons, "s1e1", "s3e1");
expect(expanded.has("season-1")).toBe(true);
expect(expanded.has("season-3")).toBe(true);
expect(expanded.has("season-2")).toBe(false);
});
it("collapses nothing extra when current and focused share a season", () => {
const expanded = initialExpandedSeasons(seasons, "s2e1", "s2e2");
expect([...expanded]).toEqual(["season-2"]);
});
it("falls back to the first season when there is no current episode", () => {
expect([...initialExpandedSeasons(seasons, null)]).toEqual(["season-1"]);
});
it("falls back to the first season when the current episode is unknown here", () => {
expect([...initialExpandedSeasons(seasons, "not-in-this-show")]).toEqual(["season-1"]);
});
it("returns nothing for a series with no seasons", () => {
expect(initialExpandedSeasons([], "s1e1").size).toBe(0);
});
});