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,453 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The notification center: a row keeps its link and its unread mark in sight, its dismiss
|
||||
// shows on hover (on touch it is in the "…"), and everything it can do is its ONE menu — opened by
|
||||
// the row's "…" or a right-click; a hidden / watched notice's Find in feed and Undo live only there.
|
||||
// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first.
|
||||
// i18n is key-passthrough, so labels read as keys.
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { api, type AppNotification } from "../../lib/api";
|
||||
import type { Notification as ClientNotif } from "../../lib/notifications";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
server: [] as unknown[],
|
||||
client: [] as unknown[],
|
||||
setPage: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
focusSettingsTab: vi.fn(),
|
||||
setFilters: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clearAll: vi.fn(),
|
||||
confirm: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
vi.mock("../../lib/useLiveQuery", () => ({
|
||||
useLiveQuery: () => ({
|
||||
data: { items: h.server, total: h.server.length },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
subscribe: () => () => {},
|
||||
getNotifications: () => h.client,
|
||||
getUnreadCount: () => 0,
|
||||
markAllRead: () => {},
|
||||
remove: (id: number) => h.remove(id),
|
||||
clearAll: () => h.clearAll(),
|
||||
}));
|
||||
vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ setPage: h.setPage }),
|
||||
}));
|
||||
vi.mock("../files/filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
|
||||
vi.mock("../channels/channelsContext", () => ({
|
||||
useChannelsActions: () => ({ focusChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../feed/feedFiltersContext", () => ({
|
||||
// Every narrowing a feed can carry (the year, the custom range, the length, the
|
||||
// content types and the scope survived "Find in feed" and could still hide the video).
|
||||
useFeedFilters: () => ({
|
||||
tags: [54],
|
||||
tagModes: { language: "and", topic: "or", other: "or" },
|
||||
untagged: true,
|
||||
q: "rust",
|
||||
maxAgeDays: 30,
|
||||
dateFrom: "2024-01-01",
|
||||
dateTo: "2024-02-01",
|
||||
year: 2024,
|
||||
minDuration: 60,
|
||||
maxDuration: 600,
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
scope: "my",
|
||||
librarySource: "organic",
|
||||
sort: "oldest",
|
||||
show: "unwatched",
|
||||
channelIds: ["x"],
|
||||
}),
|
||||
useFeedFiltersActions: () => ({ setFilters: h.setFilters }),
|
||||
}));
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import NotificationsPanel from "./NotificationsPanel";
|
||||
import PageShell from "../../components/ui/PageShell";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
for (const f of [
|
||||
h.setPage,
|
||||
h.openFile,
|
||||
h.focusSettingsTab,
|
||||
h.setFilters,
|
||||
h.remove,
|
||||
h.clearAll,
|
||||
h.confirm,
|
||||
])
|
||||
f.mockClear();
|
||||
});
|
||||
|
||||
const server = (id: number, extra: Partial<AppNotification> = {}) =>
|
||||
({
|
||||
id,
|
||||
type: "download_done",
|
||||
title: `Server ${id}`,
|
||||
body: `Body ${id}`,
|
||||
data: { title: `Ready ${id}` },
|
||||
read: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...extra,
|
||||
}) as unknown as AppNotification;
|
||||
const client = (id: number, extra: Partial<ClientNotif> = {}) =>
|
||||
({
|
||||
id,
|
||||
level: "success",
|
||||
message: `Activity ${id}`,
|
||||
requiresInteraction: false,
|
||||
ts: Date.now(),
|
||||
read: true,
|
||||
dismissed: true,
|
||||
transient: false,
|
||||
repeat: 1,
|
||||
...extra,
|
||||
}) as unknown as ClientNotif;
|
||||
|
||||
function mount() {
|
||||
const client = new QueryClient();
|
||||
const tree = () => (
|
||||
<QueryClientProvider client={client}>
|
||||
<NotificationsPanel />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(tree());
|
||||
return { rerender: () => rerender(tree()) };
|
||||
}
|
||||
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||
const moreOf = (text: string) =>
|
||||
rowOf(text).querySelector<HTMLElement>('button[aria-label="inbox.rowMenu"]')!;
|
||||
const menuRows = () =>
|
||||
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||
async function openMenu(open: () => void) {
|
||||
act(open);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||
);
|
||||
}
|
||||
const choose = (label: string) =>
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
|
||||
);
|
||||
|
||||
describe("NotificationsPanel · a server row", () => {
|
||||
it("keeps its link and mark read in sight; dismiss shows on hover, then its '…'", () => {
|
||||
h.server = [server(1)];
|
||||
h.client = [];
|
||||
mount();
|
||||
const row = rowOf("Ready 1");
|
||||
expect(row.classList.contains("group")).toBe(true);
|
||||
const buttons = [...row.querySelectorAll("button")];
|
||||
expect(buttons.map((b) => b.getAttribute("aria-label") ?? b.textContent)).toEqual([
|
||||
"inbox.openFile",
|
||||
"inbox.markRead",
|
||||
"inbox.dismiss",
|
||||
"inbox.rowMenu",
|
||||
]);
|
||||
// Hover-only on a mouse (and gone on touch, where the menu holds it).
|
||||
expect(buttons[2]!.className).toContain("reveal-hover");
|
||||
expect(moreOf("Ready 1").classList.contains("reveal")).toBe(true);
|
||||
});
|
||||
|
||||
it("a finished download opens on its file, in its folder; an older notice on Files", async () => {
|
||||
h.server = [
|
||||
server(1, { data: { title: "Ready 1", kind: "download", job_id: 41, folder_id: 3 } }),
|
||||
server(2),
|
||||
];
|
||||
h.client = [];
|
||||
mount();
|
||||
fireEvent.click(within(rowOf("Ready 1")).getByText("inbox.openFile"));
|
||||
expect(h.openFile).toHaveBeenCalledWith(41, 3);
|
||||
fireEvent.click(within(rowOf("Ready 2")).getByText("inbox.openFile"));
|
||||
expect(h.setPage).toHaveBeenCalledWith("files");
|
||||
expect(h.openFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a token about to run out is named in the reader's words and opens Settings on its tab", async () => {
|
||||
h.server = [
|
||||
server(1, {
|
||||
type: "api_token_expiring",
|
||||
title: "API token Phone expires soon",
|
||||
data: { token_id: 4, name: "Phone", expires_at: "2026-10-21T10:00:00+00:00" },
|
||||
}),
|
||||
];
|
||||
h.client = [];
|
||||
mount();
|
||||
const row = rowOf("inbox.apiTokenExpiring.title");
|
||||
expect(row.textContent).toContain("inbox.apiTokenExpiring.body");
|
||||
expect(row.textContent).not.toContain("API token Phone expires soon");
|
||||
fireEvent.click(within(row).getByText("inbox.openTokens"));
|
||||
expect(h.focusSettingsTab).toHaveBeenCalledWith("tokens");
|
||||
expect(h.setPage).toHaveBeenCalledWith("settings");
|
||||
await openMenu(() => fireEvent.click(moreOf("inbox.apiTokenExpiring.title")));
|
||||
expect(menuRows()).toEqual(["inbox.openTokens", "inbox.markRead", "inbox.dismiss"]);
|
||||
});
|
||||
|
||||
it("holds the link, mark read and dismiss", async () => {
|
||||
h.server = [server(1)];
|
||||
h.client = [];
|
||||
const dismiss = vi.spyOn(api, "dismissNotification").mockResolvedValue({} as never);
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Ready 1")));
|
||||
expect(menuRows()).toEqual(["inbox.openFile", "inbox.markRead", "inbox.dismiss"]);
|
||||
choose("inbox.dismiss");
|
||||
await waitFor(() => expect(dismiss).toHaveBeenCalledWith(1, expect.anything()));
|
||||
});
|
||||
|
||||
it("closes on a second press of the same '…', on a server and an activity row", async () => {
|
||||
h.server = [server(1)];
|
||||
h.client = [client(8)];
|
||||
mount();
|
||||
for (const name of ["Ready 1", "Activity 8"]) {
|
||||
await openMenu(() => fireEvent.click(moreOf(name)));
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf(name), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf(name));
|
||||
fireEvent.click(moreOf(name), { detail: 1 });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("still closes when a poll replaced the notification under the open menu", async () => {
|
||||
h.server = [server(1)];
|
||||
h.client = [];
|
||||
const { rerender } = mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Ready 1")));
|
||||
h.server = [server(1)]; // the next poll: a NEW object for the same notification
|
||||
rerender();
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Ready 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Ready 1"));
|
||||
fireEvent.click(moreOf("Ready 1"), { detail: 1 });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves mark read out of a read row's menu", async () => {
|
||||
h.server = [server(2, { read: true, type: "maintenance", data: {} } as never)];
|
||||
h.client = [];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Server 2")));
|
||||
expect(menuRows()).toEqual(["inbox.dismiss"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationsPanel · an activity row", () => {
|
||||
it("puts a hidden notice's Find in feed and Undo into its menu only", async () => {
|
||||
h.server = [];
|
||||
// Its toast is still up (not dismissed), so it carries the toast's Undo too: the row shows
|
||||
// neither that nor Find in feed — only its dismiss and its "…".
|
||||
h.client = [
|
||||
client(7, {
|
||||
dismissed: false,
|
||||
action: { label: "feed.undo", onClick: () => {} },
|
||||
meta: { kind: "video-hidden", videoId: "v", channelId: "c" },
|
||||
} as never),
|
||||
];
|
||||
const setState = vi.spyOn(api, "setState").mockResolvedValue({} as never);
|
||||
mount();
|
||||
expect(
|
||||
[...rowOf("Activity 7").querySelectorAll("button")].map((b) => b.getAttribute("aria-label")),
|
||||
).toEqual(["inbox.dismiss", "inbox.rowMenu"]);
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 7")));
|
||||
expect(menuRows()).toEqual([
|
||||
"notifications.findInFeed",
|
||||
"notifications.unhide",
|
||||
"inbox.dismiss",
|
||||
]);
|
||||
choose("notifications.unhide");
|
||||
await waitFor(() => expect(setState).toHaveBeenCalledWith("v", "new"));
|
||||
});
|
||||
|
||||
it("a live notice with two actions shows both, in the row and in its menu", async () => {
|
||||
h.server = [];
|
||||
const copy = vi.fn();
|
||||
const open = vi.fn();
|
||||
h.client = [
|
||||
client(5, {
|
||||
dismissed: false,
|
||||
action: { label: "tray.done.copyLink", onClick: copy },
|
||||
secondaryAction: { label: "tray.done.open", onClick: open },
|
||||
}),
|
||||
];
|
||||
mount();
|
||||
fireEvent.click(within(rowOf("Activity 5")).getByText("tray.done.open"));
|
||||
expect(open).toHaveBeenCalled();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 5")));
|
||||
expect(menuRows()).toEqual(["tray.done.copyLink", "tray.done.open", "inbox.dismiss"]);
|
||||
choose("tray.done.copyLink");
|
||||
await waitFor(() => expect(copy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("finds a watched notice's video with nothing narrowing the feed past it", async () => {
|
||||
h.server = [];
|
||||
h.client = [
|
||||
client(9, { meta: { kind: "video-watched", videoId: "v", channelId: "c" } } as never),
|
||||
];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Activity 9")));
|
||||
choose("notifications.findInFeed");
|
||||
await waitFor(() => expect(h.setFilters).toHaveBeenCalled());
|
||||
// Nothing but the state and the channel narrows; the reader's sort and tag modes stay theirs.
|
||||
expect(h.setFilters).toHaveBeenCalledWith({
|
||||
tags: [],
|
||||
tagModes: { language: "and", topic: "or", other: "or" },
|
||||
untagged: false,
|
||||
q: "",
|
||||
includeNormal: true,
|
||||
includeShorts: true,
|
||||
includeLive: true,
|
||||
scope: "all",
|
||||
librarySource: "all",
|
||||
sort: "oldest",
|
||||
show: "watched",
|
||||
channelIds: ["c"],
|
||||
});
|
||||
expect(h.setPage).toHaveBeenCalledWith("feed");
|
||||
});
|
||||
|
||||
it("dismisses a plain activity row from its menu", async () => {
|
||||
h.server = [];
|
||||
h.client = [client(8)];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Activity 8")));
|
||||
expect(menuRows()).toEqual(["inbox.dismiss"]);
|
||||
choose("inbox.dismiss");
|
||||
await waitFor(() => expect(h.remove).toHaveBeenCalledWith(8));
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationsPanel · the header's '…'", () => {
|
||||
it("keeps Mark all read in the band and asks before Clear everything", async () => {
|
||||
h.server = [server(1)];
|
||||
h.client = [client(8)];
|
||||
const clear = vi.spyOn(api, "clearNotifications").mockResolvedValue({} as never);
|
||||
mount();
|
||||
expect(screen.getByRole("button", { name: "inbox.markAllRead" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "inbox.clearAll" })).toBeNull();
|
||||
await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" })));
|
||||
expect(menuRows()).toEqual(["inbox.markAllRead", "inbox.clearAll"]);
|
||||
h.confirm.mockResolvedValueOnce(false);
|
||||
choose("inbox.clearAll");
|
||||
await waitFor(() => expect(h.confirm).toHaveBeenCalledTimes(1));
|
||||
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(h.clearAll).not.toHaveBeenCalled();
|
||||
await openMenu(() => fireEvent.click(screen.getByRole("button", { name: "inbox.menu" })));
|
||||
choose("inbox.clearAll");
|
||||
await waitFor(() => expect(clear).toHaveBeenCalled());
|
||||
expect(h.clearAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationsPanel · the page's background menu", () => {
|
||||
// DESIGN "Background menu": it mirrors the band, and a right-click on blank page must never be one
|
||||
// click from a destructive row — Clear everything stays behind the "…" and its confirm.
|
||||
it("holds Mark all read, never Clear everything", async () => {
|
||||
// PageShell measures its header and the scroller its edges; jsdom has no ResizeObserver.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
onTestFinished(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
h.server = [server(1)];
|
||||
h.client = [client(8)];
|
||||
const client_ = new QueryClient();
|
||||
const { container } = render(
|
||||
<QueryClientProvider client={client_}>
|
||||
<PageShell>
|
||||
<NotificationsPanel />
|
||||
</PageShell>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const blank = container.querySelector("main") ?? container.firstElementChild!;
|
||||
await openMenu(() => fireEvent.contextMenu(blank, { clientX: 5, clientY: 5 }));
|
||||
expect(screen.getByRole("menu", { hidden: true }).getAttribute("aria-label")).toBe(
|
||||
"common.pageMenu",
|
||||
);
|
||||
expect(menuRows()).toEqual(["inbox.markAllRead"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a download-lifecycle notice", () => {
|
||||
it("is named from its data in the reader's language, never the server's English title", () => {
|
||||
h.server = [
|
||||
server(1, {
|
||||
type: "download_shared",
|
||||
title: "Cats was shared with you",
|
||||
body: null,
|
||||
data: { kind: "download_shared", from: "a@b.c", title: "Cats" },
|
||||
} as never),
|
||||
server(2, {
|
||||
type: "download_expiring",
|
||||
title: "Dogs",
|
||||
data: { kind: "download_expiring", title: "Dogs" },
|
||||
} as never),
|
||||
server(3, {
|
||||
type: "download_expired",
|
||||
title: "download", // an untitled video: the server's English fallback word
|
||||
data: { kind: "download_expired", title: null },
|
||||
} as never),
|
||||
server(4, {
|
||||
type: "download_evicted",
|
||||
title: "Birds",
|
||||
data: { kind: "download_evicted", title: "Birds" },
|
||||
} as never),
|
||||
];
|
||||
mount();
|
||||
for (const key of ["downloadShared", "downloadExpiring", "downloadExpired", "downloadEvicted"])
|
||||
expect(screen.getByText(`inbox.${key}.title`)).toBeTruthy();
|
||||
for (const title of ["Cats", "Dogs", "Birds"]) expect(screen.getByText(title)).toBeTruthy();
|
||||
expect(screen.queryByText("Cats was shared with you")).toBeNull();
|
||||
expect(screen.queryByText("download")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationsPanel · a finished job's title", () => {
|
||||
it("names what finished: a download, a clip, an upload", () => {
|
||||
h.server = [
|
||||
server(1),
|
||||
server(2, { data: { title: "Ready 2", kind: "edit" } }),
|
||||
server(3, { data: { title: "Ready 3", kind: "upload" } }),
|
||||
];
|
||||
h.client = [];
|
||||
mount();
|
||||
const titleOf = (text: string) => rowOf(text).textContent ?? "";
|
||||
expect(titleOf("Ready 1")).toContain("inbox.downloadDone.title");
|
||||
expect(titleOf("Ready 2")).toContain("inbox.editDone.title");
|
||||
expect(titleOf("Ready 3")).toContain("inbox.uploadDone.title");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user