Files
siftlode/frontend/src/lib/channelsUrlState.dom.test.ts
T
peter fa893c73a3 feat(player): the YouTube player and the file viewer are named in the address
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.
2026-10-27 05:29:49 +01:00

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