Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 43 additions & 24 deletions apps/desktop/src/features/score/ScoreViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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<T> {
Expand All @@ -47,9 +54,9 @@ function createFakePage(renderPromise: Promise<void> = 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),
};
}

Expand All @@ -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<unknown>,
destroy: () => Promise<void> = () => Promise.resolve()
destroy: () => Promise<void> = () => Promise.resolve(),
) {
const destroyMock = vi.fn(destroy);
vi.mocked(loadScorePdf).mockReturnValueOnce({
promise,
destroy: destroyMock
destroy: destroyMock,
} as unknown as PDFDocumentLoadingTask);
return { destroy: destroyMock };
}
Expand All @@ -91,7 +98,9 @@ describe("ScoreViewer", () => {
render(<ScoreViewer data={null} onStatusChange={onStatusChange} />);

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();
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});

Expand All @@ -172,16 +189,18 @@ describe("ScoreViewer", () => {
render(<ScoreViewer data={SAMPLE_BYTES} />);

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);
Expand Down Expand Up @@ -262,7 +281,7 @@ describe("ScoreViewer", () => {
await act(async () => {
resizeCallback?.(
[{ contentRect: { width: 300 } } as ResizeObserverEntry],
{} as ResizeObserver
{} as ResizeObserver,
);
});

Expand Down Expand Up @@ -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));

Expand All @@ -306,12 +325,12 @@ describe("ScoreViewer", () => {
it("destroys the loading task on unmount and ignores late results", async () => {
const deferred = createDeferred<PDFDocumentProxy>();
const { destroy } = mockLoadTaskOnce(deferred.promise, () =>
Promise.reject(new Error("already destroyed"))
Promise.reject(new Error("already destroyed")),
);
const onStatusChange = vi.fn();

const { unmount } = render(
<ScoreViewer data={SAMPLE_BYTES} onStatusChange={onStatusChange} />
<ScoreViewer data={SAMPLE_BYTES} onStatusChange={onStatusChange} />,
);
unmount();

Expand All @@ -330,7 +349,7 @@ describe("ScoreViewer", () => {
const onStatusChange = vi.fn();

const { unmount } = render(
<ScoreViewer data={SAMPLE_BYTES} onStatusChange={onStatusChange} />
<ScoreViewer data={SAMPLE_BYTES} onStatusChange={onStatusChange} />,
);
unmount();

Expand Down
70 changes: 57 additions & 13 deletions apps/desktop/src/features/score/ScoreViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScoreViewerStatus>("LOADING");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -205,8 +215,13 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
aria-busy="true"
>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="mb-4 size-10 animate-spin text-cyan-300" aria-hidden="true" />
<p className="animate-pulse text-slate-400">{t("scoreViewerLoading")}</p>
<Loader2
className="mb-4 size-10 animate-spin text-cyan-300"
aria-hidden="true"
/>
<p className="animate-pulse text-slate-400">
{t("scoreViewerLoading")}
</p>
</CardContent>
</Card>
);
Expand All @@ -223,13 +238,19 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
<div className="mb-4 rounded-full border border-rose-300/30 bg-rose-300/10 p-4 text-rose-200">
<AlertCircle className="size-8" aria-hidden="true" />
</div>
<h3 className="mb-2 text-lg font-black text-rose-100">{t("scoreViewerFailedTitle")}</h3>
<h3 className="mb-2 text-lg font-black text-rose-100">
{t("scoreViewerFailedTitle")}
</h3>
{errorMessage && (
<p className="mb-4 rounded-md bg-rose-300/10 px-4 py-2 text-sm font-medium text-rose-100">
{errorMessage}
</p>
)}
<Button variant="outline" className="h-12 min-w-32 text-base" onClick={retry}>
<Button
variant="outline"
className="h-12 min-w-32 text-base"
onClick={retry}
>
<RotateCw aria-hidden="true" />
{t("scoreViewerRetry")}
</Button>
Expand All @@ -248,7 +269,10 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
<div className="flex flex-wrap items-center justify-between gap-3">
{fileName && (
<div className="flex min-w-0 items-center text-sm font-semibold text-slate-200">
<FileMusic className="mr-2 size-4 shrink-0 text-cyan-300" aria-hidden="true" />
<FileMusic
className="mr-2 size-4 shrink-0 text-cyan-300"
aria-hidden="true"
/>
<span className="truncate">{fileName}</span>
</div>
)}
Expand All @@ -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}
>
<ZoomOut aria-hidden="true" />
Expand All @@ -267,6 +292,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
size="icon-lg"
className="size-12"
aria-label={t("scoreViewerZoomIn")}
title={t("scoreViewerZoomIn")}
onClick={zoomIn}
>
<ZoomIn aria-hidden="true" />
Expand All @@ -275,6 +301,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
variant={fitWidth ? "secondary" : "outline"}
className="h-12 px-4 text-base"
aria-label={t("scoreViewerFitWidth")}
title={t("scoreViewerFitWidth")}
aria-pressed={fitWidth}
onClick={fitToWidth}
>
Expand All @@ -283,7 +310,10 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
</Button>
</div>
</div>
<div ref={containerRef} className="overflow-auto rounded-lg border border-white/10 bg-slate-900/60">
<div
ref={containerRef}
className="overflow-auto rounded-lg border border-white/10 bg-slate-900/60"
>
<canvas ref={canvasRef} className="mx-auto block max-w-none" />
</div>
<div className="flex items-center justify-center gap-4">
Expand All @@ -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();
}}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</Button>
Expand All @@ -305,8 +342,15 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerNextPage")}
disabled={pageNumber >= pageCount}
onClick={goToNextPage}
title={t("scoreViewerNextPage")}
aria-disabled={pageNumber >= pageCount ? "true" : undefined}
onClick={(e) => {
if (pageNumber >= pageCount) {
e.preventDefault();
return;
}
goToNextPage();
}}
>
<ChevronRight className="size-6" aria-hidden="true" />
</Button>
Expand Down
Loading
Loading