// @vitest-environment jsdom // // The address-bar helpers that back the URL model: urlWithParams (read-modify-write on the live query so // each provider owns its own key without clobbering the others), clearParams (strip only transient // auth/one-time keys, never the nav/filter ones), and filterParamUpdates (feed filters → set/delete // delta, or clear-all). The round-trip through filtersToParams/paramsToFilters is exercised too. import { describe, it, expect, beforeEach } from "vitest"; import { urlWithParams, clearParams, filterParamUpdates, filtersToParams, paramsToFilters, } from "./urlState"; import { DEFAULT_TAG_MODES, type FeedFilters } from "./api"; function setLocation(search: string) { window.history.replaceState(null, "", `/app${search}`); } function queryOf(url: string): URLSearchParams { return new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : ""); } const DEFAULTS: FeedFilters = { tags: [], tagModes: DEFAULT_TAG_MODES, channelIds: [], q: "", sort: "newest", scope: "my", includeNormal: true, includeShorts: false, includeLive: false, show: "unwatched", }; describe("urlWithParams", () => { beforeEach(() => setLocation("")); it("sets a key while preserving the pathname and existing params", () => { setLocation("?page=plex"); const url = urlWithParams({ v: "show:1" }); expect(url.startsWith("/app?")).toBe(true); const q = queryOf(url); expect(q.get("v")).toBe("show:1"); expect(q.get("page")).toBe("plex"); // untouched }); it("deletes a key on a null value and leaves the others", () => { setLocation("?page=plex&v=show:1&q=react"); const q = queryOf(urlWithParams({ v: null })); expect(q.has("v")).toBe(false); expect(q.get("page")).toBe("plex"); expect(q.get("q")).toBe("react"); }); it("applies a mix of sets and deletes in one call", () => { setLocation("?page=plex&v=show:1&chan=UC1"); const q = queryOf(urlWithParams({ page: null, chan: null, v: null, pl: null })); expect([...q.keys()]).toEqual([]); // page switch resets the view-specific keys }); it("returns a bare path when the query empties out", () => { setLocation("?page=channels"); expect(urlWithParams({ page: null })).toBe("/app"); }); }); describe("clearParams", () => { beforeEach(() => setLocation("")); it("removes only the named transient keys, keeping nav/filter params", () => { setLocation("?link=ok&page=plex&v=show:1&q=react"); clearParams("link", "oauth", "admin", "access", "verify", "reset"); const q = new URLSearchParams(window.location.search); expect(q.has("link")).toBe(false); expect(q.get("page")).toBe("plex"); expect(q.get("v")).toBe("show:1"); expect(q.get("q")).toBe("react"); }); it("is a no-op (no history write) when none of the keys are present", () => { setLocation("?page=plex"); const before = window.location.search; clearParams("link", "oauth"); expect(window.location.search).toBe(before); }); }); describe("filterParamUpdates", () => { it("maps non-default filters to values and defaults to null (delete)", () => { const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" }; const upd = filterParamUpdates(f); expect(upd.q).toBe("react"); expect(upd.tags).toBe("3,7"); expect(upd.show).toBe("watched"); expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared expect(upd.scope).toBeNull(); }); it("clears ALL filter keys when passed null (leaving the feed)", () => { const upd = filterParamUpdates(null); for (const v of Object.values(upd)) expect(v).toBeNull(); // includes every recognized filter key expect(Object.keys(upd)).toEqual( expect.arrayContaining(["q", "tags", "show", "sort", "scope"]), ); }); it("round-trips through the URL (filters → params → filters)", () => { const f: FeedFilters = { ...DEFAULTS, q: "hi", tags: [1, 2], scope: "all", includeShorts: true, }; const back = paramsToFilters(filtersToParams(f), DEFAULTS); expect(back.q).toBe("hi"); expect(back.tags).toEqual([1, 2]); expect(back.scope).toBe("all"); expect(back.includeShorts).toBe(true); }); it("round-trips per-category tag modes (only 'and' is emitted)", () => { const f: FeedFilters = { ...DEFAULTS, tags: [1, 2], tagModes: { language: "and", topic: "and", other: "or" }, }; const params = filtersToParams(f); expect(params.get("tagmode_language")).toBe("and"); expect(params.get("tagmode_topic")).toBe("and"); expect(params.has("tagmode_other")).toBe(false); // 'or' is the default, not emitted const back = paramsToFilters(params, DEFAULTS); expect(back.tagModes).toEqual({ language: "and", topic: "and", other: "or" }); }); it("migrates a legacy global ?tagmode=and into the topic slot only", () => { const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS); expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" }); }); it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => { expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1"); expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false); expect(paramsToFilters(new URLSearchParams("notag=1"), DEFAULTS).untagged).toBe(true); // Absent → the base value is left untouched (undefined here, i.e. off). expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).untagged).toBeUndefined(); }); });