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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user