diff --git a/.Jules/palette.md b/.Jules/palette.md index b6f5d5bf..c33867a4 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -29,3 +29,7 @@ ## 2024-07-01 - Testing components with focusable disabled button wrappers **Learning:** When native disabled buttons are wrapped in a focusable `span` to provide accessible tooltips, tests that previously found and clicked the `button` (by temporarily removing the `disabled` attribute) may fail or become overly complex. It is cleaner and more accurate to query the wrapper element (e.g. via its `title`) and fire events on it, reflecting the actual accessible DOM structure. **Action:** When testing UI components that wrap disabled buttons in a focusable span for accessibility (e.g., using a tooltip/title), use `screen.getByTitle(...)` to query the wrapper element for interactions like `fireEvent.click` rather than `screen.getByRole('button')`. + +## 2025-07-04 - Accessible Clear Button Unmounting Focus Management +**Learning:** When a conditionally rendered interactive element (like a 'Clear' button) triggers its own unmounting via a state change (e.g., clearing an input value), focus is lost and reset to the document body, breaking keyboard navigation. +**Action:** Always use a `useRef` to programmatically return focus to the associated primary element (e.g., the input field) prior to the state update when building self-unmounting clear buttons. diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index c039dfba..ebfbfd76 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1089,6 +1089,22 @@ describe("App", () => { }); }); + it("clears the YouTube URL and resets focus when the clear button is clicked", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + + // Using simple assertions rather than toHaveValue because RTL setup might not include custom matchers + expect((input as HTMLInputElement).value).toBe("https://youtube.com/watch?v=abc123DEF45"); + + const clearBtn = screen.getByRole("button", { name: /Clear YouTube URL/i }); + fireEvent.click(clearBtn); + + expect((input as HTMLInputElement).value).toBe(""); + expect(screen.queryByRole("button", { name: /Clear YouTube URL/i })).toBeNull(); + expect(document.activeElement).toBe(input); + }); + it("rejects non-http YouTube URL", async () => { render(); const input = screen.getByPlaceholderText(/YouTube URL.../i); diff --git a/apps/desktop/src/App.test.tsx.orig b/apps/desktop/src/App.test.tsx.orig new file mode 100644 index 00000000..c039dfba --- /dev/null +++ b/apps/desktop/src/App.test.tsx.orig @@ -0,0 +1,1436 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +const tauriInvoke = vi.fn(); +const mockLoadProject = vi.fn(); +const mockSaveProject = vi.fn(); +const mockSubscribeToAnalysisJobUpdates = vi.fn(); +let mockLocalAudioSelectionResult: Record | null = null; +let mockImportYoutubeUrlError = false; +let latestStatusSubscription: ((payload: Record) => void) | null = null; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: (command: string, args?: Record) => Promise; +}; + +const tauriWindow = window as TauriWindow; + +vi.mock("./lib/analysis", async (importActual) => { + const actual = await importActual(); + + return { + ...actual, + importYoutubeUrl: async (url: string) => { + if (mockImportYoutubeUrlError) { + throw new Error("Simulated component crash"); + } + return actual.importYoutubeUrl(url); + }, + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + }), + selectLocalAudioSource: async () => mockLocalAudioSelectionResult ?? actual.selectLocalAudioSource(), + subscribeToAnalysisJobUpdates: (...args: Parameters) => + mockSubscribeToAnalysisJobUpdates(...args), + loadProject: () => mockLoadProject(), + saveProject: (song: unknown) => mockSaveProject(song) + }; +}); + +function succeededResult() { + return { + jobId: "job-1", + state: "succeeded", + requestedAt: "2026-03-12T00:00:00.000Z", + updatedAt: "2026-03-12T00:00:01.000Z", + progressLabel: "Analysis ready", + result: { + id: "demo-song", + title: "Late Night Set", + sections: [ + { + id: "verse-1", + label: "verse", + groove: "Straight eighths with a late snare feel", + timeRange: { start: 10, end: 30 }, + confidence: { + level: "medium", + source: "model", + notes: "Double-check the pickup into the chorus." + }, + roles: [ + { + id: "bass-guitar", + name: "Bass Guitar", + roleType: "instrument", + harmony: { + chord: "C#m7", + functionLabel: "vi pedal anchor", + source: "model" + }, + cue: { kind: "transition", value: "Hold through the pickup before the downbeat." }, + range: { lowestNote: "C#2", highestNote: "E3" }, + confidence: { level: "medium", source: "model", notes: "Watch the slide into the turnaround." }, + rehearsalPriority: "high", + simplification: "Stay on roots if the chorus entrance gets muddy.", + setupNote: "Keep the attack short so the verse breathes.", + manualOverrides: [], + overlapWarnings: [ + "Density warning: competing with Keyboard Left Hand in low register." + ] + }, + { + id: "lead-vocal", + name: "Lead Vocal", + roleType: "vocal", + harmony: { + chord: "C#m7", + functionLabel: "vi melodic pull", + source: "model" + }, + cue: { kind: "lyric", value: "city lights" }, + range: { lowestNote: "G#3", highestNote: "C#5" }, + confidence: { level: "high", source: "user", notes: "Singer confirmed the pickup phrasing in rehearsal notes." }, + rehearsalPriority: "medium", + simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.", + setupNote: "Watch the breath before the last line of the verse.", + manualOverrides: [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ], + overlapWarnings: [] + } + ], + partGraph: [ + { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: ["bass-guitar"] } + ] + } + ], + exportSummary: { + format: "cue-sheet", + headline: "Start with verse entrances before the chorus lift.", + focusSections: ["verse"] + } + } + }; +} + +function bootstrapResponse(overrides: Record = {}) { + const source = { + sourcePath: "/Users/test/Music/late-night-set.wav", + fileName: "late-night-set.wav", + extension: "wav", + fileSizeBytes: 1024000 + }; + const { source: sourceOverride, ...restOverrides } = overrides; + + return { + projectId: "project-1", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/project-1", + cacheRoot: "/tmp/bandscope/cache/project-1", + tempRoot: "/tmp/bandscope/temp/project-1", + ...restOverrides, + source: { + ...source, + ...((sourceOverride as Record | undefined) ?? {}) + } + }; +} + +function jobStatusResponse(overrides: Record = {}) { + return { + jobId: "job-1", + state: "queued", + requestedAt: "2026-03-12T00:00:00.000Z", + updatedAt: "2026-03-12T00:00:00.000Z", + progressLabel: "Queued for analysis", + ...overrides + }; +} + +function failedJobStatus(jobId: string, message: string) { + return jobStatusResponse({ + jobId, + state: "failed", + error: { + code: "engine_unavailable", + message + } + }); +} + +describe("App", () => { + beforeEach(() => { + tauriInvoke.mockReset(); + mockLoadProject.mockReset(); + mockSaveProject.mockReset(); + mockSubscribeToAnalysisJobUpdates.mockReset(); + mockLocalAudioSelectionResult = null; + mockImportYoutubeUrlError = false; + latestStatusSubscription = null; + mockSubscribeToAnalysisJobUpdates.mockImplementation( + async (_jobId: string, onUpdate: (status: Record) => void) => { + latestStatusSubscription = onUpdate; + return () => { + latestStatusSubscription = null; + }; + } + ); + delete tauriWindow.__TAURI_INTERNALS__; + tauriWindow.__TAURI_INVOKE__ = tauriInvoke; + }); + + it("renders the rehearsal cockpit shell before analysis starts", () => { + render(); + + expect(screen.getByRole("img", { name: /BandScope circular equalizer mark/i })).toBeTruthy(); + expect(screen.getByText(/Your rehearsal map stays on this device/i)).toBeTruthy(); + expect(screen.getByRole("navigation", { name: /primary rehearsal views/i })).toBeTruthy(); + expect(screen.getByRole("heading", { name: /Workspace Home/i })).toBeTruthy(); + expect(screen.getByText(/SYNCED • LOCAL/i)).toBeTruthy(); + expect(screen.getByText(/Turn a song into a practical rehearsal view\./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /^Workspace$/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /^Import$/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /^Export$/i })).toBeTruthy(); + expect(screen.getByText(/^Tempo$/i)).toBeTruthy(); + expect(screen.getByText(/^Key$/i)).toBeTruthy(); + expect(screen.getByText(/Local-first/i)).toBeTruthy(); + expect(screen.getByText(/Project files stay local/i)).toBeTruthy(); + expect(screen.getByText(/YouTube only leaves the app when you choose import/i)).toBeTruthy(); + }); + + it("keeps source controls before the analysis summary", () => { + render(); + + const sourceControls = screen.getByLabelText("Source controls"); + const analysisSummary = screen.getByLabelText("Analysis summary"); + + expect(sourceControls.compareDocumentPosition(analysisSummary) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(sourceControls).toHaveTextContent(/Choose local audio/i); + expect(sourceControls).toHaveTextContent(/Import YouTube/i); + }); + + it("renders the loaded song as a dark rehearsal command board", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + expect(screen.getByText(/Roles & Harmony/i)).toBeTruthy(); + expect(screen.getByText(/Stems/i)).toBeTruthy(); + expect(screen.getByText(/Rehearsal Priorities/i)).toBeTruthy(); + expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy(); + }); + + it("renders a rehearsal song structure timeline from real section ranges", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy(); + }); + expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy(); + expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy(); + expect(screen.queryByText(/Mock-board/i)).toBeNull(); + const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); + expect(timelineRegion.className).toContain("overflow-x-auto"); + expect(timelineRegion.getAttribute("tabindex")).toBe("0"); + expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull(); + }); + + it("does not show unavailable analysis metrics as detected facts", 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(); + }); + + expect(screen.queryByText(/128 BPM/i)).toBeNull(); + expect(screen.queryByText(/E Major/i)).toBeNull(); + expect(screen.queryByText(/86%/i)).toBeNull(); + expect(screen.queryByText(/entry, dropout/i)).toBeNull(); + expect(screen.queryByText(/Preview-ready lanes/i)).toBeNull(); + expect(screen.getAllByText(/Pending/i).length).toBeGreaterThanOrEqual(2); + }); + + it("summarizes confidence from the lowest-confidence loaded section", async () => { + const loadedProject = succeededResult().result; + loadedProject.sections.push({ + ...loadedProject.sections[0], + id: "chorus-1", + label: "chorus", + confidence: { level: "high", source: "model", notes: "The chorus form is clear." } + }); + mockLoadProject.mockResolvedValueOnce(loadedProject); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByText(/^Medium$/i)).toBeTruthy(); + }); + expect(screen.getAllByText(/2 sections/i).length).toBeGreaterThan(0); + }); + + it("selects a local audio source and starts a local-audio analysis job", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-local-1", + 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: /start analysis/i })); + + await waitFor(() => { + expect(tauriInvoke).toHaveBeenNthCalledWith(2, "start_analysis_job", { + request: { + sourceKind: "local_audio", + projectId: "project-1", + sourceLabel: "late-night-set.wav", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + } + }); + }); + }); + + it("shows a safe file-intake error for unsupported local audio selection", async () => { + tauriInvoke.mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis.")); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + + await waitFor(() => { + expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy(); + }); + expect(screen.getByRole("alert").textContent).toMatch(/choose a wav, mp3, flac, or m4a file/i); + expect(screen.queryByText(/analysis failed during execution/i)).toBeNull(); + }); + + it("falls back to generic local-audio error copy when selection omits a message", async () => { + mockLocalAudioSelectionResult = { + ok: false, + error: { + code: "invalid_request", + message: "" + } + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + + await waitFor(() => { + expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy(); + }); + expect(screen.queryByText(/analysis failed during execution/i)).toBeNull(); + }); + + it("preserves safe file-read failure copy from the intake bridge", async () => { + tauriInvoke.mockRejectedValueOnce(new Error("Could not read the selected audio file.")); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + + await waitFor(() => { + expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy(); + }); + expect(screen.queryByText(/analysis failed during execution/i)).toBeNull(); + }); + + it("starts an analysis job and renders the returned rehearsal result", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-1", + 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: /start analysis/i })); + + await waitFor(() => { + expect(screen.getByText(/queued for analysis/i)).toBeTruthy(); + }); + expect(screen.getAllByRole("status").some((status) => /queued for analysis/i.test(status.textContent ?? ""))).toBe(true); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + + expect(screen.getAllByText(/Bass Guitar/i).length).toBeGreaterThan(0); + expect(tauriInvoke).toHaveBeenNthCalledWith(2, "start_analysis_job", { + request: { + sourceKind: "local_audio", + projectId: "project-1", + sourceLabel: "late-night-set.wav", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + } + }); + }); + + it("shows the engine stage label and accessible progress value while analysis runs", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-progress-1", + state: "running", + progressLabel: "Separating stems... (45%)", + progressStage: "separate", + progressPercent: 45, + cacheStatus: "miss" + })); + + 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.getByText(/separating stems/i)).toBeTruthy(); + }); + expect(screen.getByRole("progressbar", { name: /analysis progress/i })).toHaveAttribute( + "aria-valuenow", + "45" + ); + }); + + it("animates rendered progress toward the running job target", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-animated-progress", + state: "running", + progressLabel: undefined, + progressPercent: 2 + })) + .mockResolvedValue(jobStatusResponse({ + jobId: "job-animated-progress", + state: "running", + progressLabel: undefined, + progressPercent: 2 + })); + + 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.getByText(/running analysis/i)).toBeTruthy(); + }); + await waitFor(() => { + expect(screen.getByRole("progressbar", { name: /analysis progress/i })).toHaveAttribute( + "aria-valuenow", + "1" + ); + }); + await waitFor(() => { + expect(screen.getByRole("progressbar", { name: /analysis progress/i })).toHaveAttribute( + "aria-valuenow", + "2" + ); + }); + }); + + it("uses translated progress labels when status payloads omit a progress label", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-unlabeled-status", + state: "queued", + progressLabel: undefined + })); + + 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.getAllByRole("status").some((status) => /queued for analysis/i.test(status.textContent ?? ""))).toBe(true); + }); + await waitFor(() => { + expect(mockSubscribeToAnalysisJobUpdates).toHaveBeenCalledWith( + "job-unlabeled-status", + expect.any(Function) + ); + }); + + const completed = succeededResult(); + delete (completed as { progressLabel?: string }).progressLabel; + act(() => { + latestStatusSubscription?.(completed); + }); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + expect(screen.getAllByRole("status").some((status) => /analysis ready/i.test(status.textContent ?? ""))).toBe(true); + }); + + it("falls back to failed progress copy when a pushed status has no error details", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-unlabeled-failure", + state: "queued", + progressLabel: undefined + })); + + 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(mockSubscribeToAnalysisJobUpdates).toHaveBeenCalledWith( + "job-unlabeled-failure", + expect.any(Function) + ); + }); + + act(() => { + latestStatusSubscription?.(jobStatusResponse({ + jobId: "job-unlabeled-failure", + state: "failed", + progressLabel: undefined + })); + }); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/analysis could not start/i); + }); + expect(screen.getAllByRole("status").some((status) => /analysis failed during execution/i.test(status.textContent ?? ""))).toBe(true); + }); + + it("holds a terminal progress value immediately for pushed failed statuses", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-terminal-progress", + state: "queued", + progressLabel: undefined, + progressPercent: 10 + })); + + 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(mockSubscribeToAnalysisJobUpdates).toHaveBeenCalledWith( + "job-terminal-progress", + expect.any(Function) + ); + }); + + act(() => { + latestStatusSubscription?.(jobStatusResponse({ + jobId: "job-terminal-progress", + state: "failed", + progressLabel: undefined, + progressPercent: 100, + error: { + code: "engine_unavailable", + message: "Analysis failed after separation." + } + })); + }); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/analysis failed after separation/i); + }); + await waitFor(() => { + expect(screen.getByRole("progressbar", { name: /analysis progress/i })).toHaveAttribute( + "aria-valuenow", + "100" + ); + }); + }); + + it("cleans up a late status subscription when the running view unmounts first", async () => { + let resolveSubscription: ((cleanup: () => void) => void) | null = null; + let pushedUpdate: ((status: Record) => void) | null = null; + const cleanup = vi.fn(); + mockSubscribeToAnalysisJobUpdates.mockImplementation( + (_jobId: string, onUpdate: (status: Record) => void) => new Promise<() => void>((resolve) => { + pushedUpdate = onUpdate; + resolveSubscription = resolve; + }) + ); + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-late-subscription", + state: "queued", + progressLabel: undefined + })); + + const { unmount } = 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(mockSubscribeToAnalysisJobUpdates).toHaveBeenCalledWith( + "job-late-subscription", + expect.any(Function) + ); + }); + + unmount(); + act(() => { + pushedUpdate?.(succeededResult()); + }); + await act(async () => { + resolveSubscription?.(cleanup); + await Promise.resolve(); + }); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it("marks the active job failed when polling returns a malformed status", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-malformed-poll", + state: "running", + progressLabel: undefined + })) + .mockResolvedValueOnce({ jobId: "job-malformed-poll", state: "running" }); + + 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("alert").textContent).toMatch(/analysis could not start/i); + }); + }); + + it("ignores malformed poll results after a pushed update changes the active job", async () => { + let resolvePoll: ((value: unknown) => void) | null = null; + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-stale-invalid-poll", + state: "running", + progressLabel: undefined + })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolvePoll = resolve; + })); + + 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(tauriInvoke).toHaveBeenCalledTimes(3)); + + act(() => { + latestStatusSubscription?.(succeededResult()); + }); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + await act(async () => { + resolvePoll?.({ jobId: "job-stale-invalid-poll", state: "running" }); + await Promise.resolve(); + }); + + expect(screen.queryByText(/analysis could not start/i)).toBeNull(); + }); + + it("ignores transport poll failures after a pushed update changes the active job", async () => { + let rejectPoll: ((error: unknown) => void) | null = null; + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-stale-transport-poll", + state: "running", + progressLabel: undefined + })) + .mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectPoll = reject; + })); + + 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(tauriInvoke).toHaveBeenCalledTimes(3)); + + act(() => { + latestStatusSubscription?.(succeededResult()); + }); + await act(async () => { + rejectPoll?.(new Error("transport down")); + await Promise.resolve(); + }); + + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + expect(screen.queryByText(/analysis could not start/i)).toBeNull(); + }); + + it("applies pushed analysis status updates over the IPC event bridge", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-push-1", + state: "queued", + progressLabel: "Queued for analysis" + })); + + 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.getByText(/queued for analysis/i)).toBeTruthy(); + }); + await waitFor(() => { + expect(mockSubscribeToAnalysisJobUpdates).toHaveBeenCalledWith( + "job-push-1", + expect.any(Function) + ); + }); + + act(() => { + latestStatusSubscription?.(jobStatusResponse({ + jobId: "job-push-1", + state: "running", + progressLabel: "Separating stems... (45%)", + progressStage: "separate", + progressPercent: 45 + })); + }); + await waitFor(() => { + expect(screen.getByText(/separating stems/i)).toBeTruthy(); + }); + + act(() => { + latestStatusSubscription?.(succeededResult()); + }); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + }); + + it("keeps handoff metadata tied to the source that produced the current result", 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()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-1", + state: "queued", + progressLabel: "Queued for analysis" + })) + .mockResolvedValueOnce(succeededResult()) + .mockResolvedValueOnce( + bootstrapResponse({ + projectId: "project-2", + source: { + sourcePath: "/Users/test/Music/next-song.wav", + fileName: "next-song.wav", + fileSizeBytes: 2048000 + } + }) + ); + + try { + 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("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + + 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"); + } finally { + click.mockRestore(); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: originalCreateObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: originalRevokeObjectUrl + }); + } + }); + + it("shows a safe failed status when the job poll returns an error", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-2", + state: "running", + progressLabel: "Running analysis" + })) + .mockResolvedValueOnce(failedJobStatus("job-2", "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.getByText(/analysis engine is unavailable/i)).toBeTruthy(); + }); + expect(screen.getByRole("alert").textContent).toMatch(/analysis engine is unavailable/i); + }); + + it("falls back to a generic failure message when the engine omits details", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-3", + state: "running", + progressLabel: "Running analysis" + })) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-3", + state: "failed", + error: { code: "engine_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.getByText(/analysis could not start/i)).toBeTruthy(); + }); + }); + + it("keeps polling the active job when one polling request rejects", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-4", + state: "running", + progressLabel: "Running analysis" + })) + .mockRejectedValueOnce(new Error("transport down")) + .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(tauriInvoke).toHaveBeenCalledTimes(3); + }); + expect(screen.queryByText(/analysis could not start/i)).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("button", { name: /start analysis/i }).hasAttribute("disabled")).toBe(true); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + }); + + it("shows a generic failure when starting the job rejects", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockRejectedValueOnce(new Error("invoke failed")); + + 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.getByText(/analysis could not start/i)).toBeTruthy(); + }); + }); + + it("shows the direct failure message when start returns a failed job", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(failedJobStatus("job-5", "Analysis queue is full. Please wait for a running job to finish.")); + + 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.getByText(/analysis queue is full/i)).toBeTruthy(); + }); + }); + + it("falls back to generic text when start returns a failed job without details", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-6", + state: "failed" + })); + + 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.getAllByText(/analysis could not start/i).length).toBeGreaterThan(0); + }); + }); + + it("renders the result immediately when start returns a succeeded job", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .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.getByText(/Section Roadmap/i)).toBeTruthy(); + }); + expect(tauriInvoke).toHaveBeenCalledTimes(2); // select + start + }); + + it("imports a YouTube URL successfully", async () => { + tauriInvoke.mockResolvedValueOnce({ + projectId: "project-yt-1", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/project-yt-1", + cacheRoot: "/tmp/bandscope/cache/project-yt-1", + tempRoot: "/tmp/bandscope/temp/project-yt-1", + source: { + sourcePath: "/tmp/bandscope/temp/project-yt-1/youtube.wav", + fileName: "youtube.wav", + extension: "wav", + fileSizeBytes: 5000000 + } + }); + + render(); + + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + + const button = screen.getByRole("button", { name: /Import YouTube/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(tauriInvoke).toHaveBeenCalledWith("import_youtube_url", { + url: "https://youtube.com/watch?v=abc123DEF45" + }); + expect(screen.getByText(/youtube\.wav/i)).toBeTruthy(); + }); + }); + + it("handles YouTube import failure with a message", async () => { + tauriInvoke.mockRejectedValueOnce(new Error("This video is age restricted.")); + + render(); + + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=def456GHI78" } }); + + const button = screen.getByRole("button", { name: /Import YouTube/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/This video is age restricted/i)).toBeTruthy(); + }); + }); + + it("handles generic exception during YouTube import", async () => { + tauriInvoke.mockRejectedValueOnce(new Error("Network Error")); + + render(); + + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=ghi789JKL01" } }); + + const button = screen.getByRole("button", { name: /Import YouTube/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/Network Error/i)).toBeTruthy(); + }); + }); + + it("rejects empty YouTube URL", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: " " } }); + const button = screen.getByRole("button", { name: /Import YouTube/i }); + // Button is disabled if youtubeUrl is empty, but we simulate enabling it for coverage + // or we can test that the error is set when it somehow triggers, but actually it's disabled. + // Wait, the button is disabled if `!youtubeUrl`. `youtubeUrl` is " ", so button is NOT disabled! + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + }); + + it("rejects malformed YouTube URL", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "not-a-url" } }); + const button = screen.getByRole("button", { name: /Import YouTube/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + }); + + it("rejects non-http YouTube URL", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "ftp://youtube.com/watch?v=abc123DEF45" } }); + const button = screen.getByRole("button", { name: /Import YouTube/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + }); + + it("rejects non-allowlisted YouTube URL intake before invoking the bridge", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://example.com/watch?v=abc123DEF45" } }); + + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + expect(tauriInvoke).not.toHaveBeenCalled(); + }); + + it("rejects downgraded YouTube URL intake before invoking the bridge", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "http://youtube.com/watch?v=abc123DEF45" } }); + + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + expect(tauriInvoke).not.toHaveBeenCalled(); + }); + + it("rejects duplicate YouTube video parameters even when one is blank", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45&v=" } }); + + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + expect(tauriInvoke).not.toHaveBeenCalled(); + }); + + + it("loads a project and updates the UI", 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(); + }); + expect(mockLoadProject).toHaveBeenCalledTimes(1); + }); + + it("handles loading a project failure safely", async () => { + mockLoadProject.mockRejectedValueOnce(new Error("Corrupt file")); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to load project: Corrupt file/i)).toBeTruthy(); + }); + }); + + it("ignores cancellation when loading a project", async () => { + mockLoadProject.mockRejectedValueOnce(new Error("User cancelled")); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + // Should not show error, should remain in empty state + await waitFor(() => { + expect(mockLoadProject).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(screen.queryByText(/Failed to load project/i)).toBeNull(); + }); + }); + + it("handles loading a project failure with string error gracefully", async () => { + mockLoadProject.mockRejectedValueOnce("Unknown load error"); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to load project: Unknown load error/i)).toBeTruthy(); + }); + }); + + it("redacts local paths from project load failures", async () => { + mockLoadProject.mockRejectedValueOnce(new Error("Could not open C:\\Users\\Seongho\\private-set.band\nstack detail")); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to load project: Could not open \[local path\]/i)).toBeTruthy(); + }); + const alertText = screen.getByRole("alert").textContent ?? ""; + expect(alertText).not.toMatch(/C:\\Users\\Seongho/i); + expect(alertText).not.toMatch(/stack detail/i); + }); + + it("truncates oversized project load failure details", async () => { + const longDetail = "A".repeat(260); + mockLoadProject.mockRejectedValueOnce(new Error(longDetail)); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + const truncatedDetail = `${longDetail.slice(0, 217)}...`; + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain(`Failed to load project: ${truncatedDetail}`); + }); + }); + + it("ignores cancellation when loading a project with string error", async () => { + mockLoadProject.mockRejectedValueOnce("User cancelled"); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(mockLoadProject).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(screen.queryByText(/Failed to load project/i)).toBeNull(); + }); + }); + + it("saves a project successfully", 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.mockResolvedValueOnce(undefined); + + // Now click save + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(mockSaveProject).toHaveBeenCalledWith(succeededResult().result); + }); + }); + + it("handles saving a project failure gracefully", 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(); + }); + }); + + it("ignores cancellation when saving a project with Error object", 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("User cancelled")); + + // Now click save + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(mockSaveProject).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(screen.queryByText(/Failed to save project/i)).toBeNull(); + }); + }); + + it("handles saving a project failure with string error gracefully", 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("Disk full"); + + // Now click save + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to save project: Disk full/i)).toBeTruthy(); + }); + }); + + it("redacts links, local paths, and secret assignments from project save failures", 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(); + }); + + mockSaveProject.mockRejectedValueOnce( + new Error("Upload failed for https://example.com/report?token=abc access_token=secret123 at /Users/seongho/private.band") + ); + + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + let alertText = ""; + await waitFor(() => { + alertText = screen.getByRole("alert").textContent ?? ""; + expect(alertText).toMatch(/Failed to save project:/i); + }); + expect(alertText).toMatch(/\[link\]/i); + expect(alertText).toMatch(/access_token=\[redacted\]/i); + expect(alertText).toMatch(/\[local path\]/i); + expect(alertText).not.toMatch(/example\.com|secret123|\/Users\/seongho/i); + }); + + it("ignores cancellation when saving a project with string error", 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("User cancelled"); + + // Now click save + fireEvent.click(screen.getByRole("button", { name: /save project/i })); + + await waitFor(() => { + expect(mockSaveProject).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(screen.queryByText(/Failed to save project/i)).toBeNull(); + }); + }); + + it("handles song update from workspace", 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(); + }); + + // Mock prompt to simulate user entering a new chord + const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7"); + + // Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit) + fireEvent.click(screen.getAllByText("C#m7", { selector: 'button' })[0]); + + // Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated) + await waitFor(() => { + expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0); + }); + + promptSpy.mockRestore(); + }); + + it("handles YouTube import failure with a missing message falling back to generic", async () => { + tauriInvoke.mockRejectedValueOnce(new Error("")); + + render(); + + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=def456GHI78" } }); + + const button = screen.getByRole("button", { name: /Import YouTube/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + }); + + it("does nothing when Save Project is clicked but there is no jobResult", () => { + render(); + const saveSpan = screen.getByTitle("Analyze a song to enable saving"); + fireEvent.click(saveSpan); + expect(mockSaveProject).not.toHaveBeenCalled(); + }); + + it("handles exception thrown by importYoutubeUrl itself", async () => { + mockImportYoutubeUrlError = true; + + render(); + + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=crashing123" } }); + + const button = screen.getByRole("button", { name: /Import YouTube/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + }); + + + it("renders disabled Settings and Help buttons as focusable spans for accessibility", () => { + render(); + const settingsSpan = screen.getByTitle("Settings coming soon"); + expect(settingsSpan).toHaveAttribute("tabIndex", "0"); + expect(settingsSpan).toHaveAttribute("role", "button"); + }); +}); diff --git a/apps/desktop/src/App.test.tsx.rej b/apps/desktop/src/App.test.tsx.rej new file mode 100644 index 00000000..f324cdde --- /dev/null +++ b/apps/desktop/src/App.test.tsx.rej @@ -0,0 +1,24 @@ +--- apps/desktop/src/App.test.tsx ++++ apps/desktop/src/App.test.tsx +@@ -1071,6 +1071,21 @@ + }); + }); + ++ it("clears the YouTube URL and resets focus when the clear button is clicked", async () => { ++ render(); ++ const input = screen.getByPlaceholderText(/YouTube URL.../i); ++ fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); ++ ++ expect(input).toHaveValue("https://youtube.com/watch?v=abc123DEF45"); ++ ++ const clearBtn = screen.getByRole("button", { name: /Clear YouTube URL/i }); ++ fireEvent.click(clearBtn); ++ ++ expect(input).toHaveValue(""); ++ expect(screen.queryByRole("button", { name: /Clear YouTube URL/i })).toBeNull(); ++ expect(document.activeElement).toBe(input); ++ }); ++ + it("shows validation error for completely invalid URL", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 24f5fb09..2e0c7946 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -18,6 +18,7 @@ import { Sparkles, Star, Upload, + X, Users, Wand2, Loader2, @@ -56,6 +57,7 @@ const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_ const NAV_ITEMS = [ { label: "Workspace", icon: Home, active: true }, { label: "Import", icon: Upload, active: false }, + { label: "Export", icon: Save, active: false }, { label: "Sections", icon: ListMusic, active: false }, { label: "Roles", icon: Users, active: false }, @@ -228,6 +230,7 @@ export function App() { const [youtubeUrl, setYoutubeUrl] = useState(""); const [isImporting, setIsImporting] = useState(false); const activeJobIdRef = useRef(null); + const youtubeInputRef = useRef(null); const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running"; const selectedRequest: AnalysisJobRequest = selectedBootstrap @@ -389,6 +392,13 @@ export function App() { setJobStatus(null); }; + /** Documented. */ + const handleClearYoutubeUrl = () => { + setYoutubeUrl(""); + setSelectionError(null); + youtubeInputRef.current?.focus(); + }; + /** Documented. */ const handleImportYoutube = async () => { setSelectionError(null); @@ -601,15 +611,29 @@ export function App() { - setYoutubeUrl(e.target.value)} - disabled={analysisInFlight || isStarting || isImporting} - className="h-10 flex-1 border-0 bg-transparent text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300" - aria-label="YouTube URL" - /> + + setYoutubeUrl(e.target.value)} + disabled={analysisInFlight || isStarting || isImporting} + className="h-10 w-full border-0 bg-transparent pr-10 text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300" + aria-label="YouTube URL" + ref={youtubeInputRef} + /> + {youtubeUrl && ( + + + + )} + { ++ render(); ++ const input = screen.getByPlaceholderText(/YouTube URL.../i); ++ fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); ++ ++ expect(input).toHaveValue("https://youtube.com/watch?v=abc123DEF45"); ++ ++ const clearBtn = screen.getByRole("button", { name: /Clear YouTube URL/i }); ++ fireEvent.click(clearBtn); ++ ++ expect(input).toHaveValue(""); ++ expect(screen.queryByRole("button", { name: /Clear YouTube URL/i })).toBeNull(); ++ expect(document.activeElement).toBe(input); ++ }); ++ + it("shows validation error for completely invalid URL", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i);