test(notifications): the center re-says its rows after a switch, a repeat coalesces by content; e2e helper fails with a cause
The center's rows (title, message, refusal, live button) were never rendered across a language switch, and nothing held the structural compare that coalescing now needs. The channel-link helper swallowed whatever ended its poll into a bare false: a link that goes away mid-look now counts as not hit, and no hittable link fails with the poll's own message.
This commit is contained in:
@@ -196,7 +196,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
|
|||||||
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
|
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
|
||||||
await openApp(page, "/?page=feed");
|
await openApp(page, "/?page=feed");
|
||||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||||
expect(await clickVisibleChannelLink(page)).toBe(true);
|
await clickVisibleChannelLink(page);
|
||||||
const title = await page.getByTestId("channel-title").textContent();
|
const title = await page.getByTestId("channel-title").textContent();
|
||||||
await tab(page, "About").click();
|
await tab(page, "About").click();
|
||||||
const fresh = await bookmarked(browser, page);
|
const fresh = await bookmarked(browser, page);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test.describe("channel view", () => {
|
|||||||
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
|
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
|
||||||
// view would move the feed before navigating, so Back would restore that moved position, not
|
// view would move the feed before navigating, so Back would restore that moved position, not
|
||||||
// the one under test. (This is what made the test fail while the app was in fact correct.)
|
// the one under test. (This is what made the test fail while the app was in fact correct.)
|
||||||
expect(await clickVisibleChannelLink(page)).toBe(true);
|
await clickVisibleChannelLink(page);
|
||||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||||
await page.getByTestId("channel-back").click();
|
await page.getByTestId("channel-back").click();
|
||||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||||
|
|||||||
+24
-18
@@ -33,35 +33,41 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
|
|||||||
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card
|
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card
|
||||||
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
|
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
|
||||||
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
|
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
|
||||||
* So the link must be the element hit at its own centre. Returns false if none found.
|
* So the link must be the element hit at its own centre; none within the wait fails the test, with
|
||||||
* Looked for again until one is, not once: a fresh load is still settling when the first card shows
|
* the poll's own message. Looked for again until one is, not once: a fresh load is still settling
|
||||||
* (the links move down ~55px), and webkit can answer the hit test at a link's new centre with the
|
* when the first card shows (the links move down ~55px), and webkit can answer the hit test at a
|
||||||
* card under it — every link missed in 1 of 30 runs.
|
* link's new centre with the card under it — every link missed in 1 of 30 runs. A link that goes
|
||||||
|
* away mid-look (its evaluate waits for it, or throws) counts as not hit, so the next look runs.
|
||||||
*/
|
*/
|
||||||
export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
|
export async function clickVisibleChannelLink(page: Page): Promise<void> {
|
||||||
const links = page.getByTestId("video-card-channel").filter({ visible: true });
|
const links = page.getByTestId("video-card-channel").filter({ visible: true });
|
||||||
let index = -1;
|
let index = -1;
|
||||||
const findHittable = async () => {
|
const findHittable = async () => {
|
||||||
const count = await links.count();
|
const count = await links.count();
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const hittable = await links.nth(i).evaluate((el) => {
|
const hittable = await links
|
||||||
const r = el.getBoundingClientRect();
|
.nth(i)
|
||||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
.evaluate(
|
||||||
return hit !== null && el.contains(hit);
|
(el) => {
|
||||||
});
|
const r = el.getBoundingClientRect();
|
||||||
|
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||||
|
return hit !== null && el.contains(hit);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 1000 },
|
||||||
|
)
|
||||||
|
.catch(() => false);
|
||||||
if (hittable) return i;
|
if (hittable) return i;
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
try {
|
await expect
|
||||||
await expect
|
.poll(async () => (index = await findHittable()), {
|
||||||
.poll(async () => (index = await findHittable()), { timeout: 5000 })
|
message: "a channel link hit at its own centre",
|
||||||
.toBeGreaterThan(-1);
|
timeout: 5000,
|
||||||
} catch {
|
})
|
||||||
return false;
|
.toBeGreaterThan(-1);
|
||||||
}
|
|
||||||
await links.nth(index).click();
|
await links.nth(index).click();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A burst of the same notice is one entry with a count (×N), not a pile of copies. The texts are
|
||||||
|
// objects now (what to say, built afresh by every msg() call), so "the same" is the same key and
|
||||||
|
// values, never the same object: a compare by reference would let every repeat stack.
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { clearAll, getNotifications, msg, notify, raw } from "./notifications";
|
||||||
|
|
||||||
|
afterEach(() => clearAll());
|
||||||
|
|
||||||
|
describe("coalescing a repeated notice", () => {
|
||||||
|
it("folds the same key and values into one entry, counted", () => {
|
||||||
|
notify({
|
||||||
|
level: "error",
|
||||||
|
title: msg("errors.title"),
|
||||||
|
message: msg("errors.reason.unreachable"),
|
||||||
|
});
|
||||||
|
notify({
|
||||||
|
level: "error",
|
||||||
|
title: msg("errors.title"),
|
||||||
|
message: msg("errors.reason.unreachable"),
|
||||||
|
});
|
||||||
|
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds the same server refusal too", () => {
|
||||||
|
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
|
||||||
|
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
|
||||||
|
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps two notices apart when only a value differs", () => {
|
||||||
|
notify({ message: msg("channels.notify.hidden", { name: raw("One") }) });
|
||||||
|
notify({ message: msg("channels.notify.hidden", { name: raw("Two") }) });
|
||||||
|
expect(getNotifications().map((n) => n.repeat)).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The notification center says this browser's notices in the reader's language of the moment: a
|
||||||
|
// row raised in English reads Hungarian after a switch, its title, its message, a server refusal
|
||||||
|
// and the button of a notice still live. The real i18n, not the key passthrough the other panel
|
||||||
|
// tests use.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import i18n from "../../i18n";
|
||||||
|
import { api, type AppNotification } from "../../lib/api";
|
||||||
|
import type { Notification as ClientNotif } from "../../lib/notifications";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({ client: [] as unknown[] }));
|
||||||
|
|
||||||
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => vi.fn() }));
|
||||||
|
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||||
|
subscribe: () => () => {},
|
||||||
|
getNotifications: () => h.client,
|
||||||
|
getUnreadCount: () => 0,
|
||||||
|
markAllRead: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||||
|
useNavigationActions: () => ({ setPage: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../files/filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) }));
|
||||||
|
vi.mock("../channels/channelsContext", () => ({
|
||||||
|
useChannelsActions: () => ({ focusChannel: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../feed/feedFiltersContext", () => ({
|
||||||
|
useFeedFilters: () => ({}),
|
||||||
|
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
||||||
|
}));
|
||||||
|
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 { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
vi.spyOn(api, "notifications").mockResolvedValue({
|
||||||
|
items: [] as AppNotification[],
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
const notice = (id: number, extra: Partial<ClientNotif>) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
level: "info",
|
||||||
|
message: msg("common.copy.done"),
|
||||||
|
requiresInteraction: false,
|
||||||
|
ts: Date.now() - 60_000,
|
||||||
|
read: false,
|
||||||
|
dismissed: false,
|
||||||
|
transient: false,
|
||||||
|
repeat: 1,
|
||||||
|
...extra,
|
||||||
|
}) as unknown as ClientNotif;
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<main>
|
||||||
|
<NotificationsPanel />
|
||||||
|
</main>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NotificationsPanel · the reader's language", () => {
|
||||||
|
it("says a row's title, message, refusal and live button again after a switch", async () => {
|
||||||
|
h.client = [
|
||||||
|
notice(1, {
|
||||||
|
title: msg("views.actionFailed"),
|
||||||
|
message: { detail: { code: "not_found", reason: "view" } },
|
||||||
|
level: "error",
|
||||||
|
}),
|
||||||
|
notice(2, { action: { label: msg("common.undo"), onClick: () => {} } }),
|
||||||
|
];
|
||||||
|
mount();
|
||||||
|
const en = i18n.getFixedT("en");
|
||||||
|
const hu = i18n.getFixedT("hu");
|
||||||
|
expect(await screen.findByText(en("views.actionFailed"))).toBeTruthy();
|
||||||
|
expect(screen.getByText("This view doesn't exist any more")).toBeTruthy();
|
||||||
|
expect(screen.getByText(en("common.copy.done"))).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: en("common.undo") })).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||||
|
|
||||||
|
expect(screen.getByText(hu("views.actionFailed"))).toBeTruthy();
|
||||||
|
expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy();
|
||||||
|
expect(screen.getByText(hu("common.copy.done"))).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: hu("common.undo") })).toBeTruthy();
|
||||||
|
expect(screen.queryByText(en("views.actionFailed"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user