diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..99dec1ec9 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- 합주 워크스페이스의 읽기 전용 코드 수정 버튼이 키보드 포커스를 유지하고 `aria-disabled="true"`를 노출하며, 한·영 접근성 이름과 설명으로 편집 가능한 곡을 열어야 한다는 복구 방법을 안내하도록 개선했습니다. `aria-describedby`는 분석 데이터의 section/role 식별자 대신 렌더러가 소유하는 고유하고 공백 없는 ID를 사용하여 임의 문자열 식별자에서도 참조 무결성을 유지하고, 포인터 `title`에도 같은 복구 문구를 제공하며 클릭은 계속 실패-폐쇄 방식으로 차단됩니다. + ## [0.1.3] - 2026-04-29 ### Fixed diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..f4604db57 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -30,6 +30,7 @@ describe("SectionRoadmap", () => { expect(screen.getAllByText("코드").length).toBeGreaterThan(0); expect(screen.getAllByText("큐").length).toBeGreaterThan(0); expect(screen.getAllByTitle("우선순위: high").length).toBeGreaterThan(0); + expect(screen.getAllByTitle("코드를 바꾸려면 편집 가능한 곡을 여세요").length).toBeGreaterThan(0); expect(screen.getByText("사용자")).toBeTruthy(); }); @@ -60,4 +61,49 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + it("applies aria-disabled when onSongUpdate is missing, describes how to recover, and prevents click", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + // Intentionally omit onSongUpdate to simulate readonly mode + render(); + + const button = screen.getByRole("button", { name: "Edit chord for Bass Guitar in verse, current C#m7" }); + expect(button).toHaveAttribute("aria-disabled", "true"); + expect(button).toHaveAttribute("title", "Open an editable song to change this chord"); + + const descriptionId = button.getAttribute("aria-describedby"); + expect(descriptionId).toBeTruthy(); + expect(document.getElementById(descriptionId ?? "")?.textContent).toBe( + "Open an editable song to change this chord" + ); + + const promptSpy = vi.spyOn(window, "prompt"); + fireEvent.click(button); + + // Verify click handler early returns and prevents prompt + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it("uses unique whitespace-free description IDs for arbitrary section and role IDs", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0].id = "verse 1"; + song.sections[0].roles[0].id = "bass guitar"; + + render(); + + const buttons = screen.getAllByRole("button", { name: /^Edit chord for / }); + const descriptionIds = buttons.map((button) => button.getAttribute("aria-describedby")); + expect(descriptionIds.every((id) => typeof id === "string" && id.length > 0)).toBe(true); + + const ids = descriptionIds.filter((id): id is string => id !== null); + expect(ids.every((id) => !/\s/.test(id))).toBe(true); + expect(new Set(ids).size).toBe(buttons.length); + for (const id of ids) { + expect(document.getElementById(id)?.textContent).toBe( + "Open an editable song to change this chord" + ); + } + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..5ee7cd661 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -87,6 +87,11 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma return