test(e4): extract PLEX_SUB_URL for round-trip coverage

Move `type Sub` + `PLEX_SUB_URL` out of the 538-line PlexBrowse into a small
plexSubUrl.ts so the encode/decode round-trip and its guards can be unit-tested
without dragging the component (and its lazy hls.js player) into the test graph.
Pure code motion — PlexBrowse imports the two symbols; the `SubviewUrl` import
moves with them.

Tests lock: encode of each bookmarkable kind, grid/player → null, encode→decode
identity, a person name containing ':' round-tripping, and the decode guards
(positive-integer playlist id; season needs both ids; non-empty show/info/person
arg; unknown kind/garbage → null).

12 tests; full FE gate green (tsc/eslint/prettier/knip).
This commit is contained in:
2026-08-20 05:16:06 +02:00
parent e8a1c252a9
commit 0401dae689
3 changed files with 132 additions and 58 deletions
+61
View File
@@ -0,0 +1,61 @@
import type { SubviewUrl } from "../lib/history";
// The Plex module's sub-view union and its address-bar mirror, extracted from PlexBrowse so the
// encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component
// (and its lazy hls.js player) into the test graph.
export type Sub =
| { kind: "grid" }
| { kind: "show"; id: string }
| { kind: "season"; showId: string; seasonId: string }
| { kind: "player"; id: string; queue?: string[] }
| { kind: "info"; id: string }
| { kind: "person"; name: string }
| { kind: "playlist"; id: number };
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person
// name is the whole tail after the first ":", so a name containing ":" round-trips.
export const PLEX_SUB_URL: SubviewUrl<Sub> = {
key: "v",
encode: (v) => {
switch (v.kind) {
case "show":
return `show:${v.id}`;
case "season":
return `season:${v.showId}:${v.seasonId}`;
case "info":
return `info:${v.id}`;
case "person":
return `person:${v.name}`;
case "playlist":
return `playlist:${v.id}`;
default:
return null; // grid + the ephemeral player are not URL-mirrored
}
},
decode: (raw) => {
const i = raw.indexOf(":");
const kind = i === -1 ? raw : raw.slice(0, i);
const arg = i === -1 ? "" : raw.slice(i + 1);
switch (kind) {
case "show":
return arg ? { kind: "show", id: arg } : null;
case "info":
return arg ? { kind: "info", id: arg } : null;
case "person":
return arg ? { kind: "person", name: arg } : null;
case "playlist": {
// Positive-integer id only — a fractional/zero/negative ?v=playlist:<arg> would open a
// sub-view for a non-existent id (matches the ?pl guard in PlaylistsProvider.readSelectedId).
const id = arg ? Number(arg) : NaN;
return Number.isInteger(id) && id > 0 ? { kind: "playlist", id } : null;
}
case "season": {
const [showId, seasonId] = arg.split(":");
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
}
default:
return null;
}
},
};