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.
109 lines
4.1 KiB
TypeScript
109 lines
4.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The video menu's download row was labelled with the download's STATE ("Downloaded",
|
|
// "In queue") — a row that reads as a status yet opens the download dialog. It is "Download…" now,
|
|
// the state riding along as the row's hint.
|
|
import type { ReactNode } from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (k: string) => k }),
|
|
}));
|
|
vi.mock("../navigation/navigationContext", () => ({
|
|
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
|
}));
|
|
vi.mock("../comfort/comfortPin", () => ({
|
|
pinVideo: vi.fn(),
|
|
useComfortPin: () => ({ item: () => [] }),
|
|
}));
|
|
|
|
import { api, type Video } from "../../lib/api";
|
|
import { useVideoActions } from "./useVideoActions";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const video = { id: "v1", title: "A video", status: "new", position_seconds: 0 } as Video;
|
|
|
|
function downloadRow(state: string | undefined) {
|
|
vi.spyOn(api, "downloadIndex").mockResolvedValue(
|
|
(state ? { v1: state } : {}) as Awaited<ReturnType<typeof api.downloadIndex>>,
|
|
);
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
);
|
|
const { result } = renderHook(
|
|
() => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn() }, true),
|
|
{ wrapper },
|
|
);
|
|
return () => result.current.items(video, null).find((i) => i.id === "download")!;
|
|
}
|
|
|
|
describe("the video menu's download row", () => {
|
|
// The state rode in a `title`, which a key or a finger never shows.
|
|
it("says what it does, whatever the state, and carries the state as its note", async () => {
|
|
const done = downloadRow("done");
|
|
await waitFor(() => expect(done().note).toBe("downloads.button.downloaded"));
|
|
expect(done().label).toBe("downloads.menu.download");
|
|
cleanup();
|
|
const none = downloadRow(undefined);
|
|
await waitFor(() => expect(none().label).toBe("downloads.menu.download"));
|
|
expect(none().note).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// The feed's channel filter from a card (owner): under "Open the channel", a row that adds the card's
|
|
// channel to the filter, or takes it out again when it is already in.
|
|
describe("the video menu's channel-filter row", () => {
|
|
const menu = (ids: string[] | null) => {
|
|
vi.spyOn(api, "downloadIndex").mockResolvedValue({});
|
|
const set = vi.fn();
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
);
|
|
const { result } = renderHook(
|
|
() =>
|
|
useVideoActions(
|
|
{
|
|
onState: vi.fn(),
|
|
onToggleSave: vi.fn(),
|
|
channelFilter: ids ? { ids, set } : undefined,
|
|
},
|
|
true,
|
|
),
|
|
{ wrapper },
|
|
);
|
|
const items = result.current.items({ ...video, channel_id: "UCa" }, null);
|
|
return { items, set };
|
|
};
|
|
|
|
it("adds the channel to a filter that names others, right under Open the channel", () => {
|
|
const { items, set } = menu(["UCz"]);
|
|
const at = items.findIndex((i) => i.id === "channel");
|
|
const row = items[at + 1]!;
|
|
expect(row.id).toBe("channel-filter");
|
|
expect(row.label).toBe("card.menu.channelFilterAdd");
|
|
row.onSelect?.();
|
|
expect(set).toHaveBeenCalledWith(["UCz", "UCa"]);
|
|
});
|
|
|
|
it("takes it out again when it is in", () => {
|
|
const { items, set } = menu(["UCz", "UCa"]);
|
|
const row = items.find((i) => i.id === "channel-filter")!;
|
|
expect(row.label).toBe("card.menu.channelFilterRemove");
|
|
row.onSelect?.();
|
|
expect(set).toHaveBeenCalledWith(["UCz"]);
|
|
});
|
|
|
|
it("is absent where the host gives no filter (a channel page, Home, a live search)", () => {
|
|
expect(menu(null).items.some((i) => i.id === "channel-filter")).toBe(false);
|
|
});
|
|
});
|