From 046dde44a4f890d9334f159527742c8a1ca36db7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 30 Oct 2026 00:17:46 +0100 Subject: [PATCH] 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. --- frontend/e2e/bookmark.spec.ts | 2 +- frontend/e2e/channel.spec.ts | 2 +- frontend/e2e/helpers.ts | 42 ++++--- .../lib/notifications.coalesce.dom.test.ts | 37 ++++++ .../NotificationsPanel.language.dom.test.tsx | 106 ++++++++++++++++++ 5 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/notifications.coalesce.dom.test.ts create mode 100644 frontend/src/modules/notifications/NotificationsPanel.language.dom.test.tsx diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts index a72aa2dd..99251ae3 100644 --- a/frontend/e2e/bookmark.spec.ts +++ b/frontend/e2e/bookmark.spec.ts @@ -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 }) => { await openApp(page, "/?page=feed"); 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(); await tab(page, "About").click(); const fresh = await bookmarked(browser, page); diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index e7b080a6..09c78984 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -48,7 +48,7 @@ test.describe("channel view", () => { // `.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 // 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 page.getByTestId("channel-back").click(); await expect(page.getByTestId("video-card").first()).toBeVisible(); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index b9407f91..3358e076 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -33,35 +33,41 @@ export async function gotoPage(page: Page, id: string): Promise { * 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 * 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. - * Looked for again until one is, not once: a fresh load is still settling when the first card shows - * (the links move down ~55px), and webkit can answer the hit test at a link's new centre with the - * card under it — every link missed in 1 of 30 runs. + * So the link must be the element hit at its own centre; none within the wait fails the test, with + * the poll's own message. Looked for again until one is, not once: a fresh load is still settling + * when the first card shows (the links move down ~55px), and webkit can answer the hit test at a + * 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 { +export async function clickVisibleChannelLink(page: Page): Promise { const links = page.getByTestId("video-card-channel").filter({ visible: true }); let index = -1; const findHittable = async () => { const count = await links.count(); for (let i = 0; i < count; i++) { - const hittable = await links.nth(i).evaluate((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); - }); + const hittable = await links + .nth(i) + .evaluate( + (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; } return -1; }; - try { - await expect - .poll(async () => (index = await findHittable()), { timeout: 5000 }) - .toBeGreaterThan(-1); - } catch { - return false; - } + await expect + .poll(async () => (index = await findHittable()), { + message: "a channel link hit at its own centre", + timeout: 5000, + }) + .toBeGreaterThan(-1); await links.nth(index).click(); - return true; } /** diff --git a/frontend/src/lib/notifications.coalesce.dom.test.ts b/frontend/src/lib/notifications.coalesce.dom.test.ts new file mode 100644 index 00000000..e339f6f1 --- /dev/null +++ b/frontend/src/lib/notifications.coalesce.dom.test.ts @@ -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]); + }); +}); diff --git a/frontend/src/modules/notifications/NotificationsPanel.language.dom.test.tsx b/frontend/src/modules/notifications/NotificationsPanel.language.dom.test.tsx new file mode 100644 index 00000000..81d4237b --- /dev/null +++ b/frontend/src/modules/notifications/NotificationsPanel.language.dom.test.tsx @@ -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()), + 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()), + 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) => + ({ + 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( + +
+ +
+
, + ); +} + +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(); + }); +});