From 98febd0bc65d8c36ec5815b30e6110ad79d81ffd Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Mon, 29 Jun 2026 05:21:09 +0000
Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20[=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=EA=B0=9C=EC=84=A0]=20Workspace=20=EC=BB=B4=ED=8F=AC=EB=84=8C?=
=?UTF-8?q?=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/features/workspace/Workspace.test.tsx | 359 ++++++++++++++++--
1 file changed, 331 insertions(+), 28 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 39ffa05d..de2833d8 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -1,5 +1,9 @@
import { fireEvent, render, screen } from "@testing-library/react";
-import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types";
+import {
+ createDemoRehearsalSong,
+ type ProjectBootstrapSummary,
+ type RehearsalSong,
+} from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
import { EmptyState, LoadingState } from "./WorkspaceStates";
@@ -12,7 +16,7 @@ const originalRevokeObjectUrl = URL.revokeObjectURL;
function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
configurable: true,
- value: language
+ value: language,
});
}
@@ -22,11 +26,11 @@ describe("Workspace", () => {
vi.restoreAllMocks();
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
- value: originalCreateObjectUrl
+ value: originalCreateObjectUrl,
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
- value: originalRevokeObjectUrl
+ value: originalRevokeObjectUrl,
});
});
@@ -47,7 +51,7 @@ describe("Workspace", () => {
const song = createDemoRehearsalSong();
song.sections[0].timeRange = {
start: Number.NaN,
- end: Number.POSITIVE_INFINITY
+ end: Number.POSITIVE_INFINITY,
};
render();
@@ -60,13 +64,15 @@ describe("Workspace", () => {
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
id: "low-end",
- name: "Bass Guitar"
+ name: "Bass Guitar",
};
render();
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement;
+ const transcribeButton = screen.getByRole("button", {
+ name: "Transcribe Bass",
+ }) as HTMLButtonElement;
expect(transcribeButton.disabled).toBe(false);
expect(transcribeButton.title).toBe("Transcribe part");
});
@@ -78,14 +84,16 @@ describe("Workspace", () => {
name: "Bass Guitar",
transcription: [
{ pitch: "E2", onset: 0, offset: 0.75, velocity: 0.74 },
- { pitch: "G2", onset: 0.9, offset: 1.25, velocity: 0.68 }
- ]
+ { pitch: "G2", onset: 0.9, offset: 1.25, velocity: 0.68 },
+ ],
};
render();
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i });
+ const grooveMap = screen.getByRole("region", {
+ name: /bass transcription groove map/i,
+ });
expect(grooveMap.className).toContain("bg-slate-950");
expect(screen.getByText("E2")).toBeTruthy();
expect(screen.getByText("G2")).toBeTruthy();
@@ -106,7 +114,9 @@ describe("Workspace", () => {
expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy();
expect(screen.getByText(/whole step lower/i)).toBeTruthy();
- expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy();
+ expect(
+ screen.getByText(/Lock the bass entrance against the pickup/i),
+ ).toBeTruthy();
expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy();
});
@@ -116,11 +126,11 @@ describe("Workspace", () => {
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
harmonicExplanation: " ",
- transpositionPlan: ""
+ transpositionPlan: "",
};
song.collaboration = {
syncMode: "local_only",
- syncNote: "Local-only draft"
+ syncNote: "Local-only draft",
} as RehearsalSong["collaboration"];
render();
@@ -132,7 +142,10 @@ describe("Workspace", () => {
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
expect(screen.getByText("vi pedal anchor")).toBeTruthy();
- expect(screen.getAllByText("Stay on roots if the chorus entrance gets muddy.").length).toBeGreaterThan(0);
+ expect(
+ screen.getAllByText("Stay on roots if the chorus entrance gets muddy.")
+ .length,
+ ).toBeGreaterThan(0);
});
it("exports a metadata-only handoff artifact from the workspace", async () => {
@@ -147,19 +160,21 @@ describe("Workspace", () => {
sourcePath: "/Users/test/Music/late-night-set.wav",
fileName: "late-night-set.wav",
extension: "wav",
- fileSizeBytes: 1_024_000
- }
+ fileSizeBytes: 1_024_000,
+ },
};
const createObjectUrl = vi.fn(() => "blob:handoff");
const revokeObjectUrl = vi.fn();
- const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ const click = vi
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => undefined);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
- value: createObjectUrl
+ value: createObjectUrl,
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
- value: revokeObjectUrl
+ value: revokeObjectUrl,
});
render();
@@ -177,18 +192,20 @@ describe("Workspace", () => {
it("exports metadata-only handoff when source bootstrap is invalid", async () => {
const song = createDemoRehearsalSong();
const invalidSourceBootstrap = {
- projectId: "project-1"
+ projectId: "project-1",
} as ProjectBootstrapSummary;
const createObjectUrl = vi.fn(() => "blob:handoff");
const revokeObjectUrl = vi.fn();
- const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ const click = vi
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => undefined);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
- value: createObjectUrl
+ value: createObjectUrl,
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
- value: revokeObjectUrl
+ value: revokeObjectUrl,
});
render();
@@ -205,11 +222,13 @@ describe("Workspace", () => {
it("validates source bootstrap before generating metadata handoff", () => {
const song = createDemoRehearsalSong();
const invalidSourceBootstrap = {
- projectId: "project-1"
+ projectId: "project-1",
} as ProjectBootstrapSummary;
expect(() => {
- generateMetadataHandoffJson(song, { sourceBootstrap: invalidSourceBootstrap });
+ generateMetadataHandoffJson(song, {
+ sourceBootstrap: invalidSourceBootstrap,
+ });
}).toThrow("sourceMode");
});
@@ -218,8 +237,12 @@ describe("Workspace", () => {
render();
render();
- expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy();
- expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { name: "분석 준비 완료" }),
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { name: "오디오 분석 중" }),
+ ).toBeTruthy();
});
it("localizes workspace navigation and rehearsal labels", () => {
@@ -227,7 +250,7 @@ describe("Workspace", () => {
const song = createDemoRehearsalSong();
song.exportSummary = {
...song.exportSummary,
- headline: ""
+ headline: "",
};
render();
@@ -240,4 +263,284 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});
+
+ it("exports a cue sheet from the workspace", async () => {
+ const song = createDemoRehearsalSong();
+ const createObjectUrl = vi.fn(() => "blob:cuesheet");
+ 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();
+ fireEvent.click(screen.getByRole("button", { name: /export cue sheet/i }));
+
+ const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
+ const text = await blob.text();
+ expect(text).toContain("Section,Groove,Role,Harmony");
+ expect(click).toHaveBeenCalledTimes(1);
+ expect(revokeObjectUrl).toHaveBeenCalledWith("blob:cuesheet");
+ });
+
+ it("exports a chart summary from the workspace", async () => {
+ const song = createDemoRehearsalSong();
+ const createObjectUrl = vi.fn(() => "blob:chart");
+ 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();
+ fireEvent.click(screen.getByRole("button", { name: /export chart/i }));
+
+ const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
+ const text = await blob.text();
+ const payload = JSON.parse(text);
+ expect(payload.title).toBe(song.title);
+ expect(click).toHaveBeenCalledTimes(1);
+ expect(revokeObjectUrl).toHaveBeenCalledWith("blob:chart");
+ });
+
+ it("handles rendering active role comments correctly", () => {
+ const song = createDemoRehearsalSong();
+
+ song.collaboration = {
+ ...song.collaboration,
+ comments: [
+ {
+ id: "comment-1",
+ author: "John Doe",
+ body: "Need more dynamics here",
+ status: "open",
+ createdAt: new Date().toISOString(),
+ roleId: song.sections[0]!.roles[0]!.id,
+ },
+ ],
+ } as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ render();
+ // Just click all the tabs
+ screen.getAllByRole("tab").forEach((tab) => fireEvent.click(tab));
+ });
+
+ it("handles parseProjectBootstrapSummary failure safely", () => {
+ const song = createDemoRehearsalSong();
+
+ // Test the safeProjectBootstrapSummary try/catch block by giving a bad sourceBootstrap
+ const badBootstrap = { invalid: "bootstrap" } as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ render();
+ });
+
+ it("handles blank text correctly", () => {
+ // testing nonBlankText undefined path inside Workspace
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ harmonicExplanation: " ",
+ harmony: {
+ functionLabel: " ",
+ } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
+ };
+ render();
+ });
+
+ it("handles null project bootstrap gracefully", () => {
+ const song = createDemoRehearsalSong();
+ render(
+ ,
+ );
+ });
+
+ it("handles song without focusSections and blank label correctly", () => {
+ const song = createDemoRehearsalSong();
+ song.exportSummary = { headline: "test" } as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ song.sections[0]!.label = " "; // whitespace label
+ render();
+ expect(screen.getAllByText(/first pass/i)).toBeTruthy();
+ });
+
+ it("handles empty role transcription title correctly when canTranscribeBass is false and role has no name", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ id: "another-role-1",
+ name: " ", // blank name falls back to role.id
+ harmony: {
+ chord: "C",
+ originalChord: "C",
+ },
+ };
+ render();
+ // Select the tab, which should fallback to role.id 'another-role-1' due to blank name
+ const tabs = screen.getAllByRole("tab");
+ fireEvent.click(tabs[0]);
+ });
+
+ it("handles empty collaboration planning state", () => {
+ const song = createDemoRehearsalSong();
+ song.collaboration = {
+ assignments: [],
+ comments: [],
+ approvals: [],
+ } as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ render();
+ // Check if collaboration empty message exists
+ expect(screen.getByText(/0 Assignments/i)).toBeTruthy();
+ expect(screen.getByText(/0 Comments/i)).toBeTruthy();
+ expect(screen.getByText(/0 Approvals/i)).toBeTruthy();
+ });
+
+ it("covers safeProjectBootstrapSummary (!value) branch", () => {
+ // We need to render the component with sourceBootstrap = null.
+ // And to cover the catch branch, we previously used a bad bootstrap.
+ // To cover the focusSections branch completely, we need one where exportSummary.focusSections exists but is empty? No, it's || song.sections[0]?.label || "first pass"
+ const song = createDemoRehearsalSong();
+
+ // We want song.exportSummary.focusSections empty and song.sections[0].label undefined
+ song.exportSummary = { focusSections: [] } as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ song.sections[0]!.label = undefined as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ // Also we want activeRoleDetails?.name undefined to hit activeRoleDetails?.name ?? "This role" in button title
+ song.sections[0]!.roles[0]!.name = undefined as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ render();
+ const tabs = screen.getAllByRole("tab");
+ fireEvent.click(tabs[0]);
+ });
+
+ it("covers specific active role fallback strings", () => {
+ const song = createDemoRehearsalSong();
+
+ // We want activeRoleDetails?.name ?? activeRole on line 312:
+ // So name needs to be missing, and it will use activeRole (which is the role ID).
+ song.sections[0]!.roles[0]!.name = undefined as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ render();
+ const tabs = screen.getAllByRole("tab");
+ fireEvent.click(tabs[0]);
+ });
+
+ it("covers more edge case branches safely", () => {
+ const song = createDemoRehearsalSong();
+
+ // Test the activeRoleDetails?.name ?? activeRole on line 312 and 320
+ // If activeRoleDetails is defined but name is empty or undefined, it should fallback
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ name: undefined as any, // eslint-disable-line @typescript-eslint/no-explicit-any
+ };
+
+ // We already have a test for sourceBootstrap = null. The branch !value is hit.
+ // What if value is an empty object? It doesn't hit !value.
+
+ render();
+ const tabs = screen.getAllByRole("tab");
+ fireEvent.click(tabs[0]); // activeRole becomes the first role ID
+ });
+
+ it("covers final edge case fallbacks", () => {
+ const song = createDemoRehearsalSong();
+
+ // Line 277: collaboration empty array check
+ song.collaboration = {
+ assignments: [],
+ comments: [],
+ approvals: [],
+ } as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ // Line 312 and 320: activeRoleDetails?.name ?? "This role" when name is undefined
+ // For this to happen, activeRoleDetails must exist, but name must be undefined.
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ name: undefined as any, // eslint-disable-line @typescript-eslint/no-explicit-any
+ };
+
+ // Also, canTranscribeBass is based on name. If name is undefined, canTranscribeBass is false.
+ // So line 320 will hit: `${activeRoleDetails?.name ?? "This role"} transcription is coming soon...`
+ // And since name is undefined, it uses "This role".
+
+ render();
+ const tabs = screen.getAllByRole("tab");
+ fireEvent.click(tabs[0]);
+ });
+
+ it("covers safeProjectBootstrapSummary (!value) explicitly with empty string", () => {
+ const song = createDemoRehearsalSong();
+ render(
+ ,
+ );
+ });
+
+ it("covers empty role details completely", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles = []; // clear all roles
+ render();
+ // Should render fine but without role tabs
+ });
+
+ it("covers safeProjectBootstrapSummary (!value) with undefined explicitly", () => {
+ const song = createDemoRehearsalSong();
+ render();
+ });
+
+ it("covers specific active role fallback strings again", () => {
+ const song = createDemoRehearsalSong();
+ // Instead of activeRoleDetails being defined but name missing,
+ // What if activeRoleDetails itself is undefined somehow?
+ // We can do this by setting activeRole to a non-existent role, or avoiding roles.
+ song.sections[0]!.roles = [];
+ render();
+ // If no roles, there are no tabs to click...
+ });
+
+ it("covers specific active role fallback strings again 2", () => {
+ const song = createDemoRehearsalSong();
+
+ // We want activeRoleDetails?.name ?? activeRole on line 312
+ // and activeRoleDetails?.name ?? "This role" on line 320.
+ // Also covers !map.has(role.id) by having duplicate roles.
+
+ // Create duplicate roles to hit !map.has(role.id)
+ const duplicateRole = {
+ ...song.sections[0]!.roles[0]!,
+ name: undefined as any, // eslint-disable-line @typescript-eslint/no-explicit-any
+ };
+ song.sections[0]!.roles.push(duplicateRole);
+
+ // And for line 277: collaboration comments/approvals empty
+ song.collaboration = {
+ assignments: [],
+ comments: [],
+ approvals: [],
+ } as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+
+ render();
+ const tabs = screen.getAllByRole("tab");
+ fireEvent.click(tabs[0]);
+ });
});