fix(library): populate "More Episodes" for series without season folders

The Episode Focus View's episode strip collapsed to just the current
episode on some series. Two causes:

- Series that expose episodes directly as children rather than under
  season folders yielded an empty season fetch, leaving allEpisodes
  empty. The library page now groups those flat episode children by
  their season number and synthesizes minimal season headers.
- isCurrentEpisode over-matched: episodes with no season/episode number
  compared equal (undefined === undefined) and every one of them looked
  like the focused episode.

Extracts the strip's pure logic into episodeStrip.ts so both behaviours
are unit-tested, per the failing-test-first rule.

TRACES: UR-058 | DR-087
This commit is contained in:
2026-07-25 09:21:15 +02:00
parent ee584aced2
commit fb967433f0
4 changed files with 190 additions and 54 deletions
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes } 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);
});
it("restricts to the current season when multiple seasons are present", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[6]; // S2E2
const strip = adjacentEpisodes(current, eps);
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
});
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);
});
});