// @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): 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, ); }); });