diff --git a/frontend/src/PlaylistEditFailures.dom.test.tsx b/frontend/src/PlaylistEditFailures.dom.test.tsx index 16dff803..4ea1fc2b 100644 --- a/frontend/src/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/PlaylistEditFailures.dom.test.tsx @@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); const { notify } = vi.hoisted(() => ({ - notify: vi.fn((_n: { level: string; message: string }) => 0), + notify: vi.fn((_n: { level: string; message: NotifText }) => 0), })); vi.mock("./lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), @@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd"; import TagManager from "./features/channel/TagManager"; import { api, HttpError, type PlexCard } from "./lib/api"; import { qk } from "./lib/queryKeys"; +import type { NotifText } from "./lib/notifications"; beforeAll(() => { class Stub { @@ -98,7 +99,7 @@ describe("the Plex playlist page", () => { () => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!), "plex.playlist.removeFromPlaylist", ); - await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }])); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); expect(screen.getByText("Movie 1")).toBeTruthy(); }); @@ -119,7 +120,7 @@ describe("the Plex playlist page", () => { const field = await screen.findByDisplayValue("Films"); fireEvent.change(field, { target: { value: "Movies" } }); fireEvent.keyDown(field, { key: "Enter" }); - await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }])); expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again }); @@ -154,7 +155,7 @@ describe("the add-to-playlist popover", () => { ); fireEvent.click(screen.getByLabelText("playlists.addToPlaylist")); fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true })); - await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }])); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); }); @@ -282,7 +283,7 @@ describe("the add-to-playlist popover", () => { fireEvent.click(await screen.findByText("Gone")); // The server's own localized reason wins over the caller's fallback when the detail is // STRUCTURED — same as the twin above. What is under test is that anything is said at all. - await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }])); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); }); }); @@ -300,7 +301,7 @@ describe("the tag manager", () => { , ); fireEvent.click(await screen.findByLabelText("tagManager.delete")); - await waitFor(() => expect(errors()).toEqual(["gone tag"])); + await waitFor(() => expect(errors()).toEqual([{ detail: gone("tag").detailData }])); await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1)); }); }); diff --git a/frontend/src/WriteFailureReporting.dom.test.tsx b/frontend/src/WriteFailureReporting.dom.test.tsx index abffcd22..a8aa1779 100644 --- a/frontend/src/WriteFailureReporting.dom.test.tsx +++ b/frontend/src/WriteFailureReporting.dom.test.tsx @@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); const { notify } = vi.hoisted(() => ({ - notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0), + notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0), })); vi.mock("./lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), @@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor"; import UploadDialog from "./modules/files/UploadDialog"; import DownloadDialog from "./features/downloads/DownloadDialog"; import { api, HttpError } from "./lib/api"; +import { msg, type NotifText } from "./lib/notifications"; beforeAll(() => { class Stub { @@ -87,7 +88,7 @@ describe("the Plex collection editor", () => { ); mount(); fireEvent.click(await screen.findByText("Marathon")); - await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"])); + await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")])); }); it("says so when enabling editing is refused", async () => { @@ -97,7 +98,7 @@ describe("the Plex collection editor", () => { ); mount(); fireEvent.click(await screen.findByText("plex.collEditor.makeEditable")); - await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"])); + await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")])); }); it("says so when a CONFIRMED delete does not happen", async () => { @@ -108,7 +109,7 @@ describe("the Plex collection editor", () => { ); mount(); fireEvent.click(await screen.findByText("plex.collEditor.delete")); - await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"])); + await waitFor(() => expect(errors()).toEqual([msg("plex.collection.deleteFailed")])); }); }); @@ -144,7 +145,7 @@ describe("the upload dialog", () => { mount(); pick(); fireEvent.click(screen.getByText("downloads.upload.submit")); - await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"])); + await waitFor(() => expect(errors()).toEqual([msg("downloads.upload.failed")])); }); it("says nothing when the user cancelled it themselves", async () => { @@ -172,6 +173,6 @@ describe("the download dialog", () => { ); await screen.findByText("Best"); fireEvent.click(screen.getByText("downloads.dialog.submit")); - await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"])); + await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")])); }); }); diff --git a/frontend/src/components/ui/Modal.dom.test.tsx b/frontend/src/components/ui/Modal.dom.test.tsx index 4169ed69..06abb5e0 100644 --- a/frontend/src/components/ui/Modal.dom.test.tsx +++ b/frontend/src/components/ui/Modal.dom.test.tsx @@ -21,7 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => { vi.mock("../../lib/history", () => ({ useBackToClose: () => {} })); import Modal from "./Modal"; -import { clearAll, notify } from "../../lib/notifications"; +import { clearAll, notify, raw } from "../../lib/notifications"; import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack"; import { createPortal, flushSync } from "react-dom"; @@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => { , ); - act(() => void notify({ level: "info", message: "Saved" })); + act(() => void notify({ level: "info", message: raw("Saved") })); try { const dismissToast = screen.getByTitle("notifications.dismiss"); // The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast. @@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => { , ); - act(() => void notify({ level: "error", message: "Nope" })); + act(() => void notify({ level: "error", message: raw("Nope") })); const dismissToast = screen.getByTitle("notifications.dismiss"); dismissToast.focus(); expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven @@ -455,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => { , ); - act(() => void notify({ level: "error", message: "Couldn't share it." })); + act(() => void notify({ level: "error", message: raw("Couldn't share it.") })); expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it."); }); }); diff --git a/frontend/src/components/ui/Toaster.dom.test.tsx b/frontend/src/components/ui/Toaster.dom.test.tsx index 69b50ab6..91b124cd 100644 --- a/frontend/src/components/ui/Toaster.dom.test.tsx +++ b/frontend/src/components/ui/Toaster.dom.test.tsx @@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import Toaster from "./Toaster"; -import { clearAll, notify } from "../../lib/notifications"; +import { clearAll, notify, raw } from "../../lib/notifications"; import { dockUnderToasts } from "../../lib/toastDock"; afterEach(() => { @@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => { , ); - act(() => void notify({ level: "error", message: "Couldn't remove the video." })); + act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") })); expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video."); const overlay = render( @@ -54,7 +54,7 @@ describe("Toaster — one host shows the toasts", () => { it("lets a click through where there is no toast and nothing docked, and not where there is", () => { const Docked = () => ; const stop = dockUnderToasts(Docked); - notify({ level: "info", message: "hello" }); + notify({ level: "info", message: raw("hello") }); render(); const region = document.querySelector("[data-toast-region]")!; // The empty part of the region (beside and above a docked control) must not swallow the page's @@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => { , ); - act(() => void notify({ level: "error", message: "boom" })); + act(() => void notify({ level: "error", message: raw("boom") })); fireEvent.click(screen.getByText("boom")); expect(backdrop).not.toHaveBeenCalled(); }); @@ -86,11 +86,11 @@ describe("Toaster — handing the toasts to another host", () => { // The new host gets them as NEW nodes: a screen reader read every one out again, an error // assertively, each time a player (or any dialog) opened or closed. render(); - act(() => void notify({ level: "error", message: "old news" })); + act(() => void notify({ level: "error", message: raw("old news") })); render(); const old = screen.getByText("old news").closest(".toast-card")!; expect(old.getAttribute("role")).toBeNull(); - act(() => void notify({ level: "error", message: "fresh" })); + act(() => void notify({ level: "error", message: raw("fresh") })); expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert"); }); @@ -99,8 +99,8 @@ describe("Toaster — handing the toasts to another host", () => { // host last showed — re-read on every render, this host's own "first" turned silent the moment // "second" arrived (its own screen had become the handover by then). render(); - act(() => void notify({ level: "error", message: "first" })); - act(() => void notify({ level: "error", message: "second" })); + act(() => void notify({ level: "error", message: raw("first") })); + act(() => void notify({ level: "error", message: raw("second") })); const card = (text: string) => screen.getByText(text).closest(".toast-card")!; expect(card("first").getAttribute("role")).toBe("alert"); expect(card("second").getAttribute("role")).toBe("alert"); @@ -111,7 +111,7 @@ describe("Toaster — handing the toasts to another host", () => { // inserted announces them anyway. React flushes effects in the SAME task as that render, so the // arming has to wait for the next one — a screen reader reads the region's state after the task. render(); - act(() => void notify({ level: "info", message: "saved" })); + act(() => void notify({ level: "info", message: raw("saved") })); const overlay = render(); const region = overlay.container.querySelector("[aria-live]")!; expect(region.textContent).toContain("saved"); // it took the toast over… @@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {