Files
siftlode/frontend/src/lib/playlistView.test.ts
T
peter 78a9324381 fix(ui): the palette ranks what you typed, and lib stops leaning on components
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`.
2026-09-13 02:14:07 +02:00

119 lines
3.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyView, sameOrder } from "./playlistView";
import type { Column } from "./tableColumns";
import type { Video } from "./api";
// The playlist's view derivation (E4 S4). What makes it worth a test: the column sort and the
// channel grouping compose — groups are ordered by channel name in the SORT's direction, and the
// sort then applies within each group. Getting that backwards silently produces a plausible-looking
// order, and the whole "save this order" feature writes whatever this function returned.
const v = (id: string, title: string, channel: string, secs: number | null = 60): Video =>
({ id, title, channel_title: channel, duration_seconds: secs }) as Video;
const columns = [
{
key: "title",
header: "Title",
render: () => null,
sortable: true,
sortValue: (x: Video) => x.title ?? "",
},
{
key: "channel",
header: "Channel",
render: () => null,
sortable: true,
sortValue: (x: Video) => x.channel_title ?? "",
},
{
key: "duration",
header: "Length",
render: () => null,
sortable: true,
sortValue: (x: Video) => x.duration_seconds ?? Number.MAX_SAFE_INTEGER,
},
] as Column<Video>[];
const titles = (rows: Video[]) => rows.map((r) => r.title);
describe("applyView", () => {
const rows = [
v("1", "delta", "Beta"),
v("2", "alpha", "Alpha"),
v("3", "charlie", "Beta"),
v("4", "bravo", "Alpha"),
];
it("returns the stored order untouched with no sort and no grouping", () => {
expect(applyView(rows, columns, null, false)).toBe(rows);
});
it("sorts by the named column", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false))).toEqual([
"alpha",
"bravo",
"charlie",
"delta",
]);
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false))).toEqual([
"delta",
"charlie",
"bravo",
"alpha",
]);
});
it("groups by channel, ordering the groups by name and sorting within each", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true))).toEqual([
"alpha", // Alpha
"bravo", // Alpha
"charlie", // Beta
"delta", // Beta
]);
});
it("reverses the GROUP order too when the sort is descending", () => {
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true))).toEqual([
"delta", // Beta first, because the direction flips the group order as well
"charlie",
"bravo", // then Alpha
"alpha",
]);
});
it("groups without a sort by keeping each group's stored order", () => {
expect(titles(applyView(rows, columns, null, true))).toEqual([
"alpha",
"bravo",
"delta",
"charlie",
]);
});
it("parks unknown durations at the end of an ascending sort, not at zero", () => {
const mixed = [v("1", "long", "A", 900), v("2", "live", "A", null), v("3", "short", "A", 30)];
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false))).toEqual([
"short",
"long",
"live",
]);
});
});
describe("sameOrder", () => {
const a = [v("1", "x", "A"), v("2", "y", "A")];
it("is true for the same ids in the same positions", () => {
expect(sameOrder(a, [v("1", "different title", "B"), v("2", "y", "A")])).toBe(true);
});
it("is false when the order differs", () => {
expect(sameOrder(a, [a[1]!, a[0]!])).toBe(false);
});
it("is false when the length differs — a filtered view is never 'the same order'", () => {
expect(sameOrder(a, [a[0]!])).toBe(false);
});
});