Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,75 @@ describe("App", () => {
});
});

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(<App />);

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())
Expand Down
18 changes: 16 additions & 2 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,11 @@ export function App() {
const defaultRequest = useMemo(() => createDefaultAnalysisRequest(), []);
const [jobStatus, setJobStatus] = useState<AnalysisJobStatus | null>(null);
const [jobResult, setJobResult] = useState<RehearsalSong | null>(null);
const [jobResultBootstrap, setJobResultBootstrap] = useState<ProjectBootstrapSummary | null>(null);
const [jobError, setJobError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const [selectedBootstrap, setSelectedBootstrap] = useState<ProjectBootstrapSummary | null>(null);
const [activeAnalysisBootstrap, setActiveAnalysisBootstrap] = useState<ProjectBootstrapSummary | null>(null);
const [selectionError, setSelectionError] = useState<string | null>(null);
const [youtubeUrl, setYoutubeUrl] = useState("");
const [isImporting, setIsImporting] = useState(false);
Expand All @@ -198,9 +200,12 @@ export function App() {
setJobStatus(nextStatus);
if (nextStatus.state === "succeeded" && nextStatus.result) {
setJobResult(nextStatus.result);
setJobResultBootstrap(activeAnalysisBootstrap);
setActiveAnalysisBootstrap(null);
setJobError(null);
}
if (nextStatus.state === "failed") {
setActiveAnalysisBootstrap(null);
setJobError(nextStatus.error?.message ?? t("analysisCouldNotStart"));
}
} catch (error) {
Expand Down Expand Up @@ -232,25 +237,32 @@ export function App() {
}, ANALYSIS_POLL_INTERVAL_MS);

return () => window.clearTimeout(timer);
}, [jobStatus, t]);
}, [activeAnalysisBootstrap, jobStatus, t]);

/** Documented. */
const handleStartAnalysis = async () => {
const submittedBootstrap = selectedBootstrap;
setJobError(null);
setJobResult(null);
setJobResultBootstrap(null);
setJobStatus(null);
setActiveAnalysisBootstrap(submittedBootstrap);
setIsStarting(true);
try {
const nextStatus = await startAnalysisJob(selectedRequest);
setJobStatus(nextStatus);
if (nextStatus.state === "succeeded" && nextStatus.result) {
setJobResult(nextStatus.result);
setJobResultBootstrap(submittedBootstrap);
setActiveAnalysisBootstrap(null);
}
if (nextStatus.state === "failed") {
setActiveAnalysisBootstrap(null);
setJobError(nextStatus.error?.message ?? t("analysisCouldNotStart"));
}
} catch {
setJobStatus(null);
setActiveAnalysisBootstrap(null);
setJobError(t("analysisCouldNotStart"));
} finally {
setIsStarting(false);
Expand Down Expand Up @@ -306,8 +318,10 @@ export function App() {
try {
const song = await loadProject();
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
setSelectedBootstrap(null);
setActiveAnalysisBootstrap(null);
setJobStatus(null);
} catch (e) {
if (e instanceof Error && e.message !== "User cancelled") {
Expand Down Expand Up @@ -345,7 +359,7 @@ export function App() {
return <LoadingState />;
}
if (jobResult) {
return <Workspace song={jobResult} onSongUpdate={handleSongUpdate} />;
return <Workspace song={jobResult} sourceBootstrap={jobResultBootstrap} onSongUpdate={handleSongUpdate} />;
}
return <EmptyState />;
};
Expand Down
54 changes: 52 additions & 2 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it } from "vitest";
import { createDemoRehearsalSong, type ProjectBootstrapSummary } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
import { EmptyState, LoadingState } from "./WorkspaceStates";

const originalLanguage = navigator.language;
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;

function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
Expand All @@ -16,6 +18,15 @@ function setNavigatorLanguage(language: string) {
describe("Workspace", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
vi.restoreAllMocks();
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: originalCreateObjectUrl
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: originalRevokeObjectUrl
});
});

it("keeps the song-structure grid valid when a project has no sections", () => {
Expand Down Expand Up @@ -80,6 +91,45 @@ describe("Workspace", () => {
expect(screen.getByText(/2 notes mapped for rehearsal/i)).toBeTruthy();
});

it("exports a metadata-only handoff artifact from the workspace", async () => {
const song = createDemoRehearsalSong();
const sourceBootstrap: ProjectBootstrapSummary = {
projectId: "project-1",
sourceMode: "reference",
projectRoot: "/tmp/bandscope/projects/project-1",
cacheRoot: "/tmp/bandscope/cache/project-1",
tempRoot: "/tmp/bandscope/temp/project-1",
source: {
sourcePath: "/Users/test/Music/late-night-set.wav",
fileName: "late-night-set.wav",
extension: "wav",
fileSizeBytes: 1_024_000
}
};
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
});

render(<Workspace song={song} sourceBootstrap={sourceBootstrap} />);
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.artifactKind).toBe("bandscope.metadata-handoff");
expect(payload.sourceAssets[0].fileName).toBe("late-night-set.wav");
expect(JSON.stringify(payload)).not.toContain("/Users/test");
expect(click).toHaveBeenCalledTimes(1);
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:handoff");
});

it("localizes empty and loading state titles", () => {
setNavigatorLanguage("ko-KR");
render(<EmptyState />);
Expand Down
59 changes: 38 additions & 21 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { useState, useMemo, memo } from "react";
import type { RehearsalSong } from "@bandscope/shared-types";
import type { ProjectBootstrapSummary, RehearsalSong } from "@bandscope/shared-types";
import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, sanitizeFilename } from "../../lib/export";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
import { Download } from "lucide-react";

interface WorkspaceProps {
song: RehearsalSong;
sourceBootstrap?: ProjectBootstrapSummary | null;
onSongUpdate?: (song: RehearsalSong) => void;
}

Expand All @@ -24,6 +25,19 @@ function formatTimelineTime(totalSeconds: number): string {
return `${minutes}:${seconds}`;
}

/** Documented. */
function downloadTextFile(contents: string, type: string, filename: string): void {
const blob = new Blob([contents], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

type Translator = ReturnType<typeof createTranslator>;

/** Documented. */
Expand Down Expand Up @@ -74,7 +88,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
});

/** Documented. */
export function Workspace({ song, onSongUpdate }: WorkspaceProps) {
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState<string | null>(null);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);

Expand Down Expand Up @@ -108,29 +122,23 @@ export function Workspace({ song, onSongUpdate }: WorkspaceProps) {
/** Documented. */
const handleExportCueSheet = () => {
const csv = generateCueSheetCsv(song);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(song.title)}_cuesheet.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadTextFile(csv, "text/csv;charset=utf-8;", `${sanitizeFilename(song.title)}_cuesheet.csv`);
};

/** Documented. */
const handleExportChart = () => {
const json = generateChartSummaryJson(song);
const blob = new Blob([json], { type: "application/json;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(song.title)}_chart.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_chart.json`);
};

/** Documented. */
const handleExportHandoff = () => {
const json = generateMetadataHandoffJson(song, {
sourceBootstrap,
workspaceId: song.id,
workspaceTitle: song.title
});
downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_handoff.json`);
};

return (
Expand Down Expand Up @@ -164,6 +172,15 @@ export function Workspace({ song, onSongUpdate }: WorkspaceProps) {
<Download className="mr-2 size-4 text-slate-300" />
Export Chart (JSON)
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportHandoff}
className="min-h-10 border-teal-300/25 bg-teal-300/10 font-semibold text-teal-50 shadow-sm hover:bg-teal-300/20 hover:text-white"
>
<Download className="mr-2 size-4 text-teal-200" />
Export Handoff (JSON)
</Button>
</div>
</div>
</CardHeader>
Expand Down
Loading