diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..01015e9ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..3dc161966 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..da3e775a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..7e06a5b0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/desktop/src/App.recovery.test.tsx b/apps/desktop/src/App.recovery.test.tsx new file mode 100644 index 000000000..849e02b0b --- /dev/null +++ b/apps/desktop/src/App.recovery.test.tsx @@ -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(); + + 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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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); + }); +}); diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..478d7651c 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -280,6 +280,28 @@ describe("App", () => { expect(sourceControls).toHaveTextContent(/Import YouTube/i); }); + it("lets the empty workspace card start a local-audio pick", async () => { + mockLocalAudioSelectionResult = { + ok: true, + bootstrap: { + projectId: "proj-empty-next", + source: { + sourceKind: "local-audio", + sourceMode: "reference", + fileName: "rehearsal-take.wav", + format: "wav" + } + } + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" })); + + await waitFor(() => { + expect(screen.getByTitle("rehearsal-take.wav")).toBeTruthy(); + }); + }); + it("caps the YouTube URL input before import-path validation", () => { render(); @@ -400,7 +422,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); @@ -425,7 +447,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy(); @@ -445,7 +467,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy(); @@ -458,7 +480,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy(); @@ -478,7 +500,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); }); @@ -518,7 +540,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); }); @@ -552,7 +574,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -585,7 +607,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -622,7 +644,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -659,7 +681,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -714,7 +736,7 @@ describe("App", () => { const { unmount } = render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -749,7 +771,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -774,7 +796,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -809,7 +831,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -838,7 +860,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); }); @@ -912,7 +934,7 @@ describe("App", () => { try { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -920,7 +942,7 @@ describe("App", () => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/next-song\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); @@ -956,7 +978,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -983,7 +1005,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -1006,7 +1028,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -1029,7 +1051,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -1046,7 +1068,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -1066,7 +1088,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -1083,7 +1105,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); @@ -1130,7 +1152,7 @@ describe("App", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); await waitFor(() => { expect(screen.getByRole("alert")).toHaveTextContent(/choose a wav, mp3, flac, or m4a file/i); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..0d5fcf61b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -46,7 +46,12 @@ import { import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; -import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; +import { + EmptyState, + ErrorState, + LoadingState, + type WorkspaceFailureKind +} from "./features/workspace/WorkspaceStates"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; @@ -145,6 +150,23 @@ function safeErrorDetail(error: unknown, fallback: string): string { : redacted; } +/** Map validated engine status to a stable buyer-visible failure category. */ +function workspaceFailureKind(status: AnalysisJobStatus | null): WorkspaceFailureKind { + if (status?.state !== "failed") { + return "generic"; + } + if (status.progressStage === "decode") { + return "decode"; + } + if (status.progressStage === "separate") { + return "separate"; + } + if (status.error?.code === "engine_unavailable") { + return "engine"; + } + return "generic"; +} + /** Documented. */ function BandScopeMark({ ariaLabel }: { ariaLabel: string }) { return ( @@ -219,7 +241,7 @@ function ConfidenceMetric({ song, t }: { song: RehearsalSong | null; t: ReturnTy if (!lowestConfidence || confidenceOrder[section.confidence.level] < confidenceOrder[lowestConfidence]) { lowestConfidence = section.confidence.level; if (lowestConfidence === "low") { - break; // Short-circuit early since "low" is the lowest possible confidence bound + break; } } } @@ -464,6 +486,21 @@ export function App() { } }; + /** Documented. */ + const handleFocusYoutubeUrl = () => { + youtubeInputRef.current?.focus(); + }; + + /** Documented. */ + const handleStartOverAfterError = () => { + setJobError(null); + setJobStatus(null); + setSelectedBootstrap(null); + setActiveAnalysisBootstrap(null); + setSelectionError(null); + setSelectionErrorSource(null); + }; + /** Documented. */ const handleClearYoutubeUrl = () => { youtubeInputRef.current?.focus(); @@ -506,7 +543,16 @@ export function App() { /** Documented. */ const renderWorkspaceState = () => { if (jobError) { - return ; + return ( + { + void handleChooseLocalAudio(); + }} + onStartOver={handleStartOverAfterError} + /> + ); } if (analysisInFlight || isStarting) { return ; @@ -514,7 +560,14 @@ export function App() { if (jobResult) { return ; } - return ; + return ( + { + void handleChooseLocalAudio(); + }} + onFocusYoutube={handleFocusYoutubeUrl} + /> + ); }; const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx new file mode 100644 index 000000000..1919d5065 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmptyState, ErrorState, LoadingState } from "./WorkspaceStates"; + +const originalLanguage = window.navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: language + }); +} + +describe("WorkspaceStates", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("offers choose-audio and YouTube next actions on the empty card", () => { + const onChooseLocalAudio = vi.fn(); + const onFocusYoutube = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" })); + fireEvent.click(screen.getByRole("button", { name: "Paste a YouTube URL" })); + + expect(onChooseLocalAudio).toHaveBeenCalledTimes(1); + expect(onFocusYoutube).toHaveBeenCalledTimes(1); + }); + + it("keeps empty-state actions inert when no handlers are wired", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" })); + fireEvent.click(screen.getByRole("button", { name: "Paste a YouTube URL" })); + expect(screen.getByRole("heading", { name: "Ready to Analyze" })).toBeTruthy(); + }); + + it("clears failed-analysis state before choosing another file", () => { + const onChooseLocalAudio = vi.fn(); + const onStartOver = vi.fn(); + render( + + ); + + expect(screen.getByText("Decoder rejected the file")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Choose another file" })); + + expect(onStartOver).toHaveBeenCalledTimes(1); + expect(onChooseLocalAudio).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Start over" })); + expect(onStartOver).toHaveBeenCalledTimes(2); + }); + + it("renders a failed analysis card without detail copy", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Choose another file" })); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.queryByText("Decoder rejected the file")).toBeNull(); + }); + + it("localizes empty, loading, and error next-action copy", () => { + setNavigatorLanguage("ko-KR"); + render( + <> + + + + + ); + + expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "로컬 오디오 파일 선택" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "YouTube 주소 붙여넣기" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "다른 파일 선택" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "처음부터 다시" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..03311f78f 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,9 +1,16 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Music, AlertCircle } from "lucide-react"; +import { Loader2, Music, AlertCircle, Upload, Link2 } from "lucide-react"; + +/** Next-action handlers for the empty rehearsal workspace. */ +export interface EmptyStateProps { + onChooseLocalAudio?: () => void; + onFocusYoutube?: () => void; +} /** Documented. */ -export function EmptyState() { +export function EmptyState({ onChooseLocalAudio, onFocusYoutube }: EmptyStateProps = {}) { const t = createTranslator(detectPreferredLocale()); return ( @@ -13,6 +20,27 @@ export function EmptyState() {

{t("workspaceReadyToAnalyzeTitle")}

{t("workspaceEmptyState")}

+
+ + +
); @@ -38,17 +66,84 @@ export function LoadingState() { ); } -/** Documented. */ -export function ErrorState({ error }: { error?: string }) { +/** Stable presentation categories derived only from validated analysis status. */ +export type WorkspaceFailureKind = "generic" | "engine" | "decode" | "separate"; + +/** Next-action handlers after a failed analysis. */ +export interface ErrorStateProps { + error?: string; + kind?: WorkspaceFailureKind; + onChooseLocalAudio?: () => void; + onStartOver?: () => void; +} + +/** Resolve safe failure copy without interpreting provider or filesystem error text. */ +function failureCopy( + kind: WorkspaceFailureKind, + t: ReturnType +): { title: string; guidance?: string } { + switch (kind) { + case "engine": + return { + title: t("workspaceErrorEngineTitle"), + guidance: t("workspaceErrorEngineGuidance") + }; + case "decode": + return { + title: t("workspaceErrorDecodeTitle"), + guidance: t("workspaceErrorDecodeGuidance") + }; + case "separate": + return { + title: t("workspaceErrorSeparateTitle"), + guidance: t("workspaceErrorSeparateGuidance") + }; + case "generic": + return { title: t("workspaceErrorState") }; + } +} + +/** Render a recoverable, local-first analysis failure state. */ +export function ErrorState({ + error, + kind = "generic", + onChooseLocalAudio, + onStartOver +}: ErrorStateProps) { const t = createTranslator(detectPreferredLocale()); + const copy = failureCopy(kind, t); return (
-

{t("workspaceErrorState")}

+

{copy.title}

+ {copy.guidance &&

{copy.guidance}

} {error &&

{error}

} +
+ + +
); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..f7511b79c 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -30,8 +30,18 @@ "workspaceReadyToAnalyzeTitle": "Ready to Analyze", "workspaceAnalyzingAudioTitle": "Analyzing Audio", "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.", + "workspaceEmptyChooseAudio": "Choose a local audio file", + "workspaceEmptyPasteYoutube": "Paste a YouTube URL", "workspaceLoadingState": "Analyzing the song's form and instrument roles...", "workspaceErrorState": "An error occurred during analysis. Please try again.", + "workspaceErrorEngineTitle": "Analysis engine unavailable", + "workspaceErrorEngineGuidance": "Start over and try again. BandScope keeps your selected audio local and will not fabricate an analysis result.", + "workspaceErrorDecodeTitle": "Couldn’t decode this audio", + "workspaceErrorDecodeGuidance": "Choose another WAV, MP3, FLAC, or M4A file, or start over.", + "workspaceErrorSeparateTitle": "Couldn’t separate this track", + "workspaceErrorSeparateGuidance": "Choose another file or start over. BandScope will not substitute the original mix for missing stems.", + "workspaceErrorChooseAnother": "Choose another file", + "workspaceErrorStartOver": "Start over", "workspaceRehearsalMapLabel": "Tonight's rehearsal map", "workspaceRehearsalFallback": "Rehearsal Workspace", "workspaceTempoLabel": "Tempo", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..9fdd63a29 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -30,8 +30,18 @@ "workspaceReadyToAnalyzeTitle": "분석 준비 완료", "workspaceAnalyzingAudioTitle": "오디오 분석 중", "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", + "workspaceEmptyChooseAudio": "로컬 오디오 파일 선택", + "workspaceEmptyPasteYoutube": "YouTube 주소 붙여넣기", "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", + "workspaceErrorEngineTitle": "분석 엔진을 사용할 수 없습니다", + "workspaceErrorEngineGuidance": "처음부터 다시 시작해 재시도하세요. 선택한 오디오는 로컬에 유지되며 BandScope는 분석 결과를 임의로 만들어내지 않습니다.", + "workspaceErrorDecodeTitle": "이 오디오를 디코딩할 수 없습니다", + "workspaceErrorDecodeGuidance": "다른 WAV, MP3, FLAC 또는 M4A 파일을 선택하거나 처음부터 다시 시작하세요.", + "workspaceErrorSeparateTitle": "이 트랙을 스템으로 분리할 수 없습니다", + "workspaceErrorSeparateGuidance": "다른 파일을 선택하거나 처음부터 다시 시작하세요. BandScope는 분리되지 않은 원본 믹스를 스템인 것처럼 대체하지 않습니다.", + "workspaceErrorChooseAnother": "다른 파일 선택", + "workspaceErrorStartOver": "처음부터 다시", "workspaceRehearsalMapLabel": "오늘의 합주 지도", "workspaceRehearsalFallback": "합주 작업 공간", "workspaceTempoLabel": "템포",