An action label was translated when the notice was raised, so after a language switch a live toast or center row read in one language with its button in the other. The access-requests nudge no longer re-issues on a switch for it. New tests: the button and a server refusal follow a switch.
482 lines
18 KiB
TypeScript
482 lines
18 KiB
TypeScript
// @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 { msg, type Notification as ClientNotif, raw } 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: raw(`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("a Takeout import's run opens the user's own import, any other run the Scheduler", async () => {
|
|
const run = (id: number, job: string) =>
|
|
server(id, {
|
|
type: "scheduler",
|
|
data: { job_id: job, status: "ok", summary: `${job} done` },
|
|
});
|
|
h.server = [run(1, "takeout_import"), run(2, "channel_backfill")];
|
|
h.client = [];
|
|
mount();
|
|
fireEvent.click(screen.getByText("inbox.openTakeout"));
|
|
expect(h.focusSettingsTab).toHaveBeenCalledWith("account");
|
|
expect(h.setPage).toHaveBeenLastCalledWith("settings");
|
|
fireEvent.click(screen.getByText("inbox.openScheduler"));
|
|
expect(h.setPage).toHaveBeenLastCalledWith("scheduler");
|
|
const takeoutRow = screen
|
|
.getByText("inbox.openTakeout")
|
|
.closest<HTMLElement>("[data-nav-index]")!;
|
|
await openMenu(() =>
|
|
fireEvent.click(takeoutRow.querySelector<HTMLElement>('button[aria-label="inbox.rowMenu"]')!),
|
|
);
|
|
expect(menuRows()).toEqual(["inbox.openTakeout", "inbox.markRead", "inbox.dismiss"]);
|
|
h.focusSettingsTab.mockClear();
|
|
h.setPage.mockClear();
|
|
choose("inbox.openTakeout");
|
|
await waitFor(() => expect(h.focusSettingsTab).toHaveBeenCalledWith("account"));
|
|
expect(h.setPage).toHaveBeenCalledWith("settings");
|
|
});
|
|
|
|
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: msg("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: msg("tray.done.copyLink"), onClick: copy },
|
|
secondaryAction: { label: msg("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");
|
|
});
|
|
});
|