Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
915db47
fix(a11y): keep unavailable score controls discoverable
seonghobae Aug 14, 2026
f34402a
fix(a11y): explain unavailable pagination states
seonghobae Aug 14, 2026
123f8d7
test(score): require disabled remove recovery hint
seonghobae Aug 16, 2026
df0809e
test(score): align disabled remove recovery hint
seonghobae Aug 16, 2026
fb2c9d8
fix(score): explain unavailable remove action
seonghobae Aug 16, 2026
98abea5
test(score): fold recovery hint regression into component suite
seonghobae Aug 16, 2026
b88a1e0
fix(a11y): verify aria-disabled tests correctly checking title logic
seonghobae Aug 16, 2026
1ba37d7
test(a11y): restore localized disabled-action oracle
seonghobae Aug 16, 2026
8ea7ae2
fix(a11y): verify aria-disabled tests correctly checking title logic
seonghobae Aug 16, 2026
81cc280
test(a11y): restore localized disabled-action oracle
seonghobae Aug 16, 2026
9bd7fdd
test(score): require disabled page navigation descriptions
seonghobae Aug 18, 2026
a00c32c
fix(score): describe unavailable page navigation
seonghobae Aug 18, 2026
0b9554a
docs(a11y): record disabled score navigation contract
seonghobae Aug 18, 2026
84f45fb
test(a11y): require enabled score action tooltips
seonghobae Aug 18, 2026
e7f6d01
fix(a11y): preserve enabled score action tooltips
seonghobae Aug 18, 2026
a3215b5
fix(a11y): verify aria-disabled tests correctly checking title logic
seonghobae Aug 18, 2026
5ef9e09
test(a11y): restore described disabled score navigation
seonghobae Aug 18, 2026
4d45755
fix(a11y): restore described score page boundaries
seonghobae Aug 18, 2026
09b92d1
test(a11y): retain enabled score open tooltip
seonghobae Aug 18, 2026
ba0e364
fix(a11y): restore enabled score open tooltip
seonghobae Aug 18, 2026
65cd30d
fix(a11y): verify aria-disabled tests correctly checking title logic
seonghobae Aug 18, 2026
a3728f0
test(a11y): restore disabled pagination description regression
seonghobae Aug 19, 2026
69236f4
fix(a11y): restore disabled pagination descriptions
seonghobae Aug 19, 2026
c958aff
docs(a11y): restore disabled navigation rationale
seonghobae Aug 19, 2026
8388b9c
test(a11y): strengthen unavailable score action oracle
seonghobae Aug 19, 2026
4319fe9
test(score): redact untrusted bridge error details
seonghobae Aug 21, 2026
0ea4a70
fix(a11y): prevent score view error details from leaking paths or sec…
seonghobae Aug 21, 2026
0ce9f36
test(a11y): cover unavailable add-score action
seonghobae Aug 22, 2026
6076f22
fix(a11y): keep unavailable add-score action focusable
seonghobae Aug 22, 2026
1cb9109
test(a11y): verify focusable guarded attach action
seonghobae Aug 22, 2026
5d42307
docs(a11y): document focusable score action boundary
seonghobae Aug 22, 2026
5ea5663
docs(changelog): record focusable add-score guard
seonghobae Aug 22, 2026
d69a3b4
test(score): expose disabled page reasons to focus and hover
seonghobae Sep 1, 2026
66f1198
fix(score): show unavailable page reasons on focus and hover
seonghobae Sep 1, 2026
4657b30
docs(score): document focus-visible unavailable page help
seonghobae Sep 1, 2026
7fd0fef
test(score): require reduced-motion tooltip transitions
seonghobae Sep 1, 2026
96f53ec
fix(score): honor reduced motion for unavailable page help
seonghobae Sep 1, 2026
bcd7d4d
merge(develop): refresh Score accessibility base
seonghobae Sep 3, 2026
e6ce833
chore(stack): refresh Score accessibility protected base
seonghobae Sep 4, 2026
5497355
chore(score): adopt protected workflow consolidation
seonghobae Sep 4, 2026
b00d29f
fix(a11y): prevent score view error details from leaking paths or sec…
seonghobae Sep 4, 2026
3dc9947
chore(score): remove purpose-complete patch helper
seonghobae Sep 5, 2026
e5db6f8
chore(score): remove temporary source reapply helper
seonghobae Sep 5, 2026
7ca242e
fix(a11y): prevent score view error details from leaking paths or sec…
seonghobae Sep 5, 2026
c6407ce
fix(a11y): prevent score view error details from leaking paths or sec…
seonghobae Sep 5, 2026
ae086b3
fix(a11y): prevent score view error details from leaking paths or sec…
seonghobae Sep 5, 2026
8375800
fix(a11y): explain score pagination boundaries
seonghobae Sep 6, 2026
f86b0f5
fix(a11y): localize score pagination boundary reasons
seonghobae Sep 6, 2026
736ce45
test(a11y): require score pagination boundary reasons
seonghobae Sep 6, 2026
add2d9f
chore(a11y): drop unrelated supply-chain formatting drift
seonghobae Sep 6, 2026
8e0012d
test(a11y): require hoverable score boundary tooltips
seonghobae Sep 6, 2026
78a5e60
fix(a11y): keep score boundary tooltips hoverable
seonghobae Sep 6, 2026
b13c385
test(a11y): require Escape-dismissable score boundary reasons
seonghobae Sep 6, 2026
1a92b71
fix(a11y): dismiss score boundary tooltips with Escape
seonghobae Sep 6, 2026
89b12be
docs(a11y): trace hover and Escape score tooltip contract
seonghobae Sep 6, 2026
a150059
test(a11y): require continuous hover path for score tooltips
seonghobae Sep 6, 2026
a941cf1
fix(a11y): preserve continuous score tooltip hover path
seonghobae Sep 6, 2026
b2974a4
docs(a11y): record continuous score tooltip hover geometry
seonghobae Sep 6, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

- Consolidated Bandit, dependency audits, supplemental secret checks, and Trivy into one trusted-branch security backstop, delegated CodeQL to GitHub default setup, and removed duplicate local PR security and release-preflight runs.
- Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata.
- 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.

### Fixed

Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="score-viewer" />
}));

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(<ScoreView song={song} projectId={null} onSongUpdate={vi.fn()} />);

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();
});
});
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="score-viewer">Mock Viewer</div>
}));

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(<ScoreView song={song} projectId="project-1-2" onSongUpdate={vi.fn()} />);

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"
);
});
});
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="score-viewer" />
}));

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(<ScoreView song={makeSong()} projectId="project-1-2" onSongUpdate={vi.fn()} />);
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");
});
98 changes: 92 additions & 6 deletions apps/desktop/src/features/score/ScoreView.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -90,17 +90,103 @@ describe("ScoreView", () => {
expect(mockInvoke).not.toHaveBeenCalled();
});

it("disables score storage actions when no project workspace is active", () => {
it("ignores clicks while attaching", async () => {
const song = makeSong([]);
render(<ScoreView song={song} projectId="123" onSongUpdate={vi.fn()} />);
const addBtn = screen.getByRole("button", { name: "Add score" });

// In our mock, attachScorePdf returns a promise.
// We can mock it to not resolve immediately, simulating a pending attach
let resolveAttach: (val: unknown) => void;
mockInvoke.mockReturnValueOnce(new Promise((resolve) => {
resolveAttach = resolve;
}));

await act(async () => {
fireEvent.click(addBtn);
});

// Second click should hit `!isAttaching` branch and do nothing
await act(async () => {
fireEvent.click(addBtn);
});

expect(mockInvoke).toHaveBeenCalledTimes(1);

// Resolve the promise to cleanup
await act(async () => {
resolveAttach({ id: "new-score", fileName: "new.pdf" });
});
});

it("keeps unavailable score storage actions focusable when no project workspace is active", () => {
const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]);
render(<ScoreView song={song} projectId={null} onSongUpdate={vi.fn()} />);

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(<ScoreView song={makeSong()} projectId="project-1-2" onSongUpdate={onSongUpdate} />);

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 () => {
Expand Down
Loading
Loading