From 65de4588d049e3cea3823bb89cbb35d90269c226 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:11:10 +0000 Subject: [PATCH 01/34] feat(workspace): name the next rehearsal action from help Replace the coming-soon help control with a local-first panel that tells a musician whether to choose a song, start analysis, wait, retry, or open tonight's map. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- apps/desktop/src/App.test.tsx | 65 +++++++++- apps/desktop/src/App.tsx | 37 +++++- .../src/features/help/RehearsalHelp.test.tsx | 110 ++++++++++++++++ .../src/features/help/RehearsalHelp.tsx | 120 ++++++++++++++++++ .../src/features/help/rehearsalHelp.test.ts | 71 +++++++++++ .../src/features/help/rehearsalHelp.ts | 54 ++++++++ apps/desktop/src/locales/en/common.json | 13 ++ apps/desktop/src/locales/ko/common.json | 13 ++ apps/desktop/vite.config.ts | 1 + 13 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/features/help/RehearsalHelp.test.tsx create mode 100644 apps/desktop/src/features/help/RehearsalHelp.tsx create mode 100644 apps/desktop/src/features/help/rehearsalHelp.test.ts create mode 100644 apps/desktop/src/features/help/rehearsalHelp.ts diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..ed9cf8448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## Project overview - BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- Rehearsal help must name the next action: choose a local song, start analysis, wait, retry after failure, or open tonight's map. Stem playback and a licensed demo remain later work. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..bebd20870 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-21 ## Brand source @@ -86,6 +86,7 @@ Last updated: 2026-03-11 - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form + - a rehearsal-help surface that names the next local-first action (choose a song, start analysis, wait, retry, or open the map) without claiming stem playback or a licensed demo ## Confidence, edits, and provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..1ce015e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Rehearsal help now names the next action: choose a local song, start analysis, wait while analysis runs, retry after a failure, or open tonight's map. - 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..7e5f23587 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings, help). The help control names tonight's next local-first action instead of a coming-soon label. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..dfa8bb913 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -218,7 +218,11 @@ describe("App", () => { expect(screen.getByRole("button", { name: /^Import$/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /^Export$/i })).toBeTruthy(); expect(fireEvent.click(screen.getByRole("button", { name: /settings coming soon/i }))).toBe(false); - expect(fireEvent.click(screen.getByRole("button", { name: /help coming soon/i }))).toBe(false); + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch( + /Choose a local song first/i, + ); + fireEvent.click(screen.getByRole("button", { name: /Close help/i })); const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i }); const activePrimaryNavButton = within(primaryNav).getByRole("button", { name: "Workspace" }); expect(activePrimaryNavButton).toHaveAttribute("aria-current", "page"); @@ -1553,14 +1557,65 @@ describe("App", () => { }); - it("renders Settings and Help as focusable aria-disabled controls", () => { + it("renders Settings as a focusable aria-disabled control and opens rehearsal help", () => { render(); const settingsButton = screen.getByRole("button", { name: "Settings coming soon" }); - const helpButton = screen.getByRole("button", { name: "Help coming soon" }); + const helpButton = screen.getByRole("button", { name: "Open rehearsal help" }); expect(settingsButton).toHaveAttribute("aria-disabled", "true"); expect(settingsButton).not.toHaveAttribute("disabled"); - expect(helpButton).toHaveAttribute("aria-disabled", "true"); - expect(helpButton).not.toHaveAttribute("disabled"); + expect(helpButton).toHaveAttribute("aria-haspopup", "dialog"); + expect(helpButton).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(helpButton); + expect(helpButton).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /Choose a local song/i })).toBeTruthy(); + }); + + it("starts local-file intake from rehearsal help before a song is loaded", async () => { + tauriInvoke.mockResolvedValueOnce(bootstrapResponse()); + render(); + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + fireEvent.click(screen.getByRole("button", { name: /Choose a local song/i })); + await waitFor(() => { + expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); + }); + }); + + it("starts analysis from rehearsal help after a local song is chosen", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-help-start", + state: "queued", + progressLabel: "Queued for analysis" + })) + .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: /open rehearsal help/i })); + const helpDialog = screen.getByTestId("rehearsal-help-dialog"); + expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch( + /Start analysis to get tonight's first cues/i, + ); + fireEvent.click(within(helpDialog).getByRole("button", { name: /^Start analysis$/i })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + }); + + it("focuses tonight's rehearsal map from help after analysis is ready", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + fireEvent.click(screen.getByRole("button", { name: /Show the rehearsal map/i })); + expect(document.getElementById("main-content")).toBe(document.activeElement); }); it("keeps the Score view disabled until a song is loaded", () => { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..a4b652e4e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -47,7 +47,8 @@ import { createTranslator, detectPreferredLocale, type TranslationKey } from "./ import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; -import { Button } from "@/components/ui/button"; +import { RehearsalHelp } from "./features/help/RehearsalHelp"; +import { resolveRehearsalHelpPhase } from "./features/help/rehearsalHelp";import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Toaster } from "@/components/ui/sonner"; @@ -264,10 +265,17 @@ export function App() { const [youtubeUrl, setYoutubeUrl] = useState(""); const [isImporting, setIsImporting] = useState(false); const [activeView, setActiveView] = useState("workspace"); + const [helpOpen, setHelpOpen] = useState(false); const activeJobIdRef = useRef(null); const youtubeInputRef = useRef(null); const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running"; + const helpPhase = resolveRehearsalHelpPhase({ + hasLocalSource: selectedBootstrap !== null, + analysisInFlight: analysisInFlight || isStarting, + hasSong: jobResult !== null, + hasError: jobError !== null, + }); const selectedRequest: AnalysisJobRequest = selectedBootstrap ? { sourceKind: "local_audio", @@ -623,11 +631,12 @@ export function App() { @@ -635,7 +644,7 @@ export function App() { -
+
+ { + void handleChooseLocalAudio(); + }} + onStartAnalysis={() => { + void handleStartAnalysis(); + }} + onShowMap={() => { + document.getElementById("main-content")?.focus(); + }} + /> ); diff --git a/apps/desktop/src/features/help/RehearsalHelp.test.tsx b/apps/desktop/src/features/help/RehearsalHelp.test.tsx new file mode 100644 index 000000000..de6cf0bf3 --- /dev/null +++ b/apps/desktop/src/features/help/RehearsalHelp.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RehearsalHelp } from "./RehearsalHelp"; +import type { RehearsalHelpPhase } from "./rehearsalHelp"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language, + }); +} + +function renderHelp(phase: RehearsalHelpPhase) { + const onOpenChange = vi.fn(); + const onChooseLocal = vi.fn(); + const onStartAnalysis = vi.fn(); + const onShowMap = vi.fn(); + render( + , + ); + return { onOpenChange, onChooseLocal, onStartAnalysis, onShowMap }; +} + +describe("RehearsalHelp", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("names choose-a-local-song as the first action", () => { + setNavigatorLanguage("en-US"); + const handlers = renderHelp("choose-local-song"); + + expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch( + /Choose a local song first/i, + ); + fireEvent.click(screen.getByRole("button", { name: /Choose a local song/i })); + expect(handlers.onOpenChange).toHaveBeenCalledWith(false); + expect(handlers.onChooseLocal).toHaveBeenCalledTimes(1); + expect(handlers.onStartAnalysis).not.toHaveBeenCalled(); + expect(handlers.onShowMap).not.toHaveBeenCalled(); + }); + + it("starts analysis once a local file is ready", () => { + setNavigatorLanguage("en-US"); + const handlers = renderHelp("start-analysis"); + + expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch( + /Start analysis to get tonight's first cues/i, + ); + fireEvent.click(screen.getByRole("button", { name: /^Start analysis$/i })); + expect(handlers.onStartAnalysis).toHaveBeenCalledTimes(1); + expect(handlers.onChooseLocal).not.toHaveBeenCalled(); + }); + + it("waits without a competing action while analysis runs", () => { + setNavigatorLanguage("en-US"); + renderHelp("wait-for-analysis"); + + expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch( + /Analysis is running/i, + ); + expect(screen.queryByRole("button", { name: /Choose a local song/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Start analysis$/i })).toBeNull(); + expect(screen.getByRole("button", { name: /Close help/i })).toBeTruthy(); + }); + + it("retries with another local song after a failed analysis", () => { + setNavigatorLanguage("en-US"); + const handlers = renderHelp("retry-after-failure"); + + expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch( + /Choose another local song and try again/i, + ); + fireEvent.click(screen.getByRole("button", { name: /Choose another song/i })); + expect(handlers.onChooseLocal).toHaveBeenCalledTimes(1); + expect(handlers.onStartAnalysis).not.toHaveBeenCalled(); + }); + + it("shows the rehearsal map once tonight's analysis is ready", () => { + setNavigatorLanguage("en-US"); + const handlers = renderHelp("open-rehearsal-map"); + + expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch( + /rehearsal map is ready/i, + ); + fireEvent.click(screen.getByRole("button", { name: /Show the rehearsal map/i })); + expect(handlers.onShowMap).toHaveBeenCalledTimes(1); + expect(handlers.onChooseLocal).not.toHaveBeenCalled(); + }); + + it("keeps Korean next-action copy on the same help surface", () => { + setNavigatorLanguage("ko-KR"); + renderHelp("choose-local-song"); + + expect(screen.getByRole("heading", { name: /오늘 밤 BandScope가 돕는 방법/ })).toBeTruthy(); + expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch( + /먼저 로컬 곡을 고르세요/, + ); + expect(screen.getByRole("button", { name: /로컬 곡 고르기/ })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/help/RehearsalHelp.tsx b/apps/desktop/src/features/help/RehearsalHelp.tsx new file mode 100644 index 000000000..d6cfafd1f --- /dev/null +++ b/apps/desktop/src/features/help/RehearsalHelp.tsx @@ -0,0 +1,120 @@ +import { useMemo, type ReactElement } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + createTranslator, + detectPreferredLocale, + type TranslationKey, +} from "../../i18n"; +import { + rehearsalHelpAction, + type RehearsalHelpPhase, +} from "./rehearsalHelp"; + +interface RehearsalHelpProps { + open: boolean; + phase: RehearsalHelpPhase; + onOpenChange: (open: boolean) => void; + onChooseLocal: () => void; + onStartAnalysis: () => void; + onShowMap: () => void; +} + +const PHASE_BODY_KEY: Record = { + "choose-local-song": "helpChooseLocalBody", + "start-analysis": "helpStartAnalysisBody", + "wait-for-analysis": "helpWaitBody", + "retry-after-failure": "helpRetryBody", + "open-rehearsal-map": "helpReadyBody", +}; + +const PHASE_ACTION_KEY: Record< + Exclude, "none">, + TranslationKey +> = { + "choose-local": "helpChooseLocalAction", + "start-analysis": "helpStartAnalysisAction", + "focus-map": "helpReadyAction", +}; + +/** Render tonight's rehearsal help with one next action. */ +export function RehearsalHelp({ + open, + phase, + onOpenChange, + onChooseLocal, + onStartAnalysis, + onShowMap, +}: RehearsalHelpProps): ReactElement { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const action = rehearsalHelpAction(phase); + const retry = phase === "retry-after-failure"; + + /** Documented. */ + const runNextAction = (): void => { + onOpenChange(false); + if (action === "choose-local") { + onChooseLocal(); + return; + } + if (action === "start-analysis") { + onStartAnalysis(); + return; + } + if (action === "focus-map") { + onShowMap(); + } + }; + + return ( + + + + + {t("helpTitle")} + + + {t(PHASE_BODY_KEY[phase])} + + +

{t("helpPrivacy")}

+ + + {t("helpClose")} + + } + /> + {action !== "none" ? ( + + ) : null} + +
+
+ ); +} diff --git a/apps/desktop/src/features/help/rehearsalHelp.test.ts b/apps/desktop/src/features/help/rehearsalHelp.test.ts new file mode 100644 index 000000000..233a71818 --- /dev/null +++ b/apps/desktop/src/features/help/rehearsalHelp.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + rehearsalHelpAction, + resolveRehearsalHelpPhase, + type RehearsalHelpSnapshot, +} from "./rehearsalHelp"; + +function snapshot( + overrides: Partial = {}, +): RehearsalHelpSnapshot { + return { + hasLocalSource: false, + analysisInFlight: false, + hasSong: false, + hasError: false, + ...overrides, + }; +} + +describe("rehearsalHelp", () => { + it("asks for a local song before any source is chosen", () => { + expect(resolveRehearsalHelpPhase(snapshot())).toBe("choose-local-song"); + expect(rehearsalHelpAction("choose-local-song")).toBe("choose-local"); + }); + + it("starts analysis once a local song is loaded", () => { + expect( + resolveRehearsalHelpPhase(snapshot({ hasLocalSource: true })), + ).toBe("start-analysis"); + expect(rehearsalHelpAction("start-analysis")).toBe("start-analysis"); + }); + + it("waits while analysis is in flight even if an earlier error remains", () => { + expect( + resolveRehearsalHelpPhase( + snapshot({ + hasLocalSource: true, + analysisInFlight: true, + hasError: true, + }), + ), + ).toBe("wait-for-analysis"); + expect(rehearsalHelpAction("wait-for-analysis")).toBe("none"); + }); + + it("retries after a failed analysis when no song is ready", () => { + expect( + resolveRehearsalHelpPhase( + snapshot({ hasLocalSource: true, hasError: true }), + ), + ).toBe("retry-after-failure"); + expect(rehearsalHelpAction("retry-after-failure")).toBe("choose-local"); + }); + + it("opens the rehearsal map once a song is ready", () => { + expect( + resolveRehearsalHelpPhase( + snapshot({ hasLocalSource: true, hasSong: true }), + ), + ).toBe("open-rehearsal-map"); + expect(rehearsalHelpAction("open-rehearsal-map")).toBe("focus-map"); + }); + + it("keeps a ready song ahead of a stale error flag", () => { + expect( + resolveRehearsalHelpPhase( + snapshot({ hasLocalSource: true, hasSong: true, hasError: true }), + ), + ).toBe("open-rehearsal-map"); + }); +}); diff --git a/apps/desktop/src/features/help/rehearsalHelp.ts b/apps/desktop/src/features/help/rehearsalHelp.ts new file mode 100644 index 000000000..ca9439372 --- /dev/null +++ b/apps/desktop/src/features/help/rehearsalHelp.ts @@ -0,0 +1,54 @@ +/** Tonight's rehearsal-help phases. Each names one next action. */ +export type RehearsalHelpPhase = + | "choose-local-song" + | "start-analysis" + | "wait-for-analysis" + | "retry-after-failure" + | "open-rehearsal-map"; + +/** Discrete help-button actions that reuse existing App handlers. */ +export type RehearsalHelpAction = "choose-local" | "start-analysis" | "focus-map" | "none"; + +/** Observable App state used to choose the help next-action. */ +export interface RehearsalHelpSnapshot { + hasLocalSource: boolean; + analysisInFlight: boolean; + hasSong: boolean; + hasError: boolean; +} + +/** Resolve the single next rehearsal action from current App state. */ +export function resolveRehearsalHelpPhase( + snapshot: RehearsalHelpSnapshot, +): RehearsalHelpPhase { + if (snapshot.analysisInFlight) { + return "wait-for-analysis"; + } + if (snapshot.hasError && !snapshot.hasSong) { + return "retry-after-failure"; + } + if (snapshot.hasSong) { + return "open-rehearsal-map"; + } + if (snapshot.hasLocalSource) { + return "start-analysis"; + } + return "choose-local-song"; +} + +/** Map a help phase to the App handler it should invoke. */ +export function rehearsalHelpAction( + phase: RehearsalHelpPhase, +): RehearsalHelpAction { + switch (phase) { + case "choose-local-song": + case "retry-after-failure": + return "choose-local"; + case "start-analysis": + return "start-analysis"; + case "open-rehearsal-map": + return "focus-map"; + case "wait-for-analysis": + return "none"; + } +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..64ff8c70b 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -110,6 +110,19 @@ "comingSoon": "Coming soon", "settingsComingSoon": "Settings coming soon", "helpComingSoon": "Help coming soon", + "helpOpen": "Open rehearsal help", + "helpTitle": "How BandScope helps tonight", + "helpPrivacy": "Audio and project files stay on this computer.", + "helpClose": "Close help", + "helpChooseLocalBody": "Choose a local song first. Analysis stays on this computer.", + "helpChooseLocalAction": "Choose a local song", + "helpStartAnalysisBody": "The file is ready. Start analysis to get tonight's first cues.", + "helpStartAnalysisAction": "Start analysis", + "helpWaitBody": "Analysis is running. Stay on this screen until the rehearsal map appears.", + "helpRetryBody": "That analysis did not finish. Choose another local song and try again.", + "helpRetryAction": "Choose another song", + "helpReadyBody": "Tonight's rehearsal map is ready. Start with the first priority section, then export a cue sheet for the room.", + "helpReadyAction": "Show the rehearsal map", "localFirst": "Local-first", "localFirstDetail": "Your rehearsal map stays on this device. Project files stay local. YouTube only leaves the app when you choose import.", "sourceControlsAriaLabel": "Source controls", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..6b96ef3f3 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -110,6 +110,19 @@ "comingSoon": "곧 제공됩니다", "settingsComingSoon": "설정은 곧 제공됩니다", "helpComingSoon": "도움말은 곧 제공됩니다", + "helpOpen": "합주 도움말 열기", + "helpTitle": "오늘 밤 BandScope가 돕는 방법", + "helpPrivacy": "오디오와 프로젝트 파일은 이 컴퓨터에 머뭅니다.", + "helpClose": "도움말 닫기", + "helpChooseLocalBody": "먼저 로컬 곡을 고르세요. 분석은 이 컴퓨터에서만 이뤄집니다.", + "helpChooseLocalAction": "로컬 곡 고르기", + "helpStartAnalysisBody": "파일이 준비됐습니다. 분석을 시작해 오늘 밤 첫 큐를 받으세요.", + "helpStartAnalysisAction": "분석 시작", + "helpWaitBody": "분석을 진행 중입니다. 합주 지도가 나올 때까지 이 화면에 머무르세요.", + "helpRetryBody": "분석이 끝나지 않았습니다. 다른 로컬 곡을 고르고 다시 시도하세요.", + "helpRetryAction": "다른 곡 고르기", + "helpReadyBody": "오늘 밤 합주 지도가 준비됐습니다. 우선 구간부터 보고, 큐시트를 내보내 방에 나눠 주세요.", + "helpReadyAction": "합주 지도 보기", "localFirst": "로컬 우선", "localFirstDetail": "합주 지도는 이 기기에 머뭅니다. 프로젝트 파일은 로컬에 저장됩니다. 유튜브는 가져오기를 선택할 때만 앱 밖으로 나갑니다.", "sourceControlsAriaLabel": "소스 컨트롤", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..a473c8016 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "src/App.tsx", "src/lib/export.ts", "src/i18n/index.ts", + "src/features/help/rehearsalHelp.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", "src/features/score/scoreStorage.ts" From 83f36efe3eaf7f4462c69b47419e0df3af37df74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:43:36 -0700 Subject: [PATCH 02/34] test(help): detect case-insensitive module collisions --- .../src/features/help/rehearsalHelp.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/desktop/src/features/help/rehearsalHelp.test.ts b/apps/desktop/src/features/help/rehearsalHelp.test.ts index 233a71818..4c72ea5d9 100644 --- a/apps/desktop/src/features/help/rehearsalHelp.test.ts +++ b/apps/desktop/src/features/help/rehearsalHelp.test.ts @@ -1,3 +1,5 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { rehearsalHelpAction, @@ -17,7 +19,22 @@ function snapshot( }; } +function productionHelpModuleNames(): string[] { + return readdirSync(join(process.cwd(), "src", "features", "help")) + .filter( + (name) => /\.(?:ts|tsx)$/.test(name) && !name.includes(".test."), + ) + .map((name) => name.replace(/\.(?:ts|tsx)$/, "")); +} + describe("rehearsalHelp", () => { + it("keeps production module basenames unique on case-insensitive filesystems", () => { + const moduleNames = productionHelpModuleNames(); + const caseFoldedNames = moduleNames.map((name) => name.toLowerCase()); + + expect(new Set(caseFoldedNames).size).toBe(moduleNames.length); + }); + it("asks for a local song before any source is chosen", () => { expect(resolveRehearsalHelpPhase(snapshot())).toBe("choose-local-song"); expect(rehearsalHelpAction("choose-local-song")).toBe("choose-local"); From c78e4dffc808561ed834db5016f75af546f06a3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:44:07 -0700 Subject: [PATCH 03/34] fix(help): use case-distinct component module path --- .../src/features/help/RehearsalHelpDialog.tsx | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 apps/desktop/src/features/help/RehearsalHelpDialog.tsx diff --git a/apps/desktop/src/features/help/RehearsalHelpDialog.tsx b/apps/desktop/src/features/help/RehearsalHelpDialog.tsx new file mode 100644 index 000000000..d6cfafd1f --- /dev/null +++ b/apps/desktop/src/features/help/RehearsalHelpDialog.tsx @@ -0,0 +1,120 @@ +import { useMemo, type ReactElement } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + createTranslator, + detectPreferredLocale, + type TranslationKey, +} from "../../i18n"; +import { + rehearsalHelpAction, + type RehearsalHelpPhase, +} from "./rehearsalHelp"; + +interface RehearsalHelpProps { + open: boolean; + phase: RehearsalHelpPhase; + onOpenChange: (open: boolean) => void; + onChooseLocal: () => void; + onStartAnalysis: () => void; + onShowMap: () => void; +} + +const PHASE_BODY_KEY: Record = { + "choose-local-song": "helpChooseLocalBody", + "start-analysis": "helpStartAnalysisBody", + "wait-for-analysis": "helpWaitBody", + "retry-after-failure": "helpRetryBody", + "open-rehearsal-map": "helpReadyBody", +}; + +const PHASE_ACTION_KEY: Record< + Exclude, "none">, + TranslationKey +> = { + "choose-local": "helpChooseLocalAction", + "start-analysis": "helpStartAnalysisAction", + "focus-map": "helpReadyAction", +}; + +/** Render tonight's rehearsal help with one next action. */ +export function RehearsalHelp({ + open, + phase, + onOpenChange, + onChooseLocal, + onStartAnalysis, + onShowMap, +}: RehearsalHelpProps): ReactElement { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const action = rehearsalHelpAction(phase); + const retry = phase === "retry-after-failure"; + + /** Documented. */ + const runNextAction = (): void => { + onOpenChange(false); + if (action === "choose-local") { + onChooseLocal(); + return; + } + if (action === "start-analysis") { + onStartAnalysis(); + return; + } + if (action === "focus-map") { + onShowMap(); + } + }; + + return ( + + + + + {t("helpTitle")} + + + {t(PHASE_BODY_KEY[phase])} + + +

{t("helpPrivacy")}

+ + + {t("helpClose")} + + } + /> + {action !== "none" ? ( + + ) : null} + +
+
+ ); +} From 15a5a152c3eb662e218887167d6be740e751a713 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:49:51 -0700 Subject: [PATCH 04/34] fix(help): import case-distinct help component module --- apps/desktop/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a4b652e4e..2d74bd96d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -47,7 +47,7 @@ import { createTranslator, detectPreferredLocale, type TranslationKey } from "./ import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; -import { RehearsalHelp } from "./features/help/RehearsalHelp"; +import { RehearsalHelp } from "./features/help/RehearsalHelpDialog"; import { resolveRehearsalHelpPhase } from "./features/help/rehearsalHelp";import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; From af86124404b9fc67ad39f9b5b3cc6ac59b5ad6f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:50:52 -0700 Subject: [PATCH 05/34] test(help): import case-distinct dialog module --- apps/desktop/src/features/help/RehearsalHelp.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/help/RehearsalHelp.test.tsx b/apps/desktop/src/features/help/RehearsalHelp.test.tsx index de6cf0bf3..e1c050f98 100644 --- a/apps/desktop/src/features/help/RehearsalHelp.test.tsx +++ b/apps/desktop/src/features/help/RehearsalHelp.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { RehearsalHelp } from "./RehearsalHelp"; +import { RehearsalHelp } from "./RehearsalHelpDialog"; import type { RehearsalHelpPhase } from "./rehearsalHelp"; const originalLanguage = navigator.language; From f0e9247a51a4d8b71e453b52765c40725c09ec9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:51:13 -0700 Subject: [PATCH 06/34] fix(help): remove case-colliding component path --- .../src/features/help/RehearsalHelp.tsx | 120 ------------------ 1 file changed, 120 deletions(-) delete mode 100644 apps/desktop/src/features/help/RehearsalHelp.tsx diff --git a/apps/desktop/src/features/help/RehearsalHelp.tsx b/apps/desktop/src/features/help/RehearsalHelp.tsx deleted file mode 100644 index d6cfafd1f..000000000 --- a/apps/desktop/src/features/help/RehearsalHelp.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useMemo, type ReactElement } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - createTranslator, - detectPreferredLocale, - type TranslationKey, -} from "../../i18n"; -import { - rehearsalHelpAction, - type RehearsalHelpPhase, -} from "./rehearsalHelp"; - -interface RehearsalHelpProps { - open: boolean; - phase: RehearsalHelpPhase; - onOpenChange: (open: boolean) => void; - onChooseLocal: () => void; - onStartAnalysis: () => void; - onShowMap: () => void; -} - -const PHASE_BODY_KEY: Record = { - "choose-local-song": "helpChooseLocalBody", - "start-analysis": "helpStartAnalysisBody", - "wait-for-analysis": "helpWaitBody", - "retry-after-failure": "helpRetryBody", - "open-rehearsal-map": "helpReadyBody", -}; - -const PHASE_ACTION_KEY: Record< - Exclude, "none">, - TranslationKey -> = { - "choose-local": "helpChooseLocalAction", - "start-analysis": "helpStartAnalysisAction", - "focus-map": "helpReadyAction", -}; - -/** Render tonight's rehearsal help with one next action. */ -export function RehearsalHelp({ - open, - phase, - onOpenChange, - onChooseLocal, - onStartAnalysis, - onShowMap, -}: RehearsalHelpProps): ReactElement { - const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const action = rehearsalHelpAction(phase); - const retry = phase === "retry-after-failure"; - - /** Documented. */ - const runNextAction = (): void => { - onOpenChange(false); - if (action === "choose-local") { - onChooseLocal(); - return; - } - if (action === "start-analysis") { - onStartAnalysis(); - return; - } - if (action === "focus-map") { - onShowMap(); - } - }; - - return ( - - - - - {t("helpTitle")} - - - {t(PHASE_BODY_KEY[phase])} - - -

{t("helpPrivacy")}

- - - {t("helpClose")} - - } - /> - {action !== "none" ? ( - - ) : null} - -
-
- ); -} From 2d457bea6bdc479a4f109e16d18328bf7b289274 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:23:52 -0700 Subject: [PATCH 07/34] fix(help): separate import declarations --- apps/desktop/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2d74bd96d..57314aaf3 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -48,7 +48,8 @@ import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; import { RehearsalHelp } from "./features/help/RehearsalHelpDialog"; -import { resolveRehearsalHelpPhase } from "./features/help/rehearsalHelp";import { Button } from "@/components/ui/button"; +import { resolveRehearsalHelpPhase } from "./features/help/rehearsalHelp"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Toaster } from "@/components/ui/sonner"; From a053c8ce7c004ba1f33ff76411a0b6d84c62b0bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:09:42 -0700 Subject: [PATCH 08/34] test(help): cover retry selecting a different song --- ....rehearsalHelpFailure.integration.test.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx new file mode 100644 index 000000000..fdff27042 --- /dev/null +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -0,0 +1,102 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +const analysisMocks = vi.hoisted(() => ({ + getAnalysisJobStatus: vi.fn(), + importYoutubeUrl: vi.fn(), + loadProject: vi.fn(), + saveProject: vi.fn(), + selectLocalAudioSource: vi.fn(), + startAnalysisJob: vi.fn(), + subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined), +})); + +vi.mock("./lib/analysis", () => ({ + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"], + }), + getAnalysisJobStatus: analysisMocks.getAnalysisJobStatus, + importYoutubeUrl: analysisMocks.importYoutubeUrl, + isSupportedYoutubeUrl: () => false, + loadProject: analysisMocks.loadProject, + MAX_YOUTUBE_URL_LENGTH: 2048, + saveProject: analysisMocks.saveProject, + selectLocalAudioSource: analysisMocks.selectLocalAudioSource, + startAnalysisJob: analysisMocks.startAnalysisJob, + subscribeToAnalysisJobUpdates: analysisMocks.subscribeToAnalysisJobUpdates, +})); + +vi.mock("./features/score/ScoreView", () => ({ + ScoreView: () => null, +})); + +function bootstrap(projectId: string, fileName: string) { + return { + projectId, + sourceMode: "reference", + projectRoot: `/tmp/bandscope/projects/${projectId}`, + cacheRoot: `/tmp/bandscope/cache/${projectId}`, + tempRoot: `/tmp/bandscope/temp/${projectId}`, + source: { + sourcePath: `/Users/test/Music/${fileName}`, + fileName, + extension: "wav", + fileSizeBytes: 1024000, + }, + }; +} + +describe("App rehearsal-help failure recovery", () => { + beforeEach(() => { + for (const mock of Object.values(analysisMocks)) { + mock.mockReset(); + } + analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined); + }); + + it("advances from retry to start analysis after a different local song is selected", async () => { + analysisMocks.selectLocalAudioSource + .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-a", "failed-song.wav") }) + .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-b", "fresh-song.wav") }); + analysisMocks.startAnalysisJob.mockResolvedValueOnce({ + jobId: "job-help-failed", + state: "failed", + requestedAt: "2026-08-21T05:00:00.000Z", + updatedAt: "2026-08-21T05: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(/failed-song\.wav/i)).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/analysis engine is unavailable/i); + }); + + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + let helpDialog = screen.getByTestId("rehearsal-help-dialog"); + expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch( + /choose another local song and try again/i, + ); + fireEvent.click(within(helpDialog).getByRole("button", { name: /choose another song/i })); + + await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + helpDialog = screen.getByTestId("rehearsal-help-dialog"); + + expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch( + /start analysis to get tonight's first cues/i, + ); + expect(within(helpDialog).queryByRole("button", { name: /choose another song/i })).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); From 97f332f6d14020352fe76d8860ae6ecd3830a7f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:12:43 -0700 Subject: [PATCH 09/34] fix(help): reset failed analysis when choosing a new song --- apps/desktop/src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 57314aaf3..5f2480300 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -428,6 +428,8 @@ export function App() { setSelectionErrorSource(null); const selection = await selectLocalAudioSource(); if (selection.ok) { + setJobError(null); + setJobStatus(null); setSelectedBootstrap(selection.bootstrap); return; } From 58450b53046449ad5a25a5439d5ff80ec53f2809 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:13:09 -0700 Subject: [PATCH 10/34] docs(test): record help module path trust boundary --- apps/desktop/src/features/help/rehearsalHelp.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/features/help/rehearsalHelp.test.ts b/apps/desktop/src/features/help/rehearsalHelp.test.ts index 4c72ea5d9..78b6441ae 100644 --- a/apps/desktop/src/features/help/rehearsalHelp.test.ts +++ b/apps/desktop/src/features/help/rehearsalHelp.test.ts @@ -19,6 +19,11 @@ function snapshot( }; } +/** + * Security Notes: this test-only helper reads the fixed + * process.cwd()/src/features/help directory. It accepts no user-provided path + * and must not become a general-purpose file-reading API. + */ function productionHelpModuleNames(): string[] { return readdirSync(join(process.cwd(), "src", "features", "help")) .filter( From f46a7ca2cf662abab482cea74d83192419937507 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:10:55 -0700 Subject: [PATCH 11/34] test(help): reject stale result after choosing a new song --- ....rehearsalHelpFailure.integration.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index fdff27042..a7eeb9d0c 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -1,3 +1,4 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; @@ -49,6 +50,11 @@ function bootstrap(projectId: string, fileName: string) { }; } +/** + * Security Notes: + * - Local paths in this suite are synthetic test fixtures and are never rendered in customer-facing copy. + * - The suite mocks the existing picker and analysis boundaries; it adds no network access or IPC permission. + */ describe("App rehearsal-help failure recovery", () => { beforeEach(() => { for (const mock of Object.values(analysisMocks)) { @@ -99,4 +105,41 @@ describe("App rehearsal-help failure recovery", () => { expect(within(helpDialog).queryByRole("button", { name: /choose another song/i })).toBeNull(); expect(screen.queryByRole("alert")).toBeNull(); }); + + it("forgets the previous analyzed song when a different local source is selected", async () => { + analysisMocks.selectLocalAudioSource + .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-a", "analyzed-song.wav") }) + .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-b", "fresh-song.wav") }); + analysisMocks.startAnalysisJob.mockResolvedValueOnce({ + jobId: "job-help-succeeded", + state: "succeeded", + requestedAt: "2026-08-21T05:10:00.000Z", + updatedAt: "2026-08-21T05:10:01.000Z", + progressLabel: "Analysis ready", + progressStage: "ready", + progressPercent: 100, + cacheStatus: "disabled", + result: createDemoRehearsalSong(), + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/analyzed-song\.wav/i)).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull(); + }); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + const helpDialog = screen.getByTestId("rehearsal-help-dialog"); + expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch( + /start analysis to get tonight's first cues/i, + ); + expect(within(helpDialog).queryByRole("button", { name: /show rehearsal map/i })).toBeNull(); + }); }); From 85b39243663292bdcaa60c8069307850140b4861 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:13:41 -0700 Subject: [PATCH 12/34] fix(help): clear stale result when selecting a new song --- apps/desktop/src/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 5f2480300..0d44629c1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -429,7 +429,10 @@ export function App() { const selection = await selectLocalAudioSource(); if (selection.ok) { setJobError(null); + setJobResult(null); + setJobResultBootstrap(null); setJobStatus(null); + setActiveAnalysisBootstrap(null); setSelectedBootstrap(selection.bootstrap); return; } @@ -884,4 +887,4 @@ export function App() { ); -} +} \ No newline at end of file From b22c78c9e6df9608340f2f416abf543c996c4f83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:14:50 -0700 Subject: [PATCH 13/34] test(help): block stale analysis during source import --- ....rehearsalHelpFailure.integration.test.tsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index a7eeb9d0c..d29795186 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -6,6 +6,7 @@ import { App } from "./App"; const analysisMocks = vi.hoisted(() => ({ getAnalysisJobStatus: vi.fn(), importYoutubeUrl: vi.fn(), + isSupportedYoutubeUrl: vi.fn(), loadProject: vi.fn(), saveProject: vi.fn(), selectLocalAudioSource: vi.fn(), @@ -21,7 +22,7 @@ vi.mock("./lib/analysis", () => ({ }), getAnalysisJobStatus: analysisMocks.getAnalysisJobStatus, importYoutubeUrl: analysisMocks.importYoutubeUrl, - isSupportedYoutubeUrl: () => false, + isSupportedYoutubeUrl: analysisMocks.isSupportedYoutubeUrl, loadProject: analysisMocks.loadProject, MAX_YOUTUBE_URL_LENGTH: 2048, saveProject: analysisMocks.saveProject, @@ -60,6 +61,7 @@ describe("App rehearsal-help failure recovery", () => { for (const mock of Object.values(analysisMocks)) { mock.mockReset(); } + analysisMocks.isSupportedYoutubeUrl.mockReturnValue(false); analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined); }); @@ -142,4 +144,41 @@ describe("App rehearsal-help failure recovery", () => { ); expect(within(helpDialog).queryByRole("button", { name: /show rehearsal map/i })).toBeNull(); }); + + it("does not start stale local analysis from help while a YouTube import is in flight", async () => { + let resolveImport: ((value: unknown) => void) | undefined; + analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true); + analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({ + ok: true, + bootstrap: bootstrap("project-local", "local-song.wav"), + }); + analysisMocks.importYoutubeUrl.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveImport = resolve; + }), + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/local-song\.wav/i)).toBeTruthy()); + + fireEvent.change(screen.getByRole("textbox", { name: /youtube url/i }), { + target: { value: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }, + }); + fireEvent.click(screen.getByRole("button", { name: /import youtube/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: /import youtube/i }).textContent).toMatch(/importing/i); + }); + + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + const helpDialog = screen.getByTestId("rehearsal-help-dialog"); + fireEvent.click(within(helpDialog).getByRole("button", { name: /^start analysis$/i })); + + expect(analysisMocks.startAnalysisJob).not.toHaveBeenCalled(); + + resolveImport?.({ ok: true, bootstrap: bootstrap("project-youtube", "imported-song.m4a") }); + await waitFor(() => expect(screen.getByText(/imported-song\.m4a/i)).toBeTruthy()); + }); }); From 8d51eea55385d11167f55f0b4bd34eacf57c7302 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:16:24 -0700 Subject: [PATCH 14/34] fix(help): enforce analysis start guard during import --- apps/desktop/src/App.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0d44629c1..44b9631c9 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -396,6 +396,10 @@ export function App() { /** Documented. */ const handleStartAnalysis = async () => { + if (analysisInFlight || isStarting || isImporting || !selectedBootstrap) { + return; + } + const submittedBootstrap = selectedBootstrap; setJobError(null); setJobResult(null); @@ -887,4 +891,4 @@ export function App() { ); -} \ No newline at end of file +} From a786fae0f8d68914fde61cd57df2ff5f5f625742 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:17:05 -0700 Subject: [PATCH 15/34] test(help): require map action to leave score view --- ....rehearsalHelpFailure.integration.test.tsx | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index d29795186..9be5fcf35 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -32,7 +32,7 @@ vi.mock("./lib/analysis", () => ({ })); vi.mock("./features/score/ScoreView", () => ({ - ScoreView: () => null, + ScoreView: () =>
Score view
, })); function bootstrap(projectId: string, fileName: string) { @@ -181,4 +181,40 @@ describe("App rehearsal-help failure recovery", () => { resolveImport?.({ ok: true, bootstrap: bootstrap("project-youtube", "imported-song.m4a") }); await waitFor(() => expect(screen.getByText(/imported-song\.m4a/i)).toBeTruthy()); }); + + it("switches from score back to the workspace before showing the rehearsal map", async () => { + analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({ + ok: true, + bootstrap: bootstrap("project-map", "map-song.wav"), + }); + analysisMocks.startAnalysisJob.mockResolvedValueOnce({ + jobId: "job-help-map", + state: "succeeded", + requestedAt: "2026-08-21T05:20:00.000Z", + updatedAt: "2026-08-21T05:20:01.000Z", + progressLabel: "Analysis ready", + progressStage: "ready", + progressPercent: 100, + cacheStatus: "disabled", + result: createDemoRehearsalSong(), + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/map-song\.wav/i)).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull(); + }); + + fireEvent.click(screen.getAllByRole("button", { name: /^score$/i })[0]!); + await waitFor(() => expect(screen.getByTestId("score-view")).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + const helpDialog = screen.getByTestId("rehearsal-help-dialog"); + fireEvent.click(within(helpDialog).getByRole("button", { name: /show rehearsal map/i })); + + await waitFor(() => expect(screen.queryByTestId("score-view")).toBeNull()); + }); }); From a7b25de1a9ea5030314554c369a29df55fd3881d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:18:35 -0700 Subject: [PATCH 16/34] fix(help): switch to workspace before showing the map --- apps/desktop/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 44b9631c9..a39aa7daa 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -885,6 +885,7 @@ export function App() { void handleStartAnalysis(); }} onShowMap={() => { + setActiveView("workspace"); document.getElementById("main-content")?.focus(); }} /> From dcc2b469dc57db4faea046f6a7d01b23c2b2cd46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:21:05 -0700 Subject: [PATCH 17/34] test(help): keep focus on the rehearsal map after close --- apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index 9be5fcf35..4f03049f6 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -216,5 +216,7 @@ describe("App rehearsal-help failure recovery", () => { fireEvent.click(within(helpDialog).getByRole("button", { name: /show rehearsal map/i })); await waitFor(() => expect(screen.queryByTestId("score-view")).toBeNull()); + await waitFor(() => expect(screen.queryByTestId("rehearsal-help-dialog")).toBeNull()); + expect(document.activeElement?.id).toBe("main-content"); }); }); From d018d989e64ba65b177390d552c195440415cfaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:21:34 -0700 Subject: [PATCH 18/34] fix(help): preserve map focus after dialog close --- .../src/features/help/RehearsalHelpDialog.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/help/RehearsalHelpDialog.tsx b/apps/desktop/src/features/help/RehearsalHelpDialog.tsx index d6cfafd1f..81d1cc835 100644 --- a/apps/desktop/src/features/help/RehearsalHelpDialog.tsx +++ b/apps/desktop/src/features/help/RehearsalHelpDialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, type ReactElement } from "react"; +import { useMemo, useRef, type ReactElement } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -55,11 +55,22 @@ export function RehearsalHelp({ onShowMap, }: RehearsalHelpProps): ReactElement { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const focusMapOnCloseRef = useRef(false); const action = rehearsalHelpAction(phase); const retry = phase === "retry-after-failure"; + /** Preserve Base UI's normal focus restoration except for the explicit map action. */ + const finalFocusTarget = (): true | HTMLElement => { + if (!focusMapOnCloseRef.current) { + return true; + } + focusMapOnCloseRef.current = false; + return document.getElementById("main-content") ?? true; + }; + /** Documented. */ const runNextAction = (): void => { + focusMapOnCloseRef.current = action === "focus-map"; onOpenChange(false); if (action === "choose-local") { onChooseLocal(); @@ -79,6 +90,7 @@ export function RehearsalHelp({ From 24fdf5816bece841923fa773d538a1f4f783db37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:22:01 -0700 Subject: [PATCH 19/34] test(help): include the dialog in desktop coverage --- apps/desktop/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index a473c8016..e22e7db63 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ "src/lib/export.ts", "src/i18n/index.ts", "src/features/help/rehearsalHelp.ts", + "src/features/help/RehearsalHelpDialog.tsx", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", "src/features/score/scoreStorage.ts" From 91c789adecb2a2771c86c00f332cb3a94bdf79c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:23:11 -0700 Subject: [PATCH 20/34] test(help): require compact access to rehearsal help --- .../src/App.rehearsalHelpFailure.integration.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index 4f03049f6..a9ba5a6dc 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -65,6 +65,17 @@ describe("App rehearsal-help failure recovery", () => { analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined); }); + it("exposes rehearsal help from compact navigation", () => { + render(); + + const compactHelp = screen.getByRole("button", { name: /how bandscope helps tonight/i }); + const compactNav = compactHelp.closest("nav"); + + expect(compactNav?.getAttribute("aria-label")).toMatch(/compact rehearsal views/i); + fireEvent.click(compactHelp); + expect(screen.getByTestId("rehearsal-help-dialog")).toBeTruthy(); + }); + it("advances from retry to start analysis after a different local song is selected", async () => { analysisMocks.selectLocalAudioSource .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-a", "failed-song.wav") }) From 636c8982e11a55f11f3b45bba8f1fb45f77d76cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:25:38 -0700 Subject: [PATCH 21/34] fix(help): expose rehearsal help on compact layouts --- apps/desktop/src/App.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a39aa7daa..7286ac8e8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -682,6 +682,18 @@ export function App() { ); })} +
From d5e31f18fa85fdc32d67da97146bde94a4ac1ee9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:26:27 -0700 Subject: [PATCH 22/34] refactor(help): remove obsolete coming-soon copy --- apps/desktop/src/locales/en/common.json | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 64ff8c70b..2ef3324b7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -109,7 +109,6 @@ "compactViewSuffix": "compact view", "comingSoon": "Coming soon", "settingsComingSoon": "Settings coming soon", - "helpComingSoon": "Help coming soon", "helpOpen": "Open rehearsal help", "helpTitle": "How BandScope helps tonight", "helpPrivacy": "Audio and project files stay on this computer.", From 7efbe15eb8afb675bf2660ba2a911e45d8b482f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:17:44 -0700 Subject: [PATCH 23/34] test: match rehearsal map action label exactly --- .../desktop/src/App.rehearsalHelpFailure.integration.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index a9ba5a6dc..fe772a3bd 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -153,7 +153,7 @@ describe("App rehearsal-help failure recovery", () => { expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch( /start analysis to get tonight's first cues/i, ); - expect(within(helpDialog).queryByRole("button", { name: /show rehearsal map/i })).toBeNull(); + expect(within(helpDialog).queryByRole("button", { name: /show the rehearsal map/i })).toBeNull(); }); it("does not start stale local analysis from help while a YouTube import is in flight", async () => { @@ -224,7 +224,7 @@ describe("App rehearsal-help failure recovery", () => { fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); const helpDialog = screen.getByTestId("rehearsal-help-dialog"); - fireEvent.click(within(helpDialog).getByRole("button", { name: /show rehearsal map/i })); + fireEvent.click(within(helpDialog).getByRole("button", { name: /show the rehearsal map/i })); await waitFor(() => expect(screen.queryByTestId("score-view")).toBeNull()); await waitFor(() => expect(screen.queryByTestId("rehearsal-help-dialog")).toBeNull()); From 63dec827e8bbb553bd530bb8d87b143d8a9f8a76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:21:50 -0700 Subject: [PATCH 24/34] test: align source replacement and save failure contracts --- apps/desktop/src/App.test.tsx | 36 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index dfa8bb913..1a9305131 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -879,20 +879,14 @@ describe("App", () => { }); }); - it("keeps handoff metadata tied to the source that produced the current result", async () => { + it("retires handoff export when a different source replaces the analyzed source", async () => { const originalCreateObjectUrl = URL.createObjectURL; - const originalRevokeObjectUrl = URL.revokeObjectURL; const createObjectUrl = vi.fn(() => "blob:handoff"); - const revokeObjectUrl = vi.fn(); const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl }); - Object.defineProperty(URL, "revokeObjectURL", { - configurable: true, - value: revokeObjectUrl - }); tauriInvoke .mockResolvedValueOnce(bootstrapResponse()) @@ -927,24 +921,16 @@ describe("App", () => { 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 })); - const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; - const payload = JSON.parse(await blob.text()); - - expect(payload.sourceAssets[0].fileName).toBe("late-night-set.wav"); - expect(JSON.stringify(payload)).not.toContain("next-song.wav"); - expect(click).toHaveBeenCalledTimes(1); - expect(revokeObjectUrl).toHaveBeenCalledWith("blob:handoff"); + expect(screen.queryByRole("heading", { name: /Late Night Set/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /export handoff/i })).toBeNull(); + expect(createObjectUrl).not.toHaveBeenCalled(); + expect(click).not.toHaveBeenCalled(); } finally { click.mockRestore(); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: originalCreateObjectUrl }); - Object.defineProperty(URL, "revokeObjectURL", { - configurable: true, - value: originalRevokeObjectUrl - }); } }); @@ -1379,24 +1365,26 @@ describe("App", () => { }); }); - it("handles saving a project failure gracefully", async () => { + it("keeps the loaded rehearsal map visible when saving the project fails", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce(new Error("Permission denied")); - - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { expect(screen.getByText(/Failed to save project: Permission denied/i)).toBeTruthy(); }); + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + const helpDialog = screen.getByTestId("rehearsal-help-dialog"); + expect(within(helpDialog).getByRole("button", { name: /show the rehearsal map/i })).toBeTruthy(); }); it("ignores cancellation when saving a project with Error object", async () => { @@ -1671,4 +1659,4 @@ describe("App", () => { expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); -}); +}); \ No newline at end of file From d2af745d34cd4aaa67249840acc7c87f194f3921 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:46:11 -0700 Subject: [PATCH 25/34] test(help): show wait state during source import --- .../src/App.rehearsalHelpFailure.integration.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index fe772a3bd..fde430dd7 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -156,7 +156,7 @@ describe("App rehearsal-help failure recovery", () => { expect(within(helpDialog).queryByRole("button", { name: /show the rehearsal map/i })).toBeNull(); }); - it("does not start stale local analysis from help while a YouTube import is in flight", async () => { + it("shows a wait-only help state while a YouTube import is in flight", async () => { let resolveImport: ((value: unknown) => void) | undefined; analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true); analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({ @@ -185,8 +185,11 @@ describe("App rehearsal-help failure recovery", () => { fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); const helpDialog = screen.getByTestId("rehearsal-help-dialog"); - fireEvent.click(within(helpDialog).getByRole("button", { name: /^start analysis$/i })); + expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch( + /wait for this step to finish/i, + ); + expect(within(helpDialog).queryByRole("button", { name: /^start analysis$/i })).toBeNull(); expect(analysisMocks.startAnalysisJob).not.toHaveBeenCalled(); resolveImport?.({ ok: true, bootstrap: bootstrap("project-youtube", "imported-song.m4a") }); From db3ced1af1ccc20cc44022d31f9ce0074d038d6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:47:19 -0700 Subject: [PATCH 26/34] test(help): distinguish load failures and compact action label --- ....rehearsalHelpFailure.integration.test.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index fde430dd7..5f38c916b 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -65,10 +65,10 @@ describe("App rehearsal-help failure recovery", () => { analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined); }); - it("exposes rehearsal help from compact navigation", () => { + it("exposes rehearsal help from compact navigation with the same accessible name", () => { render(); - const compactHelp = screen.getByRole("button", { name: /how bandscope helps tonight/i }); + const compactHelp = screen.getByRole("button", { name: /open rehearsal help/i }); const compactNav = compactHelp.closest("nav"); expect(compactNav?.getAttribute("aria-label")).toMatch(/compact rehearsal views/i); @@ -76,6 +76,24 @@ describe("App rehearsal-help failure recovery", () => { expect(screen.getByTestId("rehearsal-help-dialog")).toBeTruthy(); }); + it("does not describe a project-load error as an analysis failure", async () => { + analysisMocks.loadProject.mockRejectedValueOnce(new Error("Broken project fixture")); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /open rehearsal help/i })); + const helpDialog = screen.getByTestId("rehearsal-help-dialog"); + expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch( + /choose a local song first/i, + ); + expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).not.toMatch( + /analysis did not finish/i, + ); + }); + it("advances from retry to start analysis after a different local song is selected", async () => { analysisMocks.selectLocalAudioSource .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-a", "failed-song.wav") }) From dc5463db61a3658748f8d40800ce0b909b4acdd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:50:44 -0700 Subject: [PATCH 27/34] fix(help): align busy, failure, and compact action states --- apps/desktop/src/App.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7286ac8e8..7fe1e2d72 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -257,6 +257,7 @@ export function App() { const [jobResult, setJobResult] = useState(null); const [jobResultBootstrap, setJobResultBootstrap] = useState(null); const [jobError, setJobError] = useState(null); + const [analysisFailed, setAnalysisFailed] = useState(false); const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined); const [isStarting, setIsStarting] = useState(false); const [selectedBootstrap, setSelectedBootstrap] = useState(null); @@ -273,9 +274,9 @@ export function App() { const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running"; const helpPhase = resolveRehearsalHelpPhase({ hasLocalSource: selectedBootstrap !== null, - analysisInFlight: analysisInFlight || isStarting, + analysisInFlight: analysisInFlight || isStarting || isImporting, hasSong: jobResult !== null, - hasError: jobError !== null, + hasError: analysisFailed, }); const selectedRequest: AnalysisJobRequest = selectedBootstrap ? { @@ -298,9 +299,11 @@ export function App() { setJobResultBootstrap(activeAnalysisBootstrap); setActiveAnalysisBootstrap(null); setJobError(null); + setAnalysisFailed(false); } if (nextStatus.state === "failed") { setActiveAnalysisBootstrap(null); + setAnalysisFailed(true); setJobError(safeErrorDetail(nextStatus.error?.message, t("analysisCouldNotStart"))); } }, [activeAnalysisBootstrap, t]); @@ -370,6 +373,7 @@ export function App() { return; } const fallbackMessage = t("analysisCouldNotStart"); + setAnalysisFailed(true); setJobError(fallbackMessage); setJobStatus({ ...jobStatus, @@ -401,6 +405,7 @@ export function App() { } const submittedBootstrap = selectedBootstrap; + setAnalysisFailed(false); setJobError(null); setJobResult(null); setJobResultBootstrap(null); @@ -420,6 +425,7 @@ export function App() { } catch { setJobStatus(null); setActiveAnalysisBootstrap(null); + setAnalysisFailed(true); setJobError(t("analysisCouldNotStart")); } finally { setIsStarting(false); @@ -432,6 +438,7 @@ export function App() { setSelectionErrorSource(null); const selection = await selectLocalAudioSource(); if (selection.ok) { + setAnalysisFailed(false); setJobError(null); setJobResult(null); setJobResultBootstrap(null); @@ -495,11 +502,13 @@ export function App() { setJobResult(song); setJobResultBootstrap(null); setJobError(null); + setAnalysisFailed(false); setSelectedBootstrap(null); setActiveAnalysisBootstrap(null); setJobStatus(null); } catch (e) { if (!isUserCancellation(e)) { + setAnalysisFailed(false); setJobError(`${t("loadProjectFailedPrefix")}: ${safeErrorDetail(e, t("loadProjectFailedFallback"))}`); } } @@ -556,7 +565,7 @@ export function App() { return (
-
+
From b9ecf6ce8a037d967b2ec14d46f2d0df9ccf69e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:09:37 -0700 Subject: [PATCH 32/34] test(rehearsal): cover distinct compact help and stale save alert --- ....rehearsalHelpFailure.integration.test.tsx | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx index 45c9756d4..0a3b040c4 100644 --- a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx +++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx @@ -65,13 +65,15 @@ describe("App rehearsal-help failure recovery", () => { analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined); }); - it("exposes rehearsal help from compact navigation with the same accessible name", () => { + it("gives compact rehearsal help a distinct accessible name", () => { render(); - const compactHelp = screen.getByRole("button", { name: /open rehearsal help/i }); - const compactNav = compactHelp.closest("nav"); + const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i }); + const compactHelp = within(compactNav).getByRole("button", { + name: /^open rehearsal help compact view$/i, + }); - expect(compactNav?.getAttribute("aria-label")).toMatch(/compact rehearsal views/i); + expect(screen.getByRole("button", { name: /^open rehearsal help$/i })).toBeTruthy(); fireEvent.click(compactHelp); expect(screen.getByTestId("rehearsal-help-dialog")).toBeTruthy(); }); @@ -174,6 +176,40 @@ describe("App rehearsal-help failure recovery", () => { expect(within(helpDialog).queryByRole("button", { name: /show the rehearsal map/i })).toBeNull(); }); + it("clears a stale save failure when a different local source is selected", async () => { + analysisMocks.selectLocalAudioSource + .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-save-a", "save-failed-song.wav") }) + .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-save-b", "fresh-song.wav") }); + analysisMocks.startAnalysisJob.mockResolvedValueOnce({ + jobId: "job-help-save-failure", + state: "succeeded", + requestedAt: "2026-08-22T06:00:00.000Z", + updatedAt: "2026-08-22T06:00:01.000Z", + progressLabel: "Analysis ready", + progressStage: "ready", + progressPercent: 100, + cacheStatus: "disabled", + result: createDemoRehearsalSong(), + }); + analysisMocks.saveProject.mockRejectedValueOnce(new Error("Disk unavailable")); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/save-failed-song\.wav/i)).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull(); + }); + + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/disk unavailable/i)); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy()); + expect(screen.queryByRole("alert")).toBeNull(); + }); + it("forgets the previous analyzed song when a YouTube source is imported", async () => { analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true); analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({ From c0095ca8f4015133a7debb6fdac910d573a6ec79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:11:37 -0700 Subject: [PATCH 33/34] fix(rehearsal): clear stale save state and disambiguate help --- apps/desktop/src/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6d26ebf6c..90fdac6dd 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -408,6 +408,7 @@ export function App() { const submittedBootstrap = selectedBootstrap; setAnalysisFailed(false); setJobError(null); + setSaveError(null); setJobResult(null); setJobResultBootstrap(null); setJobStatus(null); @@ -441,6 +442,7 @@ export function App() { if (selection.ok) { setAnalysisFailed(false); setJobError(null); + setSaveError(null); setJobResult(null); setJobResultBootstrap(null); setJobStatus(null); @@ -478,6 +480,7 @@ export function App() { if (selection.ok) { setAnalysisFailed(false); setJobError(null); + setSaveError(null); setJobResult(null); setJobResultBootstrap(null); setJobStatus(null); @@ -702,7 +705,7 @@ export function App() { })}