feat(ui): the four small gaps — player delete, repeat-move, a real channel link, YT search sorting
Items 2, 4, 5 and 7 of the bug hunt, one round. They are independent, but they are all the same kind of thing: an action that exists somewhere else in the app and is missing where you actually want it. **5 — the channel name in the YT player is a link again.** It was a `<button>`, so middle-click and Ctrl/Cmd-click did nothing on a thing that looks exactly like a link (the YouTube icon beside it IS an anchor and opens a tab). It is now a real `<a>` whose href is the same `?chan=<id>` URL `openChannel` pushes, with a plain left click still taking the in-app path — that path does more than navigate, since the player must close and reclaim its own Back-to-close history entry first. The "is this click ours or the browser's" test is now ONE predicate (`isPlainLeftClick`), which VideoCard's card link also uses, so a second such link cannot forget the middle-click arm. **2 — "Remove" from inside the player.** The parent owns the removal (the SAME `removeItem` the row menu and the inline ✕ use: one confirm, one optimistic update, one undo-stack rebase), and the player owns what happens next, because it is the only side that knows which item is playing — its launch queue is frozen at mount. It drops the item, plays what followed, and closes when the list runs out. That forced a real fix in Playlists: the player's mount was derived from `shown` every render, so anything that dropped the launch row yanked the player off the screen mid-playback. That was a latent edge (change the view and it happens) and the normal case for this feature. The parent now snapshots what it launched with, and only onClose takes the player down. **4 — "Move to <the last list you moved to>".** The submenu is two levels deep and moving a run of videos into the same list is the shape this gets used in. The remembered id is a per-account pref; the row is derived from `moveTargets`, which already excludes the list you are looking at, so it cannot offer a move to where the video already is. Remembered only on a SUCCESSFUL move — a shortcut to a move that failed is a shortcut to failing again. Absent, not disabled, when there is nothing to repeat: there is nothing to explain. **7 — the live-YouTube results can be sorted.** Client-side, over the page already loaded. Not a new `order=` param, and this is the decision worth recording: the owner's case is "even one page of results", i.e. reorder what is on screen; a re-query would REPLACE the results, which is not what "sort these" means; and the zero-quota scrape source has no order parameter at all, so a server-side control would silently do nothing (or quietly cost 100 quota units) depending on which source served the page. Only keys a search hit carries are offered — `subscribers`/`priority` describe a subscription, which a search hit is not, and `shuffle` on a ranked list is noise. Its own pref, so sorting a search page never re-sorts the library behind it. Verified in the browser on localdev against the real account: the channel name renders as `<a href="…&chan=…">` and a ctrl-click / middle-click is left to the browser while a plain click still opens the channel in-app; removing from the player stepped 1/3 → 1/2 onto the next video and closed on the last one; the row menu grew "Move to “…”" only after a move and only on a list that is not the target; and the results sort reordered by views 41.7M → 29.8M → 24.2M → 15.8M. (The pane's own ctrl-click arrives without the modifier — a harness limit, so the modifier cases were driven as real MouseEvents.) Scratch playlists and the touched prefs were cleaned up afterwards. 22 vitest, mutation-checked against 14 mutations (no href at all · href without the rest of the query · every click hijacked in-app · the middle-click arm dropped from the predicate · plain click no longer in-app · always jumping to the top of the list · the removed item left in the queue · no clamp when the last item goes · an empty queue not reported as "nothing left" · sort-then-reverse flipping ties · unknown values first · titles compared numerically · shuffle offered on search results · direction ignored) — each red on exactly the test that names it. Item 4 has no test of its own: its rule is enforced by construction, not by a check.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { 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"))).toEqual([
|
||||
"b-old-short",
|
||||
"a-new-long",
|
||||
"c-mid-mid",
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders by upload date in both directions", () => {
|
||||
expect(titles(sortYtResults(RESULTS, "date", "desc"))).toEqual([
|
||||
"a-new-long",
|
||||
"c-mid-mid",
|
||||
"b-old-short",
|
||||
]);
|
||||
expect(titles(sortYtResults(RESULTS, "date", "asc"))).toEqual([
|
||||
"b-old-short",
|
||||
"c-mid-mid",
|
||||
"a-new-long",
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders by views and by length", () => {
|
||||
expect(titles(sortYtResults(RESULTS, "popular", "desc"))[0]).toBe("c-mid-mid");
|
||||
expect(titles(sortYtResults(RESULTS, "duration", "desc"))[0]).toBe("a-new-long");
|
||||
expect(titles(sortYtResults(RESULTS, "duration", "asc"))[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"))).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"))).toEqual(["long", "short", "unknown"]);
|
||||
expect(titles(sortYtResults(mixed, "duration", "asc"))).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"))).toEqual(["first", "second", "third"]);
|
||||
expect(titles(sortYtResults(tied, "popular", "asc"))).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"))).toEqual(titles(RESULTS));
|
||||
expect(titles(sortYtResults(RESULTS, "shuffle", "desc"))).toEqual(titles(RESULTS));
|
||||
});
|
||||
|
||||
it("does not mutate the array it was given", () => {
|
||||
const input = [...RESULTS];
|
||||
sortYtResults(input, "date", "asc");
|
||||
expect(titles(input)).toEqual(titles(RESULTS));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user