Files
siftlode/frontend/src/lib/columnSort.test.ts
T
peter 45bd588af7 refactor(channels): share useSortState across the manager + discovery (R13 S1)
ChannelsProvider and ChannelDiscovery each hand-rolled the same sort plumbing
(useState<SortState> + parseSortState + a persist key). Both now use the shared
useSortState hook. hydrateSort gains an empty-options rule: with no vocabulary
passed it parses without healing (these owners heal for display via
ColumnSortControl against the live columns), so the un-healed provider store is
preserved. plexSortKeys made module-private (knip).

Closes R13 S1: feed, Plex, and the two channel tables all sort through one
SortControl + one SortState model. Gate green: tsc/eslint(0 err)/prettier/knip
clean, vitest 94.
2026-08-13 23:41:12 +02:00

183 lines
6.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
sortRows,
parseSortState,
cycleSort,
pickSort,
healSort,
hydrateSort,
type SortOption,
type SortState,
} from "./columnSort";
interface Row {
name: string;
views: number;
}
const rows: Row[] = [
{ name: "beta", views: 30 },
{ name: "alpha", views: 100 },
{ name: "gamma", views: 5 },
];
const columns = [
{ key: "name", sortValue: (r: Row) => r.name },
{ key: "views", sortValue: (r: Row) => r.views },
{ key: "nosort" }, // a column with no sortValue
];
describe("sortRows", () => {
it("returns the same array reference when there is no sort", () => {
expect(sortRows(rows, columns, null)).toBe(rows);
});
it("returns the input unchanged for a column that has no sortValue", () => {
expect(sortRows(rows, columns, { key: "nosort", dir: "asc" })).toBe(rows);
});
it("sorts numbers by numeric difference, not string order", () => {
// string order would put "100" before "30"; numeric must not.
const asc = sortRows(rows, columns, { key: "views", dir: "asc" }).map((r) => r.views);
expect(asc).toEqual([5, 30, 100]);
const desc = sortRows(rows, columns, { key: "views", dir: "desc" }).map((r) => r.views);
expect(desc).toEqual([100, 30, 5]);
});
it("sorts strings with locale compare, ascending and descending", () => {
expect(sortRows(rows, columns, { key: "name", dir: "asc" }).map((r) => r.name)).toEqual([
"alpha",
"beta",
"gamma",
]);
expect(sortRows(rows, columns, { key: "name", dir: "desc" }).map((r) => r.name)).toEqual([
"gamma",
"beta",
"alpha",
]);
});
it("does not mutate the input array", () => {
const before = rows.map((r) => r.name);
sortRows(rows, columns, { key: "name", dir: "asc" });
expect(rows.map((r) => r.name)).toEqual(before);
});
});
describe("parseSortState", () => {
it("accepts a well-formed state", () => {
expect(parseSortState({ key: "views", dir: "desc" })).toEqual({ key: "views", dir: "desc" });
});
it.each([
["null", null],
["a string", "views"],
["missing dir", { key: "views" }],
["a bad dir", { key: "views", dir: "sideways" }],
["a non-string key", { key: 3, dir: "asc" }],
])("coerces %s to null rather than passing junk to the comparator", (_label, raw) => {
expect(parseSortState(raw)).toBeNull();
});
});
describe("cycleSort", () => {
it("walks unsorted → asc → desc → unsorted for the same key", () => {
let s: SortState = null;
s = cycleSort(s, "views");
expect(s).toEqual({ key: "views", dir: "asc" });
s = cycleSort(s, "views");
expect(s).toEqual({ key: "views", dir: "desc" });
s = cycleSort(s, "views");
expect(s).toBeNull();
});
it("jumps straight to ascending when a different column is clicked", () => {
expect(cycleSort({ key: "name", dir: "desc" }, "views")).toEqual({ key: "views", dir: "asc" });
});
});
const OPTIONS: SortOption[] = [
{ key: "date", label: "Date", defaultDir: "desc" },
{ key: "title", label: "Title" }, // defaultDir omitted → asc
{ key: "shuffle", label: "Shuffle", directionless: true },
];
describe("pickSort", () => {
it("starts a freshly-picked option at its defaultDir (asc fallback)", () => {
expect(pickSort(OPTIONS, null, "date")).toEqual({ key: "date", dir: "desc" });
expect(pickSort(OPTIONS, null, "title")).toEqual({ key: "title", dir: "asc" });
});
it("keeps the current direction when re-picking the same key", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "asc" }, "date")).toEqual({
key: "date",
dir: "asc",
});
});
it("carries a stable dir for a directionless option and never shows a flip", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "asc" }, "shuffle")).toEqual({
key: "shuffle",
dir: "desc",
});
});
it("clears to null on an empty key or an unknown option", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "desc" }, "")).toBeNull();
expect(pickSort(OPTIONS, { key: "date", dir: "desc" }, "ghost")).toBeNull();
});
});
describe("healSort", () => {
it("keeps a sort whose key is still offered", () => {
const s: SortState = { key: "title", dir: "asc" };
expect(healSort(s, OPTIONS)).toBe(s);
});
it("collapses a sort naming an absent option to the fallback", () => {
expect(healSort({ key: "gone", dir: "asc" }, OPTIONS)).toBeNull();
expect(healSort({ key: "gone", dir: "asc" }, OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
});
describe("hydrateSort", () => {
it("returns the fallback for missing storage", () => {
expect(hydrateSort(null, OPTIONS)).toBeNull();
expect(hydrateSort(null, OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
it("parses and heals a stored JSON state", () => {
expect(hydrateSort(JSON.stringify({ key: "title", dir: "asc" }), OPTIONS)).toEqual({
key: "title",
dir: "asc",
});
expect(hydrateSort(JSON.stringify({ key: "gone", dir: "asc" }), OPTIONS)).toBeNull();
});
it("tolerates a legacy bare-key string, applying the option's defaultDir", () => {
expect(hydrateSort("date", OPTIONS)).toEqual({ key: "date", dir: "desc" });
expect(hydrateSort("title", OPTIONS)).toEqual({ key: "title", dir: "asc" });
});
it("falls back when a legacy bare-key names an unknown option", () => {
expect(hydrateSort("ghost", OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
it("parses without healing when no options are supplied (caller heals for display)", () => {
// A table/provider that owns no vocabulary keeps whatever was stored, even an unknown key.
expect(hydrateSort(JSON.stringify({ key: "anything", dir: "desc" }), [])).toEqual({
key: "anything",
dir: "desc",
});
// A malformed stored value still coerces to the fallback.
expect(hydrateSort(JSON.stringify({ key: 3, dir: "up" }), [], null)).toBeNull();
});
});