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
154 lines
5.6 KiB
TypeScript
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],
|
|
]);
|
|
});
|
|
});
|