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:
@@ -21,7 +21,8 @@ import {
|
|||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview, type SubviewUrl } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
|
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import SortControl from "./SortControl";
|
import SortControl from "./SortControl";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
@@ -36,63 +37,6 @@ import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./pl
|
|||||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||||
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
||||||
|
|
||||||
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.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||||
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// PLEX_SUB_URL encode/decode round-trip + guards. Extracted from PlexBrowse for this test (E4
|
||||||
|
// deep-review deferred gap). Pure — no DOM, no history.
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl";
|
||||||
|
|
||||||
|
describe("PLEX_SUB_URL encode", () => {
|
||||||
|
it("mirrors each bookmarkable sub-view to ?v=<kind>:<args>", () => {
|
||||||
|
expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1");
|
||||||
|
expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2");
|
||||||
|
expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9");
|
||||||
|
expect(U.encode({ kind: "person", name: "Jane Doe" })).toBe("person:Jane Doe");
|
||||||
|
expect(U.encode({ kind: "playlist", id: 12 })).toBe("playlist:12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT mirror the root grid or the ephemeral player (encode → null)", () => {
|
||||||
|
expect(U.encode({ kind: "grid" })).toBeNull();
|
||||||
|
expect(U.encode({ kind: "player", id: "m9" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PLEX_SUB_URL round-trip", () => {
|
||||||
|
const cases: Sub[] = [
|
||||||
|
{ kind: "show", id: "s1" },
|
||||||
|
{ kind: "season", showId: "s1", seasonId: "se2" },
|
||||||
|
{ kind: "info", id: "m9" },
|
||||||
|
{ kind: "person", name: "Jane Doe" },
|
||||||
|
{ kind: "playlist", id: 12 },
|
||||||
|
];
|
||||||
|
it.each(cases)("encode→decode is identity for %o", (view) => {
|
||||||
|
const raw = U.encode(view);
|
||||||
|
expect(raw).not.toBeNull();
|
||||||
|
expect(U.decode(raw as string)).toEqual(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a person name containing ':' round-trips (tail after the first colon)", () => {
|
||||||
|
const view: Sub = { kind: "person", name: "Dr: Strange: Origins" };
|
||||||
|
expect(U.decode(U.encode(view) as string)).toEqual(view);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PLEX_SUB_URL decode guards", () => {
|
||||||
|
it("rejects a playlist id that is not a positive integer", () => {
|
||||||
|
expect(U.decode("playlist:")).toBeNull(); // Number("")===0, not >0
|
||||||
|
expect(U.decode("playlist:0")).toBeNull();
|
||||||
|
expect(U.decode("playlist:-1")).toBeNull();
|
||||||
|
expect(U.decode("playlist:1.5")).toBeNull();
|
||||||
|
expect(U.decode("playlist:abc")).toBeNull();
|
||||||
|
expect(U.decode("playlist:12")).toEqual({ kind: "playlist", id: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires both ids for a season", () => {
|
||||||
|
expect(U.decode("season:s1")).toBeNull();
|
||||||
|
expect(U.decode("season:")).toBeNull();
|
||||||
|
expect(U.decode("season:s1:se2")).toEqual({ kind: "season", showId: "s1", seasonId: "se2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a non-empty arg for show / info / person", () => {
|
||||||
|
expect(U.decode("show:")).toBeNull();
|
||||||
|
expect(U.decode("info:")).toBeNull();
|
||||||
|
expect(U.decode("person:")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown kind or garbage", () => {
|
||||||
|
expect(U.decode("grid")).toBeNull();
|
||||||
|
expect(U.decode("player:m9")).toBeNull(); // player is not URL-restorable
|
||||||
|
expect(U.decode("bogus")).toBeNull();
|
||||||
|
expect(U.decode("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user