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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
77 changes: 77 additions & 0 deletions apps/desktop/src/features/chords/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ChordsFeature title="Chords" />);
expect(screen.getByText("No song loaded. Start an analysis to see chord data.")).toBeInTheDocument();
});

it("renders chord data for roles", () => {
render(<ChordsFeature title="Chords" song={mockSong} />);
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(<ChordsFeature title="Chords" song={mockSong} />);
expect(screen.getByText("(User)")).toBeInTheDocument();
});

it("renders transpositionPlan when provided", () => {
render(<ChordsFeature title="Chords" song={mockSong} />);
expect(screen.getByText(/Capo 2nd fret/)).toBeInTheDocument();
expect(screen.getByText(/Transpose:/)).toBeInTheDocument();
});
});
10 changes: 8 additions & 2 deletions apps/desktop/src/features/chords/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { chord: string; functionLabel: string; source: string; roleName: string }[]>();
const chordsBySectionLabel = new Map<string, { chord: string; functionLabel: string; source: string; roleName: string; transpositionPlan?: string }[]>();
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);
Expand Down Expand Up @@ -69,6 +70,11 @@ export function ChordsFeature(props: { title: string; song?: RehearsalSong | nul
<div style={{ fontSize: "0.8em", color: "#999" }}>
{role.name}
</div>
{role.transpositionPlan && (
<div style={{ marginTop: "6px", fontSize: "0.8em", color: "#d46b08", backgroundColor: "#fff7e6", padding: "4px", borderRadius: "2px" }}>
<strong>Transpose:</strong> {role.transpositionPlan}
</div>
)}
</div>
))}
</div>
Expand Down
88 changes: 88 additions & 0 deletions apps/desktop/src/features/ranges/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RangesFeature title="Ranges" />);
expect(screen.getByText("No song loaded. Start an analysis to see range data.")).toBeInTheDocument();
});

it("renders role names and ranges", () => {
render(<RangesFeature title="Ranges" song={mockSong} />);
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(<RangesFeature title="Ranges" song={mockSong} />);
expect(screen.getByText("⚠️ Clashing notes with Role 2")).toBeInTheDocument();
});

it("renders transcription count when transcription exists", () => {
render(<RangesFeature title="Ranges" song={mockSong} />);
expect(screen.getByText(/Transcription available:/)).toBeInTheDocument();
expect(screen.getByText(/2 notes/)).toBeInTheDocument();
});

it("does not render transcription block when transcription is undefined", () => {
render(<RangesFeature title="Ranges" song={mockSong} />);
// There is exactly one transcription block, from Role 1
const elements = screen.getAllByText(/Transcription available:/);
expect(elements.length).toBe(1);
});
});
5 changes: 5 additions & 0 deletions apps/desktop/src/features/ranges/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export function RangesFeature(props: { title: string; song?: RehearsalSong | nul
))}
</div>
)}
{role.transcription && role.transcription.length > 0 && (
<div style={{ marginTop: "8px", fontSize: "0.8em", color: "#08979c", backgroundColor: "#e6fffb", padding: "4px 6px", borderRadius: "4px" }}>
<strong>Transcription available:</strong> {role.transcription.length} notes
</div>
)}
</div>
))}
</div>
Expand Down