diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e7685d6f..647047e3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,7 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.24.0", - "pdfjs-dist": "6.1.200", + "pdfjs-dist": "^6.2.108", "react": "^19.2.4", "react-dom": "^19.2.7", "sonner": "^2.0.7", diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx index 3ac2dd60..fb6e0585 100644 --- a/apps/desktop/src/features/score/ScoreViewer.test.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx @@ -1,17 +1,24 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist"; import { ScoreViewer } from "./ScoreViewer"; import { loadScorePdf } from "./pdfjs"; vi.mock("./pdfjs", () => ({ - loadScorePdf: vi.fn() + loadScorePdf: vi.fn(), })); vi.mock("../../i18n", () => ({ createTranslator: () => (key: string) => ({ - scoreViewerEmpty: "No score PDF attached. Attach a validated score PDF to view it here.", + scoreViewerEmpty: + "No score PDF attached. Attach a validated score PDF to view it here.", scoreViewerLoading: "Loading score PDF...", scoreViewerFailedTitle: "Could not display the score", scoreViewerRetry: "Retry", @@ -20,9 +27,9 @@ vi.mock("../../i18n", () => ({ scoreViewerPageIndicator: "Page {current} of {total}", scoreViewerZoomIn: "Zoom in", scoreViewerZoomOut: "Zoom out", - scoreViewerFitWidth: "Fit width" + scoreViewerFitWidth: "Fit width", })[key] ?? key, - detectPreferredLocale: () => "en" + detectPreferredLocale: () => "en", })); interface Deferred { @@ -47,9 +54,9 @@ function createFakePage(renderPromise: Promise = Promise.resolve()) { renderTask, getViewport: vi.fn(({ scale }: { scale: number }) => ({ width: 600 * scale, - height: 800 * scale + height: 800 * scale, })), - render: vi.fn(() => renderTask) + render: vi.fn(() => renderTask), }; } @@ -58,19 +65,19 @@ function createFakeDocument(numPages = 3, page = createFakePage()) { page, doc: { numPages, - getPage: vi.fn(() => Promise.resolve(page)) - } as unknown as PDFDocumentProxy + getPage: vi.fn(() => Promise.resolve(page)), + } as unknown as PDFDocumentProxy, }; } function mockLoadTaskOnce( promise: Promise, - destroy: () => Promise = () => Promise.resolve() + destroy: () => Promise = () => Promise.resolve(), ) { const destroyMock = vi.fn(destroy); vi.mocked(loadScorePdf).mockReturnValueOnce({ promise, - destroy: destroyMock + destroy: destroyMock, } as unknown as PDFDocumentLoadingTask); return { destroy: destroyMock }; } @@ -91,7 +98,9 @@ describe("ScoreViewer", () => { render(); expect( - screen.getByText("No score PDF attached. Attach a validated score PDF to view it here.") + screen.getByText( + "No score PDF attached. Attach a validated score PDF to view it here.", + ), ).toBeInTheDocument(); expect(loadScorePdf).not.toHaveBeenCalled(); expect(onStatusChange).not.toHaveBeenCalled(); @@ -120,8 +129,12 @@ describe("ScoreViewer", () => { expect(page.render).toHaveBeenCalled(); }); expect(page.getViewport).toHaveBeenCalledWith({ scale: 1 }); - expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled(); + expect( + screen.getByRole("button", { name: "Previous page" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + screen.getByRole("button", { name: "Next page" }), + ).not.toHaveAttribute("aria-disabled"); }); it("shows the file name when provided", async () => { @@ -147,12 +160,16 @@ describe("ScoreViewer", () => { // The FAILED status is set from the load promise's catch (a microtask), and // onStatusChange fires from a passive effect that may not have flushed the // instant the alert appears. Poll for it, matching the READY assertion below. - await waitFor(() => expect(onStatusChange).toHaveBeenLastCalledWith("FAILED")); + await waitFor(() => + expect(onStatusChange).toHaveBeenLastCalledWith("FAILED"), + ); fireEvent.click(screen.getByRole("button", { name: "Retry" })); expect(await screen.findByText("Page 1 of 2")).toBeInTheDocument(); - await waitFor(() => expect(onStatusChange).toHaveBeenLastCalledWith("READY")); + await waitFor(() => + expect(onStatusChange).toHaveBeenLastCalledWith("READY"), + ); expect(loadScorePdf).toHaveBeenCalledTimes(2); }); @@ -172,16 +189,18 @@ describe("ScoreViewer", () => { render(); expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); - const previousButton = screen.getByRole("button", { name: "Previous page" }); + const previousButton = screen.getByRole("button", { + name: "Previous page", + }); const nextButton = screen.getByRole("button", { name: "Next page" }); - expect(previousButton).toBeDisabled(); + expect(previousButton).toHaveAttribute("aria-disabled", "true"); fireEvent.click(nextButton); expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); fireEvent.click(nextButton); expect(screen.getByText("Page 3 of 3")).toBeInTheDocument(); - expect(nextButton).toBeDisabled(); + expect(nextButton).toHaveAttribute("aria-disabled", "true"); await waitFor(() => { expect(doc.getPage).toHaveBeenCalledWith(3); @@ -262,7 +281,7 @@ describe("ScoreViewer", () => { await act(async () => { resizeCallback?.( [{ contentRect: { width: 300 } } as ResizeObserverEntry], - {} as ResizeObserver + {} as ResizeObserver, ); }); @@ -290,7 +309,7 @@ describe("ScoreViewer", () => { it("keeps the READY layout when fetching a page fails after load", async () => { const doc = { numPages: 1, - getPage: vi.fn(() => Promise.reject(new Error("destroyed"))) + getPage: vi.fn(() => Promise.reject(new Error("destroyed"))), } as unknown as PDFDocumentProxy; mockLoadTaskOnce(Promise.resolve(doc)); @@ -306,12 +325,12 @@ describe("ScoreViewer", () => { it("destroys the loading task on unmount and ignores late results", async () => { const deferred = createDeferred(); const { destroy } = mockLoadTaskOnce(deferred.promise, () => - Promise.reject(new Error("already destroyed")) + Promise.reject(new Error("already destroyed")), ); const onStatusChange = vi.fn(); const { unmount } = render( - + , ); unmount(); @@ -330,7 +349,7 @@ describe("ScoreViewer", () => { const onStatusChange = vi.fn(); const { unmount } = render( - + , ); unmount(); diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx index 82692469..ab47f86d 100644 --- a/apps/desktop/src/features/score/ScoreViewer.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.tsx @@ -45,7 +45,11 @@ const MAX_ZOOM = 4; * error with retry, READY canvas) plus rehearsal-friendly page navigation * and zoom in/out/fit-width controls. */ -export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps) { +export function ScoreViewer({ + data, + fileName, + onStatusChange, +}: ScoreViewerProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); const [status, setStatus] = useState("LOADING"); const [errorMessage, setErrorMessage] = useState(null); @@ -102,7 +106,11 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps useEffect(() => { const container = containerRef.current; - if (status !== "READY" || !container || typeof ResizeObserver === "undefined") { + if ( + status !== "READY" || + !container || + typeof ResizeObserver === "undefined" + ) { return; } @@ -132,7 +140,9 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps } const baseViewport = page.getViewport({ scale: 1 }); const scale = - fitWidth && containerWidth > 0 ? containerWidth / baseViewport.width : zoom; + fitWidth && containerWidth > 0 + ? containerWidth / baseViewport.width + : zoom; const viewport = page.getViewport({ scale }); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); @@ -205,8 +215,13 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps aria-busy="true" > - ); @@ -223,13 +238,19 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
-

{t("scoreViewerFailedTitle")}

+

+ {t("scoreViewerFailedTitle")} +

{errorMessage && (

{errorMessage}

)} - @@ -248,7 +269,10 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
{fileName && (
-
)} @@ -258,6 +282,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps size="icon-lg" className="size-12" aria-label={t("scoreViewerZoomOut")} + title={t("scoreViewerZoomOut")} onClick={zoomOut} >
-
+
@@ -292,8 +322,15 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps size="icon-lg" className="size-14" aria-label={t("scoreViewerPrevPage")} - disabled={pageNumber <= 1} - onClick={goToPreviousPage} + title={t("scoreViewerPrevPage")} + aria-disabled={pageNumber <= 1 ? "true" : undefined} + onClick={(e) => { + if (pageNumber <= 1) { + e.preventDefault(); + return; + } + goToPreviousPage(); + }} >