fix(nav): a linked player, file and search survive what the app does by itself (review round 2)

- Playlists: a link over a playlist the account does not hold plays alone; the rail's own
  fallback no longer counts as a pick that gives the player up.
- The linked opener opens or clears nothing once the address names another player, and stops
  looking (and fetching its row) once it settled.
- Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a
  notice of its own (EN/HU).
- History: an in-place view rewrite (search refine, count, channel to channel, sub-view)
  drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a
  search keeps the search; the Feed press on a search opens the plain feed.
- Files/Playlists: an emptied search is written at once.
- Client log: plf/cf (column text filters) are typed keys; the shell has none of its own.
- Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is
  sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark
  net reads page tags.
- Feed defaults in one constant; one live-search queue helper; Scheduler writes through the
  Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
This commit is contained in:
2026-10-27 16:08:52 +01:00
parent 0d5c6273f1
commit 6d57d62ac8
38 changed files with 717 additions and 187 deletions
@@ -170,8 +170,11 @@ const YT = folder(1, { source_key: "youtube" });
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
afterEach(() => {
afterEach(async () => {
cleanup();
// A viewer the cleanup closed steps the history back: jsdom lands that traversal a few tasks later,
// and on whatever case is current by then. Let it land here, before the address is reset.
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
vi.restoreAllMocks();
// The page names its sort in the address: one case's sort must not open the next.
window.history.replaceState(null, "", "/");
@@ -813,15 +816,30 @@ describe("Files · a file the address names", () => {
// A list the cap cut cannot tell a deleted file from one beyond its end: it says the file is not
// among the ones listed, and keeps the link.
it("past the cap with no search or sort to widen it, says it is not listed and keeps the link", async () => {
// The case before closed a viewer: its step back lands a moment later, on whatever is current.
await new Promise((r) => setTimeout(r, 30));
window.history.replaceState(null, "", "/?page=files&fsort=none&fview=9999");
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.folders = [YT];
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.arrival.notListed" }),
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }),
),
);
expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }),
);
expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows
it("past the cap, the server's sorted list cut too: not listed, and the link stays", async () => {
window.history.replaceState(null, "", "/?page=files&fsort=name:asc&fview=9999");
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.folders = [YT];
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }),
),
);
expect(mocks.notify).not.toHaveBeenCalledWith(