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,169 @@
// @vitest-environment jsdom
//
// The palette's DOMAIN half: which rows exist and what they do. The list/filter/keyboard shell
// (`ui/CommandPalette`) has its own tests, so it is stubbed here to hand back the groups it was given —
// what this file pins is the content: modules gated by the account, hidden channels left out, and the
// two `run`s whose ORDER matters.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CommandGroup } from "../components/ui/CommandPalette";
vi.mock("react-i18next", async (importOriginal) => ({
// The rest of the module too: the recorder's actions reach `i18n/index`, which needs it.
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
let groups: CommandGroup[] = [];
vi.mock("../components/ui/CommandPalette", () => ({
default: (props: { groups: CommandGroup[] }) => {
groups = props.groups;
return null;
},
}));
const channels = vi.fn();
const playlists = vi.fn();
vi.mock("../lib/api", () => ({
api: {
channels: (...a: unknown[]) => channels(...a),
playlists: (...a: unknown[]) => playlists(...a),
},
}));
let me: Record<string, unknown> | undefined;
vi.mock("../lib/useMe", () => ({ useMe: () => me }));
vi.mock("../lib/keymap", () => ({
usePaletteOpen: () => true,
closePalette: vi.fn(),
toggleKeyboardHelp: vi.fn(),
}));
const calls: string[] = [];
const setPage = vi.fn((page: string, then?: () => void) => {
calls.push(`setPage:${page}`);
then?.();
});
vi.mock("../features/navigation/navigationContext", () => ({
useNavigationActions: () => ({ setPage, openChannel: vi.fn() }),
}));
vi.mock("../modules/playlists/playlistsContext", () => ({
usePlaylistsActions: () => ({
setSelectedId: (id: number) => calls.push(`select:${id}`),
}),
}));
vi.mock("../features/prefs/prefsContext", () => ({
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
}));
vi.mock("../modules/feed/savedViewsContext", () => ({
useSavedViews: () => ({ views: [], apply: vi.fn() }),
}));
const requestSurprise = vi.fn();
vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() }));
const requestLinkBox = vi.fn();
vi.mock("../lib/downloadIntake", () => ({ requestLinkBox: () => requestLinkBox() }));
import AppCommandPalette from "./AppCommandPalette";
const user = (over: Record<string, unknown> = {}) => ({
id: 1,
role: "user",
plex_enabled: false,
...over,
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<AppCommandPalette />
</QueryClientProvider>,
);
}
const group = (id: string) => groups.find((g) => g.id === id);
const ids = (id: string) => group(id)?.items.map((i) => i.id) ?? [];
beforeEach(() => {
groups = [];
calls.length = 0;
me = user();
setPage.mockClear();
requestSurprise.mockClear();
channels.mockReset().mockResolvedValue([]);
playlists.mockReset().mockResolvedValue([]);
});
afterEach(cleanup);
describe("AppCommandPalette", () => {
it("offers only the modules this account can open, in nav order, and Settings", () => {
mount();
expect(ids("modules")).toContain("page:feed");
expect(ids("modules")).not.toContain("page:plex");
expect(ids("modules")).not.toContain("page:config"); // admin-only
expect(ids("modules").slice(-1)).toEqual(["page:settings"]);
cleanup();
me = user({ role: "admin", plex_enabled: true });
mount();
expect(ids("modules")).toContain("page:plex");
expect(ids("modules")).toContain("page:config");
});
it("leaves out the channels the user has hidden", async () => {
channels.mockResolvedValue([
{ id: "UC1", title: "Shown", handle: "@shown", hidden: false },
{ id: "UC2", title: "Hidden", handle: "@hidden", hidden: true },
]);
mount();
await waitFor(() => expect(group("channels")).toBeTruthy());
expect(ids("channels")).toEqual(["channel:UC1"]);
});
it("selects the playlist BEFORE switching to the page, so it opens on that list", async () => {
playlists.mockResolvedValue([{ id: 7, name: "Later", item_count: 3 }]);
mount();
await waitFor(() => expect(group("playlists")).toBeTruthy());
group("playlists")!.items[0]!.run();
expect(calls).toEqual(["select:7", "setPage:playlists"]);
});
it("asks for the surprise once the navigation to Home has happened, not before", () => {
mount();
const surprise = group("actions")!.items.find((i) => i.id === "action:surprise")!;
// Over an open player the push lands a moment after the call, and the token must name the
// entry that push makes (lib/homeIntent) — so it rides `setPage`'s `then`, not the next line.
setPage.mockImplementationOnce((page: string) => void calls.push(`setPage:${page}`));
surprise.run();
expect(requestSurprise).not.toHaveBeenCalled();
const then = setPage.mock.lastCall?.[1];
expect(setPage.mock.lastCall?.[0]).toBe("home");
then?.();
expect(requestSurprise).toHaveBeenCalledTimes(1);
});
it("Download from a link takes the reader to the Files link box, asked on that entry", () => {
mount();
const item = group("actions")!.items.find((i) => i.id === "action:downloadLink")!;
setPage.mockImplementationOnce(() => {});
item.run();
expect(setPage.mock.lastCall?.[0]).toBe("files");
expect(requestLinkBox).not.toHaveBeenCalled();
setPage.mock.lastCall?.[1]?.();
expect(requestLinkBox).toHaveBeenCalledTimes(1);
});
it("offers the debug recorder by its state", () => {
const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:"));
mount();
expect(recorder()).toEqual(["action:recorder:start"]);
cleanup();
const until = new Date(Date.now() + 60_000).toISOString();
me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } });
mount();
expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]);
});
});