🏗️ 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
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
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
|
|
|
|
// Minimal episode factory — only the fields the strip logic reads.
|
|
function ep(
|
|
id: string,
|
|
season: number | null,
|
|
number: number | null,
|
|
): MediaItem {
|
|
return {
|
|
id,
|
|
name: `S${season}E${number}`,
|
|
kind: "episode",
|
|
parentIndexNumber: season,
|
|
indexNumber: number,
|
|
} as unknown as MediaItem;
|
|
}
|
|
|
|
function season(n: number, count: number): MediaItem[] {
|
|
return Array.from({ length: count }, (_, i) => ep(`s${n}e${i + 1}`, n, i + 1));
|
|
}
|
|
|
|
describe("isCurrentEpisode", () => {
|
|
const current = ep("abc", 1, 3);
|
|
|
|
it("matches by id", () => {
|
|
expect(isCurrentEpisode(ep("abc", 9, 9), current)).toBe(true);
|
|
});
|
|
|
|
it("matches by season+episode number when id differs", () => {
|
|
expect(isCurrentEpisode(ep("other", 1, 3), current)).toBe(true);
|
|
});
|
|
|
|
it("does not match a different episode number", () => {
|
|
expect(isCurrentEpisode(ep("other", 1, 4), current)).toBe(false);
|
|
});
|
|
|
|
it("does NOT treat two number-less episodes as the same (the reported bug)", () => {
|
|
const a = ep("a", null, null);
|
|
const b = ep("b", null, null);
|
|
expect(isCurrentEpisode(a, b)).toBe(false);
|
|
});
|
|
|
|
it("does not match when only one side has numbers", () => {
|
|
expect(isCurrentEpisode(ep("a", null, null), current)).toBe(false);
|
|
expect(isCurrentEpisode(ep("a", 1, 3), ep("b", null, null))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("adjacentEpisodes", () => {
|
|
it("returns just the current episode when there are no others", () => {
|
|
const current = ep("only", 1, 1);
|
|
expect(adjacentEpisodes(current, [])).toEqual([current]);
|
|
});
|
|
|
|
it("returns siblings, not just the current episode", () => {
|
|
const eps = season(1, 8);
|
|
const current = eps[2]; // S1E3
|
|
const strip = adjacentEpisodes(current, eps);
|
|
expect(strip.length).toBeGreaterThan(1);
|
|
expect(strip).toContain(current);
|
|
});
|
|
|
|
it("windows to 3 before and 6 after the current episode", () => {
|
|
const eps = season(1, 20);
|
|
const current = eps[9]; // S1E10, index 9
|
|
const strip = adjacentEpisodes(current, eps);
|
|
// start = max(0, 9-3)=6 (E7), end = min(20, 9+7)=16 → E7..E16 (10 items)
|
|
expect(strip.map((e) => e.indexNumber)).toEqual([7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
|
expect(strip).toContain(current);
|
|
});
|
|
|
|
// ux-flows §5B.2, "Cross-season continuity": the window spans the whole
|
|
// series in episode order, so it runs past a season boundary rather than
|
|
// dead-ending at the end of a season.
|
|
it("runs past the end of a season into the next one", () => {
|
|
const eps = [...season(1, 5), ...season(2, 5)];
|
|
const current = eps[4]; // S1E5 — the season finale
|
|
const strip = adjacentEpisodes(current, eps);
|
|
expect(strip.map((e) => e.id)).toEqual([
|
|
"s1e2", "s1e3", "s1e4", "s1e5",
|
|
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
|
|
]);
|
|
});
|
|
|
|
it("reaches back into the previous season from a season opener", () => {
|
|
const eps = [...season(1, 5), ...season(2, 5)];
|
|
const current = eps[5]; // S2E1
|
|
const strip = adjacentEpisodes(current, eps);
|
|
expect(strip.slice(0, 3).map((e) => e.id)).toEqual(["s1e3", "s1e4", "s1e5"]);
|
|
expect(strip[3].id).toBe("s2e1");
|
|
});
|
|
|
|
it("orders by season then episode, never interleaving seasons", () => {
|
|
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
|
|
const current = eps[3]; // S1E1
|
|
const strip = adjacentEpisodes(current, eps);
|
|
expect(strip.map((e) => e.id)).toEqual([
|
|
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
|
|
]);
|
|
});
|
|
|
|
it("sorts specials (season 0) after the numbered seasons", () => {
|
|
const eps = [...season(0, 2), ...season(1, 2)];
|
|
const current = eps[2]; // S1E1
|
|
const strip = adjacentEpisodes(current, eps);
|
|
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s0e1", "s0e2"]);
|
|
});
|
|
|
|
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
|
|
const eps = season(1, 5);
|
|
// Focused episode has a different id than any in the list but same numbers.
|
|
const current = ep("fetched-directly", 1, 3);
|
|
const strip = adjacentEpisodes(current, eps);
|
|
// It should appear once, anchored at its numeric position, alongside siblings.
|
|
expect(strip.filter((e) => e.indexNumber === 3).length).toBe(1);
|
|
expect(strip.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("falls back to the full list when the current season is unknown", () => {
|
|
const eps = season(1, 5);
|
|
const current = ep("mystery", null, 3); // no season number
|
|
const strip = adjacentEpisodes(current, eps);
|
|
expect(strip.length).toBeGreaterThan(1);
|
|
// Anchored at its episode number, not dumped at one end of the list.
|
|
expect(strip.indexOf(current)).toBeGreaterThan(0);
|
|
expect(strip.indexOf(current)).toBeLessThan(strip.length - 1);
|
|
});
|
|
});
|
|
|
|
describe("compareSeriesOrder", () => {
|
|
it("orders by season, then episode", () => {
|
|
expect(compareSeriesOrder(ep("a", 1, 9), ep("b", 2, 1))).toBeLessThan(0);
|
|
expect(compareSeriesOrder(ep("a", 2, 1), ep("b", 2, 2))).toBeLessThan(0);
|
|
expect(compareSeriesOrder(ep("a", 2, 2), ep("b", 2, 2))).toBe(0);
|
|
});
|
|
|
|
it("puts specials last", () => {
|
|
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 1, 1))).toBeGreaterThan(0);
|
|
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 9, 1))).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("falls back to episode number when a season is unknown", () => {
|
|
expect(compareSeriesOrder(ep("a", null, 2), ep("b", 1, 5))).toBeLessThan(0);
|
|
});
|
|
});
|
|
|
|
describe("stripCardLabel", () => {
|
|
const current = ep("cur", 2, 4);
|
|
|
|
it("shows a bare episode number within the current season", () => {
|
|
expect(stripCardLabel(ep("a", 2, 6), current)).toBe("6.");
|
|
});
|
|
|
|
it("shows SxEy once the card crosses a season boundary", () => {
|
|
expect(stripCardLabel(ep("a", 3, 1), current)).toBe("S3E1");
|
|
expect(stripCardLabel(ep("a", 1, 8), current)).toBe("S1E8");
|
|
});
|
|
|
|
it("degrades to the episode number when the season is unknown", () => {
|
|
expect(stripCardLabel(ep("a", null, 7), current)).toBe("7.");
|
|
});
|
|
});
|