From e588b35ed0029c3d3d7afddbbc43d7537ddd04cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:15:26 +0000 Subject: [PATCH 01/10] feat(workspace): name the next action after analysis fails After a failed analysis, the workspace offers Try this song again and Choose another song instead of a message-only dead end. Choosing another file that fails keeps the admitted song so retry remains available. Parent buyer gap: #964. Do not mix with #811, #828, or #897. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 + apps/desktop/src/App.test.tsx | 113 ++++++++++++++++++ apps/desktop/src/App.tsx | 34 +++++- .../workspace/WorkspaceStates.test.tsx | 62 ++++++++++ .../features/workspace/WorkspaceStates.tsx | 61 +++++++++- apps/desktop/src/locales/en/common.json | 5 + apps/desktop/src/locales/ko/common.json | 5 + 10 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/features/workspace/WorkspaceStates.test.tsx diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..01a7eb3c8 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. +- After analysis fails, customer-facing copy must enable the next rehearsal action (try this song again or choose another file). Do not leave a failed analysis as a message-only dead end. - Do not reduce the product to a chord analyzer when form, timing, player coordination, 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 3302a6fc3..f92cfb079 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. +- After analysis fails, the workspace error card names try-this-song-again and choose-another-song as the next actions instead of a message-only dead end. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..6dc9e06a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- After analysis fails, the workspace names Try this song again and Choose another song as the next rehearsal actions. - 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 82c2c704a..9d8f75137 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. +After analysis fails, the workspace must name Try this song again or Choose another song rather than leaving a message-only error. + 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.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..f74ea9219 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1076,6 +1076,119 @@ describe("App", () => { }); }); + it("retries the admitted song from the analysis failure card", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(failedJobStatus("job-5", "Analysis queue is full. Please wait for a running job to finish.")) + .mockResolvedValueOnce(succeededResult()); + + render(); + + 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 })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /try this song again/i })).toBeTruthy(); + }); + expect(screen.getByText(/this song is still on this device/i)).toBeTruthy(); + expect(screen.getByText(/analysis queue is full/i)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /try this song again/i })); + + await waitFor(() => { + expect(screen.getByText(/Section Roadmap/i)).toBeTruthy(); + }); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("lets the player choose another song after analysis fails", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(failedJobStatus("job-7", "Analysis engine is unavailable.")) + .mockResolvedValueOnce(bootstrapResponse({ + projectId: "project-2", + source: { + sourcePath: "/Users/test/Music/next-song.wav", + fileName: "next-song.wav", + extension: "wav", + fileSizeBytes: 2048000 + } + })); + + render(); + + 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 })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: /choose another song/i })); + + await waitFor(() => { + expect(screen.getByText(/next-song\.wav/i)).toBeTruthy(); + }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByText(/choose an audio file to prepare for your rehearsal/i)).toBeTruthy(); + }); + + it("keeps the admitted song when choosing another file fails after analysis failure", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(failedJobStatus("job-8", "Analysis engine is unavailable.")); + + render(); + + 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 })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy(); + }); + + mockLocalAudioSelectionResult = { + ok: false, + error: { code: "invalid_request", message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." } + }; + fireEvent.click(screen.getByRole("button", { name: /choose another song/i })); + + await waitFor(() => { + expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy(); + }); + expect(screen.getAllByRole("alert").some((alert) => /analysis engine is unavailable/i.test(alert.textContent ?? ""))).toBe(true); + expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /try this song again/i }).hasAttribute("disabled")).toBe(false); + }); + + it("does not offer analysis retry after a project save failure", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(succeededResult()); + mockSaveProject.mockRejectedValueOnce(new Error("Disk full")); + + render(); + + 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 })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save project/i })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(screen.getByText(/failed to save project: disk full/i)).toBeTruthy(); + }); + expect(screen.queryByRole("button", { name: /try this song again/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /choose another song/i })).toBeNull(); + }); + it("renders the result immediately when start returns a succeeded job", async () => { tauriInvoke .mockResolvedValueOnce(bootstrapResponse()) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..44f45fcac 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -59,6 +59,7 @@ const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi; type RehearsalView = "workspace" | "score"; +type WorkspaceJobErrorKind = "analysis" | "project"; const NAV_ITEMS = [ { labelKey: "navWorkspace", icon: Home, view: "workspace" }, @@ -255,6 +256,7 @@ export function App() { const [jobResult, setJobResult] = useState(null); const [jobResultBootstrap, setJobResultBootstrap] = useState(null); const [jobError, setJobError] = useState(null); + const [jobErrorKind, setJobErrorKind] = useState(null); const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined); const [isStarting, setIsStarting] = useState(false); const [selectedBootstrap, setSelectedBootstrap] = useState(null); @@ -289,10 +291,12 @@ export function App() { setJobResultBootstrap(activeAnalysisBootstrap); setActiveAnalysisBootstrap(null); setJobError(null); + setJobErrorKind(null); } if (nextStatus.state === "failed") { setActiveAnalysisBootstrap(null); setJobError(safeErrorDetail(nextStatus.error?.message, t("analysisCouldNotStart"))); + setJobErrorKind("analysis"); } }, [activeAnalysisBootstrap, t]); @@ -362,6 +366,7 @@ export function App() { } const fallbackMessage = t("analysisCouldNotStart"); setJobError(fallbackMessage); + setJobErrorKind("analysis"); setJobStatus({ ...jobStatus, state: "failed", @@ -389,6 +394,7 @@ export function App() { const handleStartAnalysis = async () => { const submittedBootstrap = selectedBootstrap; setJobError(null); + setJobErrorKind(null); setJobResult(null); setJobResultBootstrap(null); setJobStatus(null); @@ -408,6 +414,7 @@ export function App() { setJobStatus(null); setActiveAnalysisBootstrap(null); setJobError(t("analysisCouldNotStart")); + setJobErrorKind("analysis"); } finally { setIsStarting(false); } @@ -420,12 +427,19 @@ export function App() { const selection = await selectLocalAudioSource(); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setJobError(null); + setJobErrorKind(null); + setJobStatus(null); + setActiveAnalysisBootstrap(null); return; } - setSelectedBootstrap(null); setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio"))); setSelectionErrorSource("local"); + if (jobErrorKind === "analysis") { + return; + } + setSelectedBootstrap(null); setJobStatus(null); }; @@ -452,6 +466,10 @@ export function App() { if (selection.ok) { setSelectedBootstrap(selection.bootstrap); setYoutubeUrl(""); + setJobError(null); + setJobErrorKind(null); + setJobStatus(null); + setActiveAnalysisBootstrap(null); } else { setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed"))); setSelectionErrorSource("youtube"); @@ -477,12 +495,14 @@ export function App() { setJobResult(song); setJobResultBootstrap(null); setJobError(null); + setJobErrorKind(null); setSelectedBootstrap(null); setActiveAnalysisBootstrap(null); setJobStatus(null); } catch (e) { if (!isUserCancellation(e)) { setJobError(`${t("loadProjectFailedPrefix")}: ${safeErrorDetail(e, t("loadProjectFailedFallback"))}`); + setJobErrorKind("project"); } } }; @@ -494,6 +514,7 @@ export function App() { } catch (e) { if (!isUserCancellation(e)) { setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`); + setJobErrorKind("project"); } } }; @@ -506,7 +527,16 @@ export function App() { /** Documented. */ const renderWorkspaceState = () => { if (jobError) { - return ; + const analysisRecovery = jobErrorKind === "analysis"; + return ( + { void handleStartAnalysis(); } : undefined} + onChooseAnotherSong={analysisRecovery ? () => { void handleChooseLocalAudio(); } : undefined} + actionsDisabled={analysisInFlight || isStarting || isImporting} + /> + ); } if (analysisInFlight || isStarting) { return ; 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..4869a5d52 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { EmptyState, ErrorState, LoadingState } from "./WorkspaceStates"; + +describe("WorkspaceStates", () => { + it("renders the empty prompt that names choosing a song", () => { + render(); + expect(screen.getByText(/ready to analyze/i)).toBeTruthy(); + expect(screen.getByText(/choose an audio file to prepare for your rehearsal/i)).toBeTruthy(); + }); + + it("renders a busy analysis status", () => { + render(); + expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true"); + expect(screen.getByText(/analyzing audio/i)).toBeTruthy(); + }); + + it("keeps project failures as a message-only alert", () => { + render(); + expect(screen.getByRole("alert").textContent).toMatch(/an error occurred during analysis/i); + expect(screen.queryByRole("button", { name: /try this song again/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /choose another song/i })).toBeNull(); + }); + + it("names retry and choose-another as the next analysis actions", () => { + const onRetry = vi.fn(); + const onChooseAnotherSong = vi.fn(); + render( + + ); + + expect(screen.getByRole("heading", { name: /analysis didn't finish/i })).toBeTruthy(); + expect(screen.getByText(/this song is still on this device/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /try this song again/i })); + fireEvent.click(screen.getByRole("button", { name: /choose another song/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onChooseAnotherSong).toHaveBeenCalledTimes(1); + }); + + it("disables retry until a song is admitted", () => { + const onRetry = vi.fn(); + render( + undefined} + /> + ); + + const retry = screen.getByRole("button", { name: /try this song again/i }); + expect(retry).toBeDisabled(); + expect(retry).toHaveAttribute("title", "Choose a song first"); + fireEvent.click(retry); + expect(onRetry).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..8d8750701 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,6 +1,7 @@ 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, Play, Upload } from "lucide-react"; /** Documented. */ export function EmptyState() { @@ -38,17 +39,69 @@ export function LoadingState() { ); } -/** Documented. */ -export function ErrorState({ error }: { error?: string }) { +/** Next-action handlers after analysis fails without claiming a demo or stem player. */ +export interface ErrorStateProps { + error?: string; + canRetry?: boolean; + onRetry?: () => void; + onChooseAnotherSong?: () => void; + actionsDisabled?: boolean; +} + +/** Name the next rehearsal action after analysis fails. */ +export function ErrorState({ + error, + canRetry = false, + onRetry, + onChooseAnotherSong, + actionsDisabled = false +}: ErrorStateProps) { const t = createTranslator(detectPreferredLocale()); + const showRecoveryActions = Boolean(onRetry || onChooseAnotherSong); + return (
-

{t("workspaceErrorState")}

+

+ {showRecoveryActions ? t("analysisFailedTitle") : t("workspaceErrorState")} +

+ {showRecoveryActions ? ( +

{t("analysisFailedGuidance")}

+ ) : null} {error &&

{error}

} + {showRecoveryActions ? ( +
+ {onRetry ? ( + + ) : null} + {onChooseAnotherSong ? ( + + ) : null} +
+ ) : null}
); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..a7e4cc5b1 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -32,6 +32,11 @@ "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.", "workspaceLoadingState": "Analyzing the song's form and instrument roles...", "workspaceErrorState": "An error occurred during analysis. Please try again.", + "analysisFailedTitle": "Analysis didn't finish", + "analysisFailedGuidance": "This song is still on this device. Try analysis again, or choose another file.", + "analysisFailedRetry": "Try this song again", + "analysisFailedChooseAnother": "Choose another song", + "analysisFailedRetryUnavailable": "Choose a song first", "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 371884abb..408c5734a 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -32,6 +32,11 @@ "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", + "analysisFailedTitle": "분석이 끝나지 않았습니다", + "analysisFailedGuidance": "이 곡은 이 기기에 그대로 있습니다. 다시 분석하거나 다른 파일을 고르세요.", + "analysisFailedRetry": "이 곡 다시 분석하기", + "analysisFailedChooseAnother": "다른 곡 고르기", + "analysisFailedRetryUnavailable": "먼저 곡을 선택하세요", "workspaceRehearsalMapLabel": "오늘의 합주 지도", "workspaceRehearsalFallback": "합주 작업 공간", "workspaceTempoLabel": "템포", From ee26a037a559237f247d1e693e7049134da9510b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 14:04:38 -0700 Subject: [PATCH 02/10] docs(storybook): cover analysis failure recovery states --- .../workspace/WorkspaceStates.stories.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx new file mode 100644 index 000000000..94b27b2ee --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { ErrorState } from "./WorkspaceStates" + +const meta = { + title: "Workspace/Analysis failure recovery", + component: ErrorState, + parameters: { layout: "padded" }, + args: { + error: "Analysis queue is full. Please wait for a running job to finish.", + canRetry: true, + onRetry: () => undefined, + onChooseAnotherSong: () => undefined, + actionsDisabled: false, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Analysis failures keep the admitted song actionable instead of ending at a message-only alert. */ +export const RecoverableAnalysisFailure: Story = {} + +/** Retry stays visibly unavailable until a song has been admitted, while replacement remains available. */ +export const RetryUnavailable: Story = { + args: { + error: "Analysis could not start.", + canRetry: false, + }, +} + +/** In-flight recovery prevents duplicate submissions without removing the customer's next actions. */ +export const RecoveryInFlight: Story = { + args: { + actionsDisabled: true, + }, +} + +/** Project persistence failures remain message-only and do not imply that re-analysis is the remedy. */ +export const ProjectFailure: Story = { + args: { + error: "Failed to save project: Disk full", + canRetry: false, + onRetry: undefined, + onChooseAnotherSong: undefined, + }, +} From 4bf625900de7f9c3faf0384143055db82c16281b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:06:51 -0700 Subject: [PATCH 03/10] test(workspace): pin silent local-picker cancellation --- .../src/lib/analysis.selection.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/desktop/src/lib/analysis.selection.test.ts diff --git a/apps/desktop/src/lib/analysis.selection.test.ts b/apps/desktop/src/lib/analysis.selection.test.ts new file mode 100644 index 000000000..f42325be6 --- /dev/null +++ b/apps/desktop/src/lib/analysis.selection.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { selectLocalAudioSource } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: (command: string, args?: Record) => Promise; +}; + +const tauriWindow = window as TauriWindow; + +describe("local audio selection boundary", () => { + beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("preserves native picker cancellation as a silent-capable signal", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(new Error("User cancelled")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "User cancelled" + } + }); + }); + + it("does not expose arbitrary native picker errors to the buyer", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue(new Error("/Users/customer/Music/private.wav token=secret-value")); + + await expect(selectLocalAudioSource()).resolves.toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); +}); From 6265983e4a9e3b14d0d60308208787050539ea37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:07:27 -0700 Subject: [PATCH 04/10] test(workspace): keep recovery state on picker cancel --- ...pp.analysis-recovery-cancellation.test.tsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 apps/desktop/src/App.analysis-recovery-cancellation.test.tsx diff --git a/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx b/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx new file mode 100644 index 000000000..3c5665b72 --- /dev/null +++ b/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { App } from "./App"; + +const selectLocalAudioSource = vi.fn(); +const startAnalysisJob = vi.fn(); + +vi.mock("./features/score/ScoreView", () => ({ + ScoreView: () =>
Score view
+})); + +vi.mock("./lib/analysis", () => ({ + MAX_YOUTUBE_URL_LENGTH: 2000, + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + }), + getAnalysisJobStatus: vi.fn(), + importYoutubeUrl: vi.fn(), + isSupportedYoutubeUrl: () => false, + loadProject: vi.fn(), + saveProject: vi.fn(), + selectLocalAudioSource, + startAnalysisJob, + subscribeToAnalysisJobUpdates: vi.fn().mockResolvedValue(() => undefined) +})); + +const admittedBootstrap = { + projectId: "project-1", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/project-1", + cacheRoot: "/tmp/bandscope/cache/project-1", + tempRoot: "/tmp/bandscope/temp/project-1", + source: { + sourcePath: "/Users/test/Music/late-night-set.wav", + fileName: "late-night-set.wav", + extension: "wav", + fileSizeBytes: 1024000 + } +}; + +describe("analysis failure recovery cancellation", () => { + beforeEach(() => { + selectLocalAudioSource.mockReset(); + startAnalysisJob.mockReset(); + }); + + it("keeps the admitted song and recovery actions when the replacement picker is cancelled", async () => { + selectLocalAudioSource + .mockResolvedValueOnce({ ok: true, bootstrap: admittedBootstrap }) + .mockResolvedValueOnce({ + ok: false, + error: { code: "invalid_request", message: "User cancelled" } + }); + startAnalysisJob.mockResolvedValue({ + jobId: "job-1", + state: "failed", + requestedAt: "2026-08-22T00:00:00.000Z", + updatedAt: "2026-08-22T00:00:01.000Z", + error: { + code: "engine_unavailable", + message: "Analysis engine is unavailable." + } + }); + + render(); + + 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 })); + await waitFor(() => expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /choose another song/i })); + await waitFor(() => expect(selectLocalAudioSource).toHaveBeenCalledTimes(2)); + + expect(screen.queryByText(/user cancelled/i)).toBeNull(); + expect(screen.queryByText(/choose a wav, mp3, flac, or m4a file/i)).toBeNull(); + expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /try this song again/i }).hasAttribute("disabled")).toBe(false); + expect(screen.getByRole("button", { name: /choose another song/i }).hasAttribute("disabled")).toBe(false); + }); +}); From e8782fff261b8d8728204638d9b98712482e6472 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:08:01 -0700 Subject: [PATCH 05/10] fix(workspace): preserve picker cancellation signal --- apps/desktop/src/lib/analysis.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..7eea76b06 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -37,6 +37,7 @@ const BROWSER_PROGRESS_STEPS = [ const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, + "User cancelled", "Could not read the selected audio file.", "Could not prepare the local project workspace.", "Could not prepare the local cache workspace.", From 5165178d8e0d29aeb8d5065727322ffa4d179934 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:09:02 -0700 Subject: [PATCH 06/10] fix(workspace): distinguish picker cancellation from validation --- apps/desktop/src-tauri/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..f9a5eabe5 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -641,7 +641,7 @@ fn select_local_audio_source( let path = FileDialog::new() .add_filter("Audio", &AUDIO_EXTENSIONS) .pick_file() - .ok_or_else(|| "Choose a WAV, MP3, FLAC, or M4A file to start analysis.".to_string())?; + .ok_or_else(|| "User cancelled".to_string())?; let source = normalize_local_audio_source(&path)?; let project_id = next_project_id(&state); let project_root = app_owned_root(&app, "projects", &project_id)?; From e28f7e7b2fa6956f7c8c49c221cd53ab0f101074 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:11:06 -0700 Subject: [PATCH 07/10] fix(workspace): keep recovery state on picker cancel --- apps/desktop/src/App.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 44f45fcac..11a8e46df 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -365,6 +365,7 @@ export function App() { return; } const fallbackMessage = t("analysisCouldNotStart"); + setActiveAnalysisBootstrap(null); setJobError(fallbackMessage); setJobErrorKind("analysis"); setJobStatus({ @@ -434,6 +435,10 @@ export function App() { return; } + if (isUserCancellation(selection.error.message)) { + return; + } + setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio"))); setSelectionErrorSource("local"); if (jobErrorKind === "analysis") { From 9271422453d4ac40399fb1208de5b356d5ab3caf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:13:04 -0700 Subject: [PATCH 08/10] test(workspace): mirror native cancellation rejection shape --- apps/desktop/src/lib/analysis.selection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.selection.test.ts b/apps/desktop/src/lib/analysis.selection.test.ts index f42325be6..d22737420 100644 --- a/apps/desktop/src/lib/analysis.selection.test.ts +++ b/apps/desktop/src/lib/analysis.selection.test.ts @@ -16,7 +16,7 @@ describe("local audio selection boundary", () => { }); it("preserves native picker cancellation as a silent-capable signal", async () => { - tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue(new Error("User cancelled")); + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue("User cancelled"); await expect(selectLocalAudioSource()).resolves.toEqual({ ok: false, From 2208f3ad4053338c77b9778ec2a3b65e01543696 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:13:52 -0700 Subject: [PATCH 09/10] fix(workspace): preserve Tauri string cancellation safely --- apps/desktop/src/lib/analysis.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 7eea76b06..7393714f4 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -232,13 +232,14 @@ export async function selectLocalAudioSource(): Promise Date: Sat, 22 Aug 2026 07:15:21 -0700 Subject: [PATCH 10/10] test(workspace): hoist analysis recovery mocks --- ...pp.analysis-recovery-cancellation.test.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx b/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx index 3c5665b72..e8eacf331 100644 --- a/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx +++ b/apps/desktop/src/App.analysis-recovery-cancellation.test.tsx @@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; -const selectLocalAudioSource = vi.fn(); -const startAnalysisJob = vi.fn(); +const analysisMocks = vi.hoisted(() => ({ + selectLocalAudioSource: vi.fn(), + startAnalysisJob: vi.fn() +})); vi.mock("./features/score/ScoreView", () => ({ ScoreView: () =>
Score view
@@ -22,8 +24,8 @@ vi.mock("./lib/analysis", () => ({ isSupportedYoutubeUrl: () => false, loadProject: vi.fn(), saveProject: vi.fn(), - selectLocalAudioSource, - startAnalysisJob, + selectLocalAudioSource: analysisMocks.selectLocalAudioSource, + startAnalysisJob: analysisMocks.startAnalysisJob, subscribeToAnalysisJobUpdates: vi.fn().mockResolvedValue(() => undefined) })); @@ -43,18 +45,18 @@ const admittedBootstrap = { describe("analysis failure recovery cancellation", () => { beforeEach(() => { - selectLocalAudioSource.mockReset(); - startAnalysisJob.mockReset(); + analysisMocks.selectLocalAudioSource.mockReset(); + analysisMocks.startAnalysisJob.mockReset(); }); it("keeps the admitted song and recovery actions when the replacement picker is cancelled", async () => { - selectLocalAudioSource + analysisMocks.selectLocalAudioSource .mockResolvedValueOnce({ ok: true, bootstrap: admittedBootstrap }) .mockResolvedValueOnce({ ok: false, error: { code: "invalid_request", message: "User cancelled" } }); - startAnalysisJob.mockResolvedValue({ + analysisMocks.startAnalysisJob.mockResolvedValue({ jobId: "job-1", state: "failed", requestedAt: "2026-08-22T00:00:00.000Z", @@ -74,7 +76,7 @@ describe("analysis failure recovery cancellation", () => { await waitFor(() => expect(screen.getByRole("button", { name: /choose another song/i })).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: /choose another song/i })); - await waitFor(() => expect(selectLocalAudioSource).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(analysisMocks.selectLocalAudioSource).toHaveBeenCalledTimes(2)); expect(screen.queryByText(/user cancelled/i)).toBeNull(); expect(screen.queryByText(/choose a wav, mp3, flac, or m4a file/i)).toBeNull();