diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..7a4b11855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Changed + +- Keep unavailable Add/Open/Remove score actions and PDF pagination controls keyboard-focusable, expose their unavailable state and recovery copy to assistive technology, and prevent project-missing, pagination-boundary, or repeated in-flight attach activation at the action boundary. + ## [0.1.3] - 2026-04-29 ### Fixed @@ -65,4 +69,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file diff --git a/apps/desktop/src/features/score/ScoreView.disabled-action-accessibility.test.tsx b/apps/desktop/src/features/score/ScoreView.disabled-action-accessibility.test.tsx new file mode 100644 index 000000000..dbe9250f5 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreView.disabled-action-accessibility.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { invoke } from "@tauri-apps/api/core"; +import { ScoreView } from "./ScoreView"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn() +})); + +vi.mock("./ScoreViewer", () => ({ + ScoreViewer: () =>
+})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewTitle: "Score", + scoreViewSubtitle: "Attach validated PDF scores to the current song.", + scoreListTitle: "Attached scores", + scoreAttach: "Add score", + scoreRemove: "Remove", + scoreOpen: "Open score", + scoreRequiresProject: "Scores attach to the active analysis project.", + scoreNavDisabledHint: "Analyze or open a song first" + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +const song = { + id: "song-1", + title: "Late Night Set", + scoreAttachments: [{ id: "score-1", fileName: "opener.pdf" }] +} as RehearsalSong; + +describe("ScoreView unavailable action accessibility", () => { + it("links focusable unavailable actions to localized recovery copy and blocks activation", () => { + render(); + + const requirement = screen.getByText("Scores attach to the active analysis project."); + const addButton = screen.getByRole("button", { name: "Add score" }); + const openButton = screen.getByRole("button", { name: "Open score: opener.pdf" }); + const removeButton = screen.getByRole("button", { name: "Remove: opener.pdf" }); + + for (const button of [addButton, openButton, removeButton]) { + expect(button).toHaveAttribute("aria-disabled", "true"); + expect(button).toHaveAttribute("aria-describedby", requirement.id); + expect(button).toHaveAttribute("title", "Analyze or open a song first"); + expect(button).not.toBeDisabled(); + } + + fireEvent.click(addButton); + fireEvent.click(openButton); + fireEvent.click(removeButton); + expect(vi.mocked(invoke)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/features/score/ScoreView.enabled-action-tooltips.test.tsx b/apps/desktop/src/features/score/ScoreView.enabled-action-tooltips.test.tsx new file mode 100644 index 000000000..e7a32dd43 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreView.enabled-action-tooltips.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { ScoreView } from "./ScoreView"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn() +})); + +vi.mock("./ScoreViewer", () => ({ + ScoreViewer: () =>
Mock Viewer
+})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreOpen: "Open score", + scoreRemove: "Remove" + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +describe("ScoreView enabled action tooltips", () => { + it("exposes localized pointer tooltips for enabled open and remove actions", () => { + const song = { + id: "song-1", + title: "Test", + scoreAttachments: [{ id: "doc1", fileName: "opener.pdf" }] + } as RehearsalSong; + + render(); + + expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toHaveAttribute("title", "Open score: opener.pdf"); + expect(screen.getByRole("button", { name: "Remove: opener.pdf" })).toHaveAttribute( + "title", + "Remove: opener.pdf" + ); + }); +}); diff --git a/apps/desktop/src/features/score/ScoreView.error-privacy.test.tsx b/apps/desktop/src/features/score/ScoreView.error-privacy.test.tsx new file mode 100644 index 000000000..5e37ec7d9 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreView.error-privacy.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { beforeEach, expect, it, vi } from "vitest"; + +import { ScoreView } from "./ScoreView"; +import { attachScorePdf } from "./scoreStorage"; + +vi.mock("./scoreStorage", () => ({ + attachScorePdf: vi.fn(), + readScorePdf: vi.fn(), + removeScorePdf: vi.fn() +})); + +vi.mock("./ScoreViewer", () => ({ + ScoreViewer: () =>
+})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewTitle: "Score", + scoreViewSubtitle: "Attach validated PDF scores to the current song.", + scoreListTitle: "Attached scores", + scoreListEmpty: "No scores attached to this song yet.", + scoreAttach: "Add score", + scoreAttaching: "Attaching...", + scoreRemove: "Remove", + scoreRemoveConfirm: "Remove {fileName} from this song?", + scoreOpen: "Open score", + scoreOpening: "Opening score PDF...", + scoreAttachFailed: "Could not attach the score PDF.", + scoreReadFailed: "Could not open the score PDF.", + scoreRemoveFailed: "Could not remove the score PDF.", + scoreRequiresProject: "Scores attach to the active analysis project.", + scoreNavDisabledHint: "Open an active project first." + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +const mockAttachScorePdf = vi.mocked(attachScorePdf); + +function makeSong(): RehearsalSong { + return { + id: "song-1", + title: "Late Night Set", + sections: [], + exportSummary: { format: "cue-sheet", headline: "", focusSections: [] } + } as RehearsalSong; +} + +beforeEach(() => { + mockAttachScorePdf.mockReset(); +}); + +it("does not render dependency-controlled score bridge secrets or local paths", async () => { + mockAttachScorePdf.mockRejectedValueOnce( + new Error("Failed to open /Users/Alice/private-score.pdf token=super-secret") + ); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Could not attach the score PDF."); + expect(alert).not.toHaveTextContent("/Users/Alice"); + expect(alert).not.toHaveTextContent("private-score.pdf"); + expect(alert).not.toHaveTextContent("token=super-secret"); +}); diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index de4ccb95c..c66a2bdc2 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; import { invoke } from "@tauri-apps/api/core"; @@ -90,17 +90,74 @@ describe("ScoreView", () => { expect(mockInvoke).not.toHaveBeenCalled(); }); - it("disables score storage actions when no project workspace is active", () => { + it("keeps unavailable score storage actions focusable when no project workspace is active", () => { const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); render(); expect(screen.getByText("Scores attach to the active analysis project.")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Add score" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Remove: opener.pdf" })).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + const addBtn = screen.getByRole("button", { name: "Add score" }); + expect(addBtn).toHaveAttribute("aria-disabled", "true"); + expect(addBtn).toHaveAttribute("aria-describedby"); + expect(addBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + expect(addBtn).toHaveAttribute("title", "scoreNavDisabledHint"); + expect(addBtn).not.toBeDisabled(); + + const openBtn = screen.getByRole("button", { name: "Open score: opener.pdf" }); + expect(openBtn).toHaveAttribute("aria-disabled", "true"); + expect(openBtn).toHaveAttribute("aria-describedby"); + expect(openBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + expect(openBtn).toHaveAttribute("title", "scoreNavDisabledHint"); + + const removeBtn = screen.getByRole("button", { name: "Remove: opener.pdf" }); + expect(removeBtn).toHaveAttribute("aria-disabled", "true"); + expect(removeBtn).toHaveAttribute("aria-describedby"); + expect(removeBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + expect(removeBtn).toHaveAttribute("title", "scoreNavDisabledHint"); + + const addClickEvent = createEvent.click(addBtn); + fireEvent(addBtn, addClickEvent); + expect(addClickEvent.defaultPrevented).toBe(true); + expect(mockInvoke).not.toHaveBeenCalled(); + + const openClickEvent = createEvent.click(openBtn); + fireEvent(openBtn, openClickEvent); + expect(openClickEvent.defaultPrevented).toBe(true); expect(mockInvoke).not.toHaveBeenCalled(); + + const clickEvent = createEvent.click(removeBtn); + fireEvent(removeBtn, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + }); + + it("blocks repeated attach activation while an attach is already pending", async () => { + let resolveAttach!: (value: unknown) => void; + mockInvoke + .mockImplementationOnce(() => new Promise((resolve) => { resolveAttach = resolve; })) + .mockResolvedValueOnce([1, 2, 3]); + const onSongUpdate = vi.fn(); + + render(); + + const addBtn = screen.getByRole("button", { name: "Add score" }); + fireEvent.click(addBtn); + + await waitFor(() => { + expect(addBtn).toHaveAttribute("aria-disabled", "true"); + }); + + const repeatedClick = createEvent.click(addBtn); + fireEvent(addBtn, repeatedClick); + expect(repeatedClick.defaultPrevented).toBe(true); + expect(mockInvoke).toHaveBeenCalledTimes(1); + + resolveAttach(attachResponse()); + + await waitFor(() => { + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:3:opener.pdf"); + }); + expect(mockInvoke).toHaveBeenCalledTimes(2); + expect(onSongUpdate).toHaveBeenCalledTimes(1); }); it("attaches a score, persists the metadata, and opens the new PDF", async () => { diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index 72732450f..c3d857e2b 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useId, useMemo, useRef, useState } from "react"; import { FileMusic, FilePlus2, Loader2, Trash2 } from "lucide-react"; import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; import { createTranslator, detectPreferredLocale } from "../../i18n"; @@ -29,7 +29,14 @@ export interface ScoreViewProps { function bridgeErrorDetail(error: unknown, fallback: string): string { const raw = error instanceof Error ? error.message : typeof error === "string" ? error : null; const firstLine = raw?.split(/\r?\n/)[0]?.trim(); - return firstLine ? firstLine : fallback; + if (!firstLine) return fallback; + + // Protect against dependency information leakage (paths and secrets) + if (firstLine.includes("/") || firstLine.includes("\\") || firstLine.toLowerCase().includes("token=")) { + return fallback; + } + + return firstLine; } /** @@ -39,6 +46,7 @@ function bridgeErrorDetail(error: unknown, fallback: string): string { */ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const scoreRequiresProjectId = useId(); const attachments = useMemo(() => song.scoreAttachments ?? [], [song.scoreAttachments]); const [selected, setSelected] = useState(null); const [pdfBytes, setPdfBytes] = useState(null); @@ -78,8 +86,8 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { /** * Attach a new score PDF via the native picker and open it. The attach - * control is disabled while `isAttaching`, so overlapping attaches cannot be - * started; the active project id is supplied by the enabled control. + * control is action-guarded while `isAttaching`, so overlapping attaches + * cannot be started; the active project id is supplied by the enabled control. */ const handleAttach = async (activeProjectId: string) => { setError(null); @@ -134,10 +142,18 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {

{t("scoreViewSubtitle")}

{!projectId && ( -

+

{t("scoreRequiresProject")}

)} @@ -183,11 +202,19 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { > diff --git a/apps/desktop/src/features/score/ScoreViewer.disabled-navigation-accessibility.test.tsx b/apps/desktop/src/features/score/ScoreViewer.disabled-navigation-accessibility.test.tsx new file mode 100644 index 000000000..78eb4f92d --- /dev/null +++ b/apps/desktop/src/features/score/ScoreViewer.disabled-navigation-accessibility.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { 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() +})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewerPrevPage: "Previous page", + scoreViewerNextPage: "Next page", + scoreViewerPageIndicator: "Page {current} of {total}", + scoreViewerZoomIn: "Zoom in", + scoreViewerZoomOut: "Zoom out", + scoreViewerFitWidth: "Fit width", + scoreViewerPrevPageDisabled: "Previous page (Unavailable)", + scoreViewerNextPageDisabled: "Next page (Unavailable)" + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +const SAMPLE_BYTES = new Uint8Array([0x25, 0x50, 0x44, 0x46]); + +function createFakeDocument(): PDFDocumentProxy { + const page = { + getViewport: vi.fn(({ scale }: { scale: number }) => ({ + width: 600 * scale, + height: 800 * scale + })), + render: vi.fn(() => ({ + promise: Promise.resolve(), + cancel: vi.fn() + })) + }; + + return { + numPages: 3, + getPage: vi.fn(() => Promise.resolve(page)) + } as unknown as PDFDocumentProxy; +} + +describe("ScoreViewer disabled page navigation accessibility", () => { + beforeEach(() => { + vi.mocked(loadScorePdf).mockReset(); + vi.mocked(loadScorePdf).mockReturnValue({ + promise: Promise.resolve(createFakeDocument()), + destroy: vi.fn(() => Promise.resolve()) + } as unknown as PDFDocumentLoadingTask); + }); + + it("associates the disabled boundary explanation with the unavailable page button", async () => { + render(); + + expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); + const previousButton = screen.getByRole("button", { name: "Previous page" }); + const nextButton = screen.getByRole("button", { name: "Next page" }); + const previousDescriptionId = previousButton.getAttribute("aria-describedby"); + + expect(previousDescriptionId).toBeTruthy(); + expect(document.getElementById(previousDescriptionId!)).toHaveTextContent( + "Previous page (Unavailable)" + ); + expect(nextButton).not.toHaveAttribute("aria-describedby"); + + fireEvent.click(nextButton); + fireEvent.click(nextButton); + expect(await screen.findByText("Page 3 of 3")).toBeInTheDocument(); + + const nextDescriptionId = nextButton.getAttribute("aria-describedby"); + expect(nextDescriptionId).toBeTruthy(); + expect(document.getElementById(nextDescriptionId!)).toHaveTextContent( + "Next page (Unavailable)" + ); + expect(previousButton).not.toHaveAttribute("aria-describedby"); + }); +}); diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx index 3ac2dd605..bd4761639 100644 --- a/apps/desktop/src/features/score/ScoreViewer.test.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, createEvent, 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"; @@ -120,8 +120,15 @@ 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(); + const prevBtn = screen.getByRole("button", { name: "Previous page" }); + const nextBtn = screen.getByRole("button", { name: "Next page" }); + expect(prevBtn).toHaveAttribute("aria-disabled", "true"); + expect(prevBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + expect(nextBtn).toHaveAttribute("aria-disabled", "false"); + + const clickEvent = createEvent.click(prevBtn); + fireEvent(prevBtn, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); }); it("shows the file name when provided", async () => { @@ -174,14 +181,20 @@ describe("ScoreViewer", () => { expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); 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"); + expect(previousButton).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); 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"); + expect(nextButton).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + + const clickEvent = createEvent.click(nextButton); + fireEvent(nextButton, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); await waitFor(() => { expect(doc.getPage).toHaveBeenCalledWith(3); diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx index 82692469e..db50ef37d 100644 --- a/apps/desktop/src/features/score/ScoreViewer.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist"; import { AlertCircle, @@ -47,6 +47,8 @@ const MAX_ZOOM = 4; */ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const previousDisabledDescriptionId = useId(); + const nextDisabledDescriptionId = useId(); const [status, setStatus] = useState("LOADING"); const [errorMessage, setErrorMessage] = useState(null); const [pdfDocument, setPdfDocument] = useState(null); @@ -258,6 +260,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps size="icon-lg" className="size-12" aria-label={t("scoreViewerZoomOut")} + title={t("scoreViewerZoomOut")} onClick={zoomOut} >