Files
siftlode/frontend/src/lib/feedSort.yt.test.ts
T
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.

- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
  the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
  outside lib/format.ts, localeCompare, and a component reading the language
  off the i18n singleton
2026-09-29 22:39:57 +02:00

154 lines
5.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { feedSortOptions, sortYtResults, YT_SORT_KEYS, SORT_KEYS } from "./feedSort";
import type { Video } from "./api";
// The live-YouTube results view shipped with no sort control at all: the provider's order was the
// only order, "már 1 lapnyi találatnál is lenne értelme". These pin what the added control means —
// it reorders the page already on screen, and it only offers keys a search result actually carries.
const v = (over: Partial<Video>): Video => ({ id: over.title ?? "?", ...over }) as Video;
const titles = (list: Video[]) => list.map((x) => x.title);
const RESULTS: Video[] = [
v({
title: "b-old-short",
published_at: "2019-01-01T00:00:00Z",
view_count: 10,
duration_seconds: 60,
}),
v({
title: "a-new-long",
published_at: "2026-01-01T00:00:00Z",
view_count: 5,
duration_seconds: 6000,
}),
v({
title: "c-mid-mid",
published_at: "2022-01-01T00:00:00Z",
view_count: 99,
duration_seconds: 600,
}),
];
describe("YT_SORT_KEYS", () => {
it("offers only keys a live search result can be ordered by", () => {
// `subscribers`/`priority` describe a SUBSCRIPTION, which a search hit is not, and `shuffle` on a
// ranked result list is noise. An option that cannot mean anything here is worse than no option.
expect(YT_SORT_KEYS).not.toContain("subscribers");
expect(YT_SORT_KEYS).not.toContain("priority");
expect(YT_SORT_KEYS).not.toContain("shuffle");
// …and every key it does offer is part of the feed's one vocabulary, not a private spelling.
for (const k of YT_SORT_KEYS) {
if (k === "relevance") continue; // the provider's ranking; not in the wire-sort map
expect(SORT_KEYS).toContain(k);
}
});
});
describe("sortYtResults", () => {
it("leaves the provider's ranking alone on `relevance`", () => {
expect(titles(sortYtResults(RESULTS, "relevance", "desc", "en"))).toEqual([
"b-old-short",
"a-new-long",
"c-mid-mid",
]);
});
it("orders by upload date in both directions", () => {
expect(titles(sortYtResults(RESULTS, "date", "desc", "en"))).toEqual([
"a-new-long",
"c-mid-mid",
"b-old-short",
]);
expect(titles(sortYtResults(RESULTS, "date", "asc", "en"))).toEqual([
"b-old-short",
"c-mid-mid",
"a-new-long",
]);
});
it("orders by views and by length", () => {
expect(titles(sortYtResults(RESULTS, "popular", "desc", "en"))[0]).toBe("c-mid-mid");
expect(titles(sortYtResults(RESULTS, "duration", "desc", "en"))[0]).toBe("a-new-long");
expect(titles(sortYtResults(RESULTS, "duration", "asc", "en"))[0]).toBe("b-old-short");
});
it("orders by title alphabetically, not by code point", () => {
const accented = [v({ title: "Zebra" }), v({ title: "Ábel" }), v({ title: "alma" })];
expect(titles(sortYtResults(accented, "title", "asc", "en"))).toEqual([
"Ábel",
"alma",
"Zebra",
]);
});
it("sinks results with no value to the END in both directions", () => {
// YouTube does not always report a duration; "unknown" is not "the shortest".
const mixed = [
v({ title: "unknown", duration_seconds: null }),
v({ title: "long", duration_seconds: 900 }),
v({ title: "short", duration_seconds: 60 }),
];
expect(titles(sortYtResults(mixed, "duration", "desc", "en"))).toEqual([
"long",
"short",
"unknown",
]);
expect(titles(sortYtResults(mixed, "duration", "asc", "en"))).toEqual([
"short",
"long",
"unknown",
]);
});
it("keeps the provider's ranking inside a tie, in both directions", () => {
// Flipping the direction must reverse the RANKING, not shuffle equally-ranked hits around.
const tied = [
v({ title: "first", view_count: 100 }),
v({ title: "second", view_count: 100 }),
v({ title: "third", view_count: 5 }),
];
expect(titles(sortYtResults(tied, "popular", "desc", "en"))).toEqual([
"first",
"second",
"third",
]);
expect(titles(sortYtResults(tied, "popular", "asc", "en"))).toEqual([
"third",
"first",
"second",
]);
});
it("does not reorder anything for a key it cannot honour", () => {
// A pref stored before this view offered a control can name `shuffle` or `priority`.
expect(titles(sortYtResults(RESULTS, "priority", "desc", "en"))).toEqual(titles(RESULTS));
expect(titles(sortYtResults(RESULTS, "shuffle", "desc", "en"))).toEqual(titles(RESULTS));
});
it("does not mutate the array it was given", () => {
const input = [...RESULTS];
sortYtResults(input, "date", "asc", "en");
expect(titles(input)).toEqual(titles(RESULTS));
});
});
describe("feedSortOptions", () => {
// The library feed and the live results used to build their options from two copies of this
// mapping; one helper means a key cannot get a different default direction in one of the modes.
it("gives directional keys their default direction and marks shuffle/relevance directionless", () => {
// Literal directions, not the implementation's own table: comparing against that table let a
// wrong entry in it pass. Name is the one key that starts ascending.
const opts = feedSortOptions(
["relevance", "date", "duration", "title", "shuffle"],
(k) => `label:${k}`,
);
expect(opts.map((o) => [o.key, o.label, !!o.directionless, o.defaultDir])).toEqual([
["relevance", "label:relevance", true, undefined],
["date", "label:date", false, "desc"],
["duration", "label:duration", false, "desc"],
["title", "label:title", false, "asc"],
["shuffle", "label:shuffle", true, undefined],
]);
});
});