diff --git a/CHANGELOG.md b/CHANGELOG.md index 071a641d..5624c7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,3 +53,11 @@ - Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports - Issue #30: Added policy-constrained YouTube import with local fallback - Issue #26: Finalized roadmap and prepared application for initial release + +## [0.1.4] - 2026-05-15 + +### 추가됨 (Added) + +- `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`). diff --git a/apps/desktop/src/features/chords/index.test.tsx b/apps/desktop/src/features/chords/index.test.tsx new file mode 100644 index 00000000..18637d1d --- /dev/null +++ b/apps/desktop/src/features/chords/index.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { ChordsFeature } from "./index"; +import type { RehearsalSong } from "@bandscope/shared-types"; + +const mockSong: RehearsalSong = { + id: "song-1", + title: "Test Song", + exportSummary: { format: "cue-sheet", headline: "Test Headline", focusSections: [] }, + sections: [ + { + id: "sec-1", + label: "verse", + groove: "test groove", + timeRange: { start: 0, end: 10 }, + confidence: { level: "high", reason: "test" }, + partGraph: [], + roles: [ + { + id: "role-1", + name: "Test Role", + roleType: "instrument", + harmony: { chord: "Cmaj7", functionLabel: "Tonic", source: "model" }, + cue: { value: "test cue", anchor: "count", confidence: { level: "high", reason: "test" } }, + range: { lowestNote: "C4", highestNote: "C5" }, + confidence: { level: "high", reason: "test" }, + rehearsalPriority: "high", + simplification: "none", + setupNote: "none", + manualOverrides: [], + overlapWarnings: [], + }, + { + id: "role-2", + name: "Transposed Role", + roleType: "instrument", + harmony: { chord: "Dmaj7", functionLabel: "Subdominant", source: "user" }, + cue: { value: "test cue", anchor: "count", confidence: { level: "high", reason: "test" } }, + range: { lowestNote: "D4", highestNote: "D5" }, + confidence: { level: "high", reason: "test" }, + rehearsalPriority: "high", + simplification: "none", + setupNote: "none", + manualOverrides: [], + overlapWarnings: [], + transpositionPlan: "Capo 2nd fret", + }, + ], + }, + ], +}; + +describe("ChordsFeature", () => { + it("renders empty state without a song", () => { + render(); + expect(screen.getByText("No song loaded. Start an analysis to see chord data.")).toBeInTheDocument(); + }); + + it("renders chord data for roles", () => { + render(); + expect(screen.getByText("verse")).toBeInTheDocument(); + expect(screen.getByText("Cmaj7")).toBeInTheDocument(); + expect(screen.getByText("Test Role")).toBeInTheDocument(); + expect(screen.getByText("Tonic")).toBeInTheDocument(); + }); + + it("renders user badge for user-sourced harmony", () => { + render(); + expect(screen.getByText("(User)")).toBeInTheDocument(); + }); + + it("renders transpositionPlan when provided", () => { + render(); + expect(screen.getByText(/Capo 2nd fret/)).toBeInTheDocument(); + expect(screen.getByText(/Transpose:/)).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/features/chords/index.tsx b/apps/desktop/src/features/chords/index.tsx index e9d0c016..c410304b 100644 --- a/apps/desktop/src/features/chords/index.tsx +++ b/apps/desktop/src/features/chords/index.tsx @@ -14,15 +14,16 @@ export function ChordsFeature(props: { title: string; song?: RehearsalSong | nul } // Collect unique chords across all sections and roles - const chordsBySectionLabel = new Map(); + const chordsBySectionLabel = new Map(); for (const section of song.sections) { - const entries: { chord: string; functionLabel: string; source: string; roleName: string }[] = []; + const entries: { chord: string; functionLabel: string; source: string; roleName: string; transpositionPlan?: string }[] = []; for (const role of section.roles) { entries.push({ chord: role.harmony.chord, functionLabel: role.harmony.functionLabel, source: role.harmony.source, roleName: role.name, + transpositionPlan: role.transpositionPlan, }); } chordsBySectionLabel.set(section.label, entries); @@ -69,6 +70,11 @@ export function ChordsFeature(props: { title: string; song?: RehearsalSong | nul
{role.name}
+ {role.transpositionPlan && ( +
+ Transpose: {role.transpositionPlan} +
+ )} ))} diff --git a/apps/desktop/src/features/ranges/index.test.tsx b/apps/desktop/src/features/ranges/index.test.tsx new file mode 100644 index 00000000..585c1f4a --- /dev/null +++ b/apps/desktop/src/features/ranges/index.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { RangesFeature } from "./index"; +import type { RehearsalSong } from "@bandscope/shared-types"; + +const mockSong: RehearsalSong = { + id: "song-1", + title: "Test Song", + exportSummary: { format: "cue-sheet", headline: "Test Headline", focusSections: [] }, + sections: [ + { + id: "sec-1", + label: "chorus", + groove: "test groove", + timeRange: { start: 0, end: 10 }, + confidence: { level: "high", reason: "test" }, + partGraph: [], + roles: [ + { + id: "role-1", + name: "Test Role 1", + roleType: "instrument", + harmony: { chord: "Cmaj7", functionLabel: "Tonic", source: "model" }, + cue: { value: "test cue", anchor: "count", confidence: { level: "high", reason: "test" } }, + range: { lowestNote: "C4", highestNote: "C5" }, + confidence: { level: "high", reason: "test" }, + rehearsalPriority: "high", + simplification: "none", + setupNote: "none", + manualOverrides: [], + overlapWarnings: ["Clashing notes with Role 2"], + transcription: [ + { pitch: "C4", onset: 0, offset: 1, velocity: 100 }, + { pitch: "E4", onset: 1, offset: 2, velocity: 100 }, + ], + }, + { + id: "role-2", + name: "Test Role 2", + roleType: "instrument", + harmony: { chord: "Cmaj7", functionLabel: "Tonic", source: "model" }, + cue: { value: "test cue", anchor: "count", confidence: { level: "high", reason: "test" } }, + range: { lowestNote: "G4", highestNote: "G5" }, + confidence: { level: "high", reason: "test" }, + rehearsalPriority: "high", + simplification: "none", + setupNote: "none", + manualOverrides: [], + overlapWarnings: [], + // No transcription + }, + ], + }, + ], +}; + +describe("RangesFeature", () => { + it("renders empty state without a song", () => { + render(); + expect(screen.getByText("No song loaded. Start an analysis to see range data.")).toBeInTheDocument(); + }); + + it("renders role names and ranges", () => { + render(); + expect(screen.getByText("Test Role 1")).toBeInTheDocument(); + expect(screen.getByText("🎵 C4 — C5")).toBeInTheDocument(); + expect(screen.getByText("Test Role 2")).toBeInTheDocument(); + expect(screen.getByText("🎵 G4 — G5")).toBeInTheDocument(); + }); + + it("renders overlap warnings", () => { + render(); + expect(screen.getByText("⚠️ Clashing notes with Role 2")).toBeInTheDocument(); + }); + + it("renders transcription count when transcription exists", () => { + render(); + expect(screen.getByText(/Transcription available:/)).toBeInTheDocument(); + expect(screen.getByText(/2 notes/)).toBeInTheDocument(); + }); + + it("does not render transcription block when transcription is undefined", () => { + render(); + // There is exactly one transcription block, from Role 1 + const elements = screen.getAllByText(/Transcription available:/); + expect(elements.length).toBe(1); + }); +}); diff --git a/apps/desktop/src/features/ranges/index.tsx b/apps/desktop/src/features/ranges/index.tsx index 4dedd784..1cbb020b 100644 --- a/apps/desktop/src/features/ranges/index.tsx +++ b/apps/desktop/src/features/ranges/index.tsx @@ -56,6 +56,11 @@ export function RangesFeature(props: { title: string; song?: RehearsalSong | nul ))} )} + {role.transcription && role.transcription.length > 0 && ( +
+ Transcription available: {role.transcription.length} notes +
+ )} ))}