Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle `@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed matches in pure group order and always highlighted the first one. - CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint, keywords or group name; an item ranks by its worst word, then the sum. The group order stays fixed, rows are ranked inside each group, and the best row overall starts highlighted. The highlight is held by id and follows the best match until the arrows or the pointer pick a row, so a channel list landing mid-typing no longer shifts it. - A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing element, and "Nothing matches" sits outside it; a polite status announces the count once the typing pauses. - `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`, and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib helpers imported the column contract from a ui component, and `playerModalHooks` its types from a feature component. - eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included; the boundary contract test proves it fires. CLAUDE.md and the ui README say so. - keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
109 lines
4.1 KiB
TypeScript
109 lines
4.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// S8 — the Channels-manager content state (active tab + column sort + the column-filter map) round-trips
|
|
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the S4/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: {} };
|
|
|
|
// 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("emits nothing for the all-default state", () => {
|
|
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
|
|
});
|
|
|
|
it("clears every key when passed null (off-manager page)", () => {
|
|
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
|
|
});
|
|
|
|
it("emits only non-default keys", () => {
|
|
const u = channelsParamUpdates({
|
|
view: "hidden",
|
|
sort: { key: "subscribers", dir: "desc" },
|
|
filters: { sync: "fully" },
|
|
});
|
|
expect(u.ctab).toBe("hidden");
|
|
expect(u.csort).toBe("subscribers:desc");
|
|
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
|
|
});
|
|
|
|
it("drops empty filter entries so a cleared funnel emits no cf", () => {
|
|
expect(
|
|
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
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,
|
|
};
|
|
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);
|
|
// 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" },
|
|
});
|
|
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, { view: "subscribed", sort: null, filters: {} });
|
|
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,
|
|
);
|
|
});
|
|
});
|