The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
132 lines
4.7 KiB
TypeScript
132 lines
4.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Channels-manager content state (active tab + column sort + the column-filter map + the name
|
|
// search) round-trips through namespaced URL keys (ctab/csort/cf/cq) so a manager view is shareable, the plexUrlState
|
|
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
channelsParamsToState,
|
|
channelsParamUpdates,
|
|
hasChannelsParams,
|
|
type ChannelsUrlState,
|
|
} from "./channelsUrlState";
|
|
import type { FilterMap } from "./tableColumns";
|
|
|
|
// A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and
|
|
// an array (a multi column).
|
|
const RICH_FILTERS: FilterMap = {
|
|
sync: "needsFull",
|
|
priority: "3",
|
|
tags: ["12", "34"],
|
|
};
|
|
|
|
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {}, search: "" };
|
|
|
|
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
|
|
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
|
const p = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(updates)) {
|
|
if (v != null) p.set(k, v);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
describe("channelsParamUpdates", () => {
|
|
it("writes no search for a blank one", () => {
|
|
expect(channelsParamUpdates({ ...BASE, search: " " }).cq).toBeNull();
|
|
});
|
|
|
|
it("writes the default view in full: this browser remembers one a link must not inherit", () => {
|
|
expect(channelsParamUpdates(BASE)).toEqual({
|
|
ctab: "subscribed",
|
|
csort: "none",
|
|
cf: "",
|
|
cq: null,
|
|
});
|
|
});
|
|
|
|
it("a link to the default view opens it over another remembered one", () => {
|
|
const params = applyUpdates(channelsParamUpdates(BASE));
|
|
const back = channelsParamsToState(params, {
|
|
view: "hidden",
|
|
sort: { key: "subscribers", dir: "desc" },
|
|
filters: { sync: "fully" },
|
|
search: "",
|
|
});
|
|
expect(back).toEqual(BASE);
|
|
});
|
|
|
|
it("clears every key when passed null (off-manager page)", () => {
|
|
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
|
|
});
|
|
|
|
it("writes the view picked", () => {
|
|
const u = channelsParamUpdates({
|
|
view: "hidden",
|
|
sort: { key: "subscribers", dir: "desc" },
|
|
filters: { sync: "fully" },
|
|
search: "tom",
|
|
});
|
|
expect(u.ctab).toBe("hidden");
|
|
expect(u.csort).toBe("subscribers:desc");
|
|
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
|
|
expect(u.cq).toBe("tom");
|
|
});
|
|
|
|
it("drops empty filter entries so a cleared funnel writes an empty cf", () => {
|
|
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("round-trip", () => {
|
|
it("restores a rich state (scalar + array filters) from the emitted URL", () => {
|
|
const state: ChannelsUrlState = {
|
|
view: "blocked",
|
|
sort: { key: "priority", dir: "asc" },
|
|
filters: RICH_FILTERS,
|
|
search: "jazz",
|
|
};
|
|
const params = applyUpdates(channelsParamUpdates(state));
|
|
const back = channelsParamsToState(params, BASE);
|
|
expect(back.view).toBe("blocked");
|
|
expect(back.sort).toEqual({ key: "priority", dir: "asc" });
|
|
expect(back.filters).toEqual(RICH_FILTERS);
|
|
expect(back.search).toBe("jazz");
|
|
// the array shape survives the JSON round-trip, not collapsed to a scalar
|
|
expect(Array.isArray(back.filters.tags)).toBe(true);
|
|
});
|
|
|
|
it("keeps base values for absent keys (partial link)", () => {
|
|
const params = new URLSearchParams("csort=title:asc");
|
|
const back = channelsParamsToState(params, {
|
|
view: "discovery",
|
|
sort: null,
|
|
filters: { sync: "needsFull" },
|
|
search: "",
|
|
});
|
|
expect(back.view).toBe("discovery"); // from base, no ctab
|
|
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
|
|
expect(back.sort).toEqual({ key: "title", dir: "asc" });
|
|
});
|
|
|
|
it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => {
|
|
const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson");
|
|
const back = channelsParamsToState(params, BASE);
|
|
expect(back.view).toBe("subscribed"); // bogus tab → base
|
|
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
|
|
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty
|
|
});
|
|
});
|
|
|
|
describe("hasChannelsParams", () => {
|
|
it("detects any namespaced key", () => {
|
|
expect(hasChannelsParams(new URLSearchParams("ctab=hidden"))).toBe(true);
|
|
expect(hasChannelsParams(new URLSearchParams("cf=%7B%22sync%22%3A%22fully%22%7D"))).toBe(true);
|
|
});
|
|
it("ignores the feed's and plex's keys", () => {
|
|
expect(hasChannelsParams(new URLSearchParams("scope=all&pscope=movie&sort=oldest"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|