Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
- Customer-facing empty and error copy must enable the next action (choose local audio, paste a YouTube URL, choose another file, or start over). Do not leave those states as text-only cards.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Last updated: 2026-03-11

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.
- Empty and failed workspace cards are actionable state cards. They must expose the next rehearsal action instead of describing the gap and stopping.

## Security source

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Empty and failed workspace cards now expose the next rehearsal action: choose a local audio file, paste a YouTube URL, choose another file, or start over.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

`AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win.

Workspace empty and error cards must keep a visible next action (choose local audio, paste a YouTube URL, choose another file, or start over).

Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.

## Common commands
Expand Down
163 changes: 163 additions & 0 deletions apps/desktop/src/App.recovery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";

vi.mock("./features/score/pdfjs", () => ({
configureScorePdfWorker: vi.fn(),
loadScorePdf: vi.fn(() => ({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }),
destroy: vi.fn(() => Promise.resolve())
}))
}));

const mockLoadProject = vi.fn();
const mockSelectLocalAudioSource = vi.fn();
const mockStartAnalysisJob = vi.fn();

vi.mock("./lib/analysis", async (importActual) => {
const actual = await importActual<typeof import("./lib/analysis")>();

return {
...actual,
loadProject: () => mockLoadProject(),
selectLocalAudioSource: () => mockSelectLocalAudioSource(),
startAnalysisJob: (request: unknown) => mockStartAnalysisJob(request)
};
});

describe("App workspace recovery actions", () => {
beforeEach(() => {
mockLoadProject.mockReset();
mockSelectLocalAudioSource.mockReset();
mockStartAnalysisJob.mockReset();
});

it("focuses the existing YouTube field from the empty workspace card", () => {
render(<App />);
const youtubeInput = screen.getByRole("textbox", { name: /YouTube URL/i });

fireEvent.click(screen.getByRole("button", { name: "Paste a YouTube URL" }));

expect(document.activeElement).toBe(youtubeInput);
});

it("returns a failed workspace to the empty state when starting over", async () => {
mockLoadProject.mockRejectedValueOnce(new Error("project load failed"));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Start over" }));

await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(screen.getByRole("heading", { name: "Ready to Analyze" })).toBeTruthy();
});

it("clears a failed analysis source selection when starting over", async () => {
mockSelectLocalAudioSource.mockResolvedValueOnce({
ok: true,
bootstrap: {
projectId: "proj-recovery",
source: {
sourceKind: "local-audio",
sourceMode: "reference",
fileName: "recovery-take.wav",
format: "wav"
}
}
});
mockStartAnalysisJob.mockResolvedValueOnce({
jobId: "job-recovery",
state: "failed",
requestedAt: "2026-08-17T00:00:00.000Z",
updatedAt: "2026-08-17T00:00:01.000Z",
error: {
code: "engine_unavailable",
message: "Analysis failed"
}
});
render(<App />);

fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" }));
await waitFor(() => expect(screen.getByTitle("recovery-take.wav")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Analysis failed"));
expect(screen.getByRole("heading", { name: "Analysis engine unavailable" })).toBeTruthy();
expect(screen.getByTitle("recovery-take.wav")).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: "Start over" }));

await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(screen.queryByTitle("recovery-take.wav")).toBeNull();
expect(screen.getByRole("button", { name: /start analysis/i })).toBeDisabled();
expect(mockStartAnalysisJob).toHaveBeenCalledTimes(1);
});

it.each([
["decode", "Couldn’t decode this audio"],
["separate", "Couldn’t separate this track"]
] as const)("names a %s-stage analysis failure before offering recovery", async (progressStage, expectedTitle) => {
mockSelectLocalAudioSource.mockResolvedValueOnce({
ok: true,
bootstrap: {
projectId: `proj-${progressStage}`,
source: {
sourceKind: "local-audio",
sourceMode: "reference",
fileName: `${progressStage}-take.wav`,
format: "wav"
}
}
});
mockStartAnalysisJob.mockResolvedValueOnce({
jobId: `job-${progressStage}`,
state: "failed",
requestedAt: "2026-08-17T00:00:00.000Z",
updatedAt: "2026-08-17T00:00:01.000Z",
progressStage,
error: {
code: "engine_unavailable",
message: `Safe ${progressStage} failure detail`
}
});
render(<App />);

fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" }));
await waitFor(() => expect(screen.getByTitle(`${progressStage}-take.wav`)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));

await waitFor(() => expect(screen.getByRole("heading", { name: expectedTitle })).toBeTruthy());
expect(screen.getByRole("alert")).toHaveTextContent(`Safe ${progressStage} failure detail`);
expect(screen.getByRole("button", { name: "Choose another file" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Start over" })).toBeTruthy();
});

it("clears a failed workspace before accepting another local audio file", async () => {
mockLoadProject.mockRejectedValueOnce(new Error("project load failed"));
mockSelectLocalAudioSource.mockResolvedValueOnce({
ok: true,
bootstrap: {
projectId: "proj-recovery",
source: {
sourceKind: "local-audio",
sourceMode: "reference",
fileName: "recovery-take.wav",
format: "wav"
}
}
});
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Choose another file" }));

await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(screen.getByTitle("recovery-take.wav")).toBeTruthy();
expect(mockSelectLocalAudioSource).toHaveBeenCalledTimes(1);
});
});
Loading
Loading