refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,389 @@
// @vitest-environment jsdom
//
// The download tray: up only while something waits, runs or failed — and what finished in this tab,
// until it is closed; a row's right click and "…" are the queue's menu; a failure says why and offers
// a retry; a finish is announced once with "Copy link" and "Open"; it folds to a pill, remembered.
// The queue is fed straight in (the poll is stubbed); a poll's look is `noteQueue`, as fetchQueue does.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type QueueJob } from "../../lib/api";
const h = vi.hoisted(() => ({
jobs: [] as unknown[],
updatedAt: 0,
openFile: vi.fn(),
share: vi.fn(async () => "https://x/watch/t"),
notify: vi.fn(),
prefs: new Map<string, unknown>(),
intervalMs: ((_d: unknown) => 0) as (d: unknown) => number,
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string | string[], o?: { count?: number; title?: string }) =>
`${Array.isArray(key) ? key[0] : key}${o?.count !== undefined ? `:${o.count}` : ""}${o?.title ? `:${o.title}` : ""}`,
}),
}));
vi.mock("../../lib/useLiveQuery", () => ({
useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
(h.intervalMs = opts.intervalMs),
{
data: h.jobs,
dataUpdatedAt: h.updatedAt,
isError: false,
isLoading: false,
refetch: vi.fn(),
}
),
}));
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
// usePref stores them is its own business (usePref.dom.test).
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
);
return [
value,
(next: T) => {
h.prefs.set(key, next);
setValue(next);
},
];
},
}));
vi.mock("../../modules/files/filesContext", () => ({
useFilesActions: () => ({ openFile: h.openFile }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("../../lib/downloads", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/downloads")>()),
quickShareLink: h.share,
}));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import DownloadTray from "./DownloadTray";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys";
beforeEach(() => {
resetQueueWatch();
h.prefs.clear();
h.updatedAt = 0;
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
for (const f of [h.openFile, h.share, h.notify]) f.mockClear();
});
const job = (id: number, status: string, extra: Partial<QueueJob> = {}) =>
({
id,
status,
progress: 40,
phase: "video",
error_code: null,
error_detail: null,
job_kind: "download",
display_name: `Clip ${id}`,
source_key: "site:tiktok.com",
folder_id: 9,
asset: { media_kind: "video" },
...extra,
}) as unknown as QueueJob;
/** A poll: the queue the tray now sees. */
function poll(jobs: QueueJob[], view?: { rerender: () => void }) {
h.jobs = jobs;
act(() => noteQueue(jobs));
view?.rerender();
}
function mount() {
const qc = new QueryClient();
const tree = () => (
<QueryClientProvider client={qc}>
<DownloadTray />
</QueryClientProvider>
);
const { rerender } = render(tree());
return { rerender: () => act(() => rerender(tree())) };
}
const tray = () => screen.queryByRole("region", { name: "tray.label" });
const rowOf = (title: string) => screen.getByText(title).closest<HTMLElement>("[data-job-id]")!;
describe("DownloadTray", () => {
it("is not there while nothing waits, runs or failed", () => {
poll([job(1, "done")]);
mount();
expect(tray()).toBeNull();
});
it("lists what waits, runs and failed, its sites, and sums them up", () => {
poll([job(1, "running"), job(2, "queued"), job(3, "error", { error_code: "login_required" })]);
mount();
expect(tray()).not.toBeNull();
expect(screen.getByText(/tray\.count\.running:2, tray\.count\.error:1/)).toBeTruthy();
expect(within(rowOf("Clip 1")).getByText("tiktok.com")).toBeTruthy();
expect(within(rowOf("Clip 1")).getByText("40%")).toBeTruthy();
expect(within(rowOf("Clip 2")).getByText("downloads.status.queued")).toBeTruthy();
});
it("a running job names its step: the second stream's bar does not read as a restart", () => {
poll([job(1, "running", { phase: "audio" }), job(2, "running", { phase: "merging" })]);
mount();
const [audio, merging] = screen.getAllByRole("progressbar");
expect(within(rowOf("Clip 1")).getByText("downloads.phase.audio")).toBeTruthy();
expect(audio!.getAttribute("aria-valuenow")).toBe("40");
// A step that reports no bytes has no % to claim.
expect(within(rowOf("Clip 2")).getByText("downloads.phase.merging")).toBeTruthy();
expect(merging!.getAttribute("aria-valuenow")).toBeNull();
expect(within(rowOf("Clip 2")).queryByText("40%")).toBeNull();
});
it("a paused download is waited on slowly, and the tray neither spins nor says done", () => {
poll([job(1, "paused")]);
mount();
// Nothing moves until its user resumes it: no fast polling for that.
expect(h.intervalMs([job(1, "paused")])).toBe(60000);
expect(h.intervalMs([job(1, "paused"), job(2, "queued")])).toBe(2000);
const marks = [...tray()!.querySelectorAll("svg")].map((i) => i.getAttribute("class") ?? "");
expect(marks.some((c) => /lucide-(circle-check|check-circle)/.test(c))).toBe(false);
expect(
marks.some((c) => /lucide-(circle-pause|pause-circle)/.test(c) && /text-warn/.test(c)),
).toBe(true);
});
it("a failure says why, and its Retry resumes it", () => {
const resume = vi.spyOn(api, "resumeDownload").mockResolvedValue({} as never);
poll([job(3, "error", { error_code: "login_required" })]);
mount();
expect(
within(rowOf("Clip 3")).getByText("downloads.errors.fail.cat.login_required.short"),
).toBeTruthy();
fireEvent.click(within(rowOf("Clip 3")).getByText("downloads.actions.retry"));
return waitFor(() => expect(resume).toHaveBeenCalledWith(3));
});
it("a row's right click opens the queue's menu", async () => {
poll([job(1, "running")]);
mount();
act(() => {
fireEvent.contextMenu(rowOf("Clip 1"));
});
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toContain(
"downloads.actions.pause",
),
);
});
it("a finish is announced once — Copy link, Open — and stays as Done · Open", async () => {
poll([job(1, "running")]);
const view = mount();
poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
const notice = h.notify.mock.calls[0]![0] as {
title: string;
message: string;
action: { label: string; onClick: () => void };
secondaryAction: { label: string; onClick: () => void };
};
expect(notice.title).toBe("tray.done.title:Clip 1");
expect(notice.message).toBe("tray.done.phone");
expect(notice.action.label).toBe("tray.done.copyLink");
notice.action.onClick();
expect(h.share).toHaveBeenCalled();
notice.secondaryAction.onClick();
expect(h.openFile).toHaveBeenCalledWith(1, 9);
// Still on the tray, done; its Open opens it.
fireEvent.click(within(rowOf("Clip 1")).getByText("tray.done.open"));
expect(h.openFile).toHaveBeenCalledTimes(2);
view.rerender();
expect(h.notify).toHaveBeenCalledTimes(1);
});
it("each finish is announced once: a later one does not repeat an earlier", async () => {
poll([job(1, "running"), job(2, "running")]);
const view = mount();
poll([job(1, "done"), job(2, "running")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
poll([job(1, "done"), job(2, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([
"tray.done.title:Clip 1",
"tray.done.title:Clip 2",
]);
});
it("a plain file's notice does not promise a phone", async () => {
poll([job(1, "running", { asset: { media_kind: "file" } as never })]);
const view = mount();
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })),
);
});
it("a finish refreshes Files at once, so the notice's Open finds the file listed", async () => {
const invalidate = vi.spyOn(QueryClient.prototype, "invalidateQueries");
poll([job(1, "running")]);
const view = mount();
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: qk.files() });
poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.fileFolders() });
});
it("an upload the worker converted first is announced as uploaded, not downloaded", async () => {
poll([job(1, "running", { source_key: "upload" })]);
const view = mount();
poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
),
);
});
it("closed, it forgets what finished and stays away until the work changes", () => {
poll([job(1, "running"), job(2, "running")]);
const view = mount();
poll([job(1, "running"), job(2, "done")], view);
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(tray()).toBeNull();
// The same work moving on is not news…
poll([job(1, "running", { progress: 80 })], view);
expect(tray()).toBeNull();
// …a new job is.
poll([job(1, "running"), job(4, "queued")], view);
expect(tray()).not.toBeNull();
expect(screen.queryByText("Clip 2")).toBeNull();
});
it("closed on an old failure, it stays closed after a reload", () => {
poll([job(3, "error")]);
const first = render(
<QueryClientProvider client={new QueryClient()}>
<DownloadTray />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
first.unmount();
resetQueueWatch();
poll([job(3, "error")]);
mount();
expect(tray()).toBeNull();
});
it("closed on an old failure, a new download's finish still shows", () => {
poll([job(3, "error")]);
const view = mount();
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(tray()).toBeNull();
poll([job(4, "running"), job(3, "error")], view);
expect(tray()).not.toBeNull();
// Done, the queue is the old failure alone again — the very queue it was closed on.
poll([job(4, "done"), job(3, "error")], view);
expect(tray()).not.toBeNull();
expect(within(rowOf("Clip 4")).getByText("tray.done.label")).toBeTruthy();
});
it("folds to a pill that sums it up, and opens again — the keyboard following", () => {
poll([job(1, "running")]);
mount();
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
expect(screen.queryByText("Clip 1")).toBeNull();
const pill = screen.getByRole("button", { name: /tray\.count\.running:1/ });
expect(document.activeElement).toBe(pill);
fireEvent.click(pill);
expect(screen.getByText("Clip 1")).toBeTruthy();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "tray.fold" }));
});
it("closed, it leaves the keyboard on the page", () => {
poll([job(1, "running")]);
render(
<QueryClientProvider client={new QueryClient()}>
<main id="content" tabIndex={-1} />
<DownloadTray />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(document.activeElement?.id).toBe("content");
});
describe("View on a job just started", () => {
it("brings a closed tray back, on the job's row", async () => {
poll([job(3, "error")]);
const view = mount();
fireEvent.click(screen.getByRole("button", { name: "tray.close" }));
expect(tray()).toBeNull();
act(() => showInTray(3));
view.rerender();
expect(tray()).not.toBeNull();
await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 3")));
});
it("unfolds a folded tray onto the row", async () => {
poll([job(1, "running"), job(2, "queued")]);
mount();
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
act(() => showInTray(2));
await waitFor(() => expect(document.activeElement).toBe(rowOf("Clip 2")));
});
it("asked from inside a player, it opens under it but leaves the keyboard there", async () => {
poll([job(1, "running"), job(2, "queued")]);
mount();
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
const player = document.createElement("button");
document.body.append(player);
player.focus();
const layer = pushLayer();
act(() => showInTray(2));
await waitFor(() => expect(screen.getByText("Clip 2")).toBeTruthy());
expect(document.activeElement).toBe(player);
removeLayer(layer);
player.remove();
});
it("a job done before the tray saw it run opens as a file", () => {
// The queue's first look only learns what is there: this one never ran in this tab.
poll([job(5, "done", { folder_id: 7 })]);
mount();
act(() => showInTray(5));
expect(h.openFile).toHaveBeenCalledWith(5, 7);
expect(tray()).toBeNull();
});
it("a job a later poll does not have is let go — nothing opens, nothing waits for it", () => {
poll([job(1, "running")]);
const view = mount();
fireEvent.click(screen.getByRole("button", { name: "tray.fold" }));
act(() => showInTray(9));
h.updatedAt = Date.now() + 1000;
poll([job(1, "running")], view);
// Had the ask stayed, the job turning up later would pull the tray open on it.
poll([job(1, "running"), job(9, "running")], view);
expect(screen.queryByText("Clip 9")).toBeNull();
expect(h.openFile).not.toHaveBeenCalled();
});
});
});