-
Notifications
You must be signed in to change notification settings - Fork 1
feat(workspace): name the selected part's first-pass take #1151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
seonghobae
wants to merge
1
commit into
feat/workspace-selected-part-entrance-cue
from
feat/workspace-selected-part-first-pass
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
78 changes: 78 additions & 0 deletions
78
apps/desktop/src/features/workspace/Workspace.first-pass.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it } from "vitest"; | ||
| import { Workspace } from "./Workspace"; | ||
|
|
||
| const originalLanguage = navigator.language; | ||
|
|
||
| function setNavigatorLanguage(language: string) { | ||
| Object.defineProperty(navigator, "language", { | ||
| configurable: true, | ||
| value: language | ||
| }); | ||
| } | ||
|
|
||
| describe("Workspace selected-part first-pass take", () => { | ||
| afterEach(() => { | ||
| setNavigatorLanguage(originalLanguage); | ||
| }); | ||
|
|
||
| it("hides the first-pass take until a named part is selected", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
|
|
||
| expect(screen.queryByTestId("selected-part-first-pass")).toBeNull(); | ||
| }); | ||
|
|
||
| it("names the selected bass part's simpler take as the first pass", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); | ||
|
|
||
| const callout = screen.getByTestId("selected-part-first-pass"); | ||
| expect(callout).toHaveTextContent("Tonight's first-pass take"); | ||
| expect(callout).toHaveTextContent( | ||
| "First pass for Bass Guitar in verse: Stay on roots if the chorus entrance gets muddy. Play that simpler take before adding the rest." | ||
| ); | ||
| }); | ||
|
|
||
| it("names the selected vocal part's simpler take as the first pass", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); | ||
|
|
||
| expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent( | ||
| "First pass for Lead Vocal in verse: Keep the sustained note centered; skip the ad-lib on the first pass. Play that simpler take before adding the rest." | ||
| ); | ||
| }); | ||
|
|
||
| it("keeps Korean copy particle-safe for a Latin role name", () => { | ||
| setNavigatorLanguage("ko-KR"); | ||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); | ||
|
|
||
| const callout = screen.getByTestId("selected-part-first-pass"); | ||
| expect(callout).toHaveTextContent("오늘 이 파트의 첫 간소화"); | ||
| expect(callout).toHaveTextContent("Bass Guitar 파트"); | ||
| expect(callout).not.toHaveTextContent("Bass Guitar으로"); | ||
| expect(callout).toHaveTextContent( | ||
| "verse에서 Bass Guitar 파트의 첫 패스: Stay on roots if the chorus entrance gets muddy. 나머지를 더하기 전에 그 간소화된 버전으로 연습하세요." | ||
| ); | ||
| }); | ||
|
|
||
| it("tells the player to confirm a missing first-pass take instead of hiding the next action", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| const song = createDemoRehearsalSong(); | ||
| song.sections[0]!.roles[0] = { | ||
| ...song.sections[0]!.roles[0]!, | ||
| simplification: "none" | ||
| }; | ||
|
|
||
| render(<Workspace song={song} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); | ||
|
|
||
| expect(screen.getByTestId("selected-part-first-pass")).toHaveTextContent( | ||
| "This part still needs a trusted first-pass take. Confirm the simpler version before the first run." | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
212 changes: 212 additions & 0 deletions
212
apps/desktop/src/features/workspace/firstPassSimplification.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| import { createDemoRehearsalSong, type RehearsalRole, type RehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { fillFirstPassCopy, firstPassSimplification } from "./firstPassSimplification"; | ||
|
|
||
| /** Return the demo song with one role replaced in every section it appears. */ | ||
| function withRolePatch( | ||
| song: RehearsalSong, | ||
| roleId: string, | ||
| patch: (role: RehearsalRole) => RehearsalRole | ||
| ): RehearsalSong { | ||
| return { | ||
| ...song, | ||
| sections: song.sections.map((section) => ({ | ||
| ...section, | ||
| roles: section.roles.map((role) => (role.id === roleId ? patch(role) : role)) | ||
| })) | ||
| }; | ||
| } | ||
|
|
||
| describe("firstPassSimplification", () => { | ||
| it("names the selected bass part's first-pass take", () => { | ||
| expect(firstPassSimplification(createDemoRehearsalSong(), "bass-guitar")).toEqual({ | ||
| status: "ready", | ||
| value: "Stay on roots if the chorus entrance gets muddy.", | ||
| sectionLabel: "verse", | ||
| roleName: "Bass Guitar" | ||
| }); | ||
| }); | ||
|
|
||
| it("names the selected keys part's first-pass take", () => { | ||
| expect(firstPassSimplification(createDemoRehearsalSong(), "keys-right")).toEqual({ | ||
| status: "ready", | ||
| value: "Drop the top extension if the chorus turnaround still feels busy.", | ||
| sectionLabel: "verse", | ||
| roleName: "Keyboard 1 Right Hand" | ||
| }); | ||
| }); | ||
|
|
||
| it("names the selected vocal part's first-pass take", () => { | ||
| expect(firstPassSimplification(createDemoRehearsalSong(), "lead-vocal")).toEqual({ | ||
| status: "ready", | ||
| value: "Keep the sustained note centered; skip the ad-lib on the first pass.", | ||
| sectionLabel: "verse", | ||
| roleName: "Lead Vocal" | ||
| }); | ||
| }); | ||
|
|
||
| it("fails closed without a selected named part", () => { | ||
| expect(firstPassSimplification(createDemoRehearsalSong(), null)).toEqual({ status: "unavailable" }); | ||
| expect(firstPassSimplification(createDemoRehearsalSong(), " ")).toEqual({ status: "unavailable" }); | ||
| expect(firstPassSimplification(createDemoRehearsalSong(), "none")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed on blank, none, or missing simplification values", () => { | ||
| const blank = withRolePatch(createDemoRehearsalSong(), "bass-guitar", (role) => ({ | ||
| ...role, | ||
| simplification: " " | ||
| })); | ||
| const none = withRolePatch(createDemoRehearsalSong(), "lead-vocal", (role) => ({ | ||
| ...role, | ||
| simplification: "none" | ||
| })); | ||
| const missing = withRolePatch(createDemoRehearsalSong(), "keys-right", (role) => { | ||
| const { simplification: _dropped, ...withoutSimplification } = role; | ||
| void _dropped; | ||
| return withoutSimplification as RehearsalRole; | ||
| }); | ||
|
|
||
| expect(firstPassSimplification(blank, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| expect(firstPassSimplification(none, "lead-vocal")).toEqual({ status: "unavailable" }); | ||
| expect(firstPassSimplification(missing, "keys-right")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed when simplification is inherited instead of owned", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const role = { ...song.sections[0]!.roles[0]! }; | ||
| const { simplification: _dropped, ...withoutSimplification } = role; | ||
| void _dropped; | ||
| Object.setPrototypeOf(withoutSimplification, { simplification: "sneaky roots only" }); | ||
| song.sections[0] = { | ||
| ...song.sections[0]!, | ||
| roles: [withoutSimplification as RehearsalRole, ...song.sections[0]!.roles.slice(1)] | ||
| }; | ||
|
|
||
| expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed on a malformed song root", () => { | ||
| expect(firstPassSimplification(null, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| expect(firstPassSimplification({ title: "no sections" }, "bass-guitar")).toEqual({ | ||
| status: "unavailable" | ||
| }); | ||
| }); | ||
|
|
||
| it("fails closed on a malformed section member", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| song.sections = [null as unknown as RehearsalSong["sections"][number], ...song.sections]; | ||
|
|
||
| expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed when a section omits roles or a role omits identity", () => { | ||
| const missingRoles = createDemoRehearsalSong(); | ||
| const { roles: _droppedRoles, ...sectionWithoutRoles } = missingRoles.sections[0]!; | ||
| void _droppedRoles; | ||
| missingRoles.sections[0] = sectionWithoutRoles as RehearsalSong["sections"][number]; | ||
|
|
||
| const missingRoleIdentity = createDemoRehearsalSong(); | ||
| const { id: _droppedId, ...roleWithoutId } = missingRoleIdentity.sections[0]!.roles[0]!; | ||
| void _droppedId; | ||
| missingRoleIdentity.sections[0] = { | ||
| ...missingRoleIdentity.sections[0]!, | ||
| roles: [roleWithoutId as RehearsalRole, ...missingRoleIdentity.sections[0]!.roles.slice(1)] | ||
| }; | ||
|
|
||
| expect(firstPassSimplification(missingRoles, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| expect(firstPassSimplification(missingRoleIdentity, "bass-guitar")).toEqual({ | ||
| status: "unavailable" | ||
| }); | ||
| }); | ||
|
|
||
| it("fails closed on duplicate role ids in one section", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| song.sections[0] = { | ||
| ...song.sections[0]!, | ||
| roles: [...song.sections[0]!.roles, { ...song.sections[0]!.roles[0]! }] | ||
| }; | ||
|
|
||
| expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed when the same selected id uses two display names", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const verse = song.sections[0]!; | ||
| song.sections = [ | ||
| verse, | ||
| { | ||
| ...verse, | ||
| id: "chorus-1", | ||
| label: "chorus", | ||
| roles: verse.roles.map((role) => | ||
| role.id === "bass-guitar" ? { ...role, name: "Electric Bass" } : role | ||
| ) | ||
| } | ||
| ]; | ||
|
|
||
| expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("skips a non-canonical section label instead of showing it as the first pass", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const verse = song.sections[0]!; | ||
| song.sections = [ | ||
| { ...verse, label: "drop-D intro" as RehearsalSong["sections"][number]["label"] }, | ||
| { ...verse, id: "chorus-1", label: "chorus" } | ||
| ]; | ||
|
|
||
| expect(firstPassSimplification(song, "bass-guitar")).toEqual({ | ||
| status: "ready", | ||
| value: "Stay on roots if the chorus entrance gets muddy.", | ||
| sectionLabel: "chorus", | ||
| roleName: "Bass Guitar" | ||
| }); | ||
| }); | ||
|
|
||
| it("does not skip an untrusted first canonical take to a later section", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const verse = song.sections[0]!; | ||
| song.sections = [ | ||
| { | ||
| ...verse, | ||
| roles: verse.roles.map((role) => | ||
| role.id === "bass-guitar" ? { ...role, simplification: "none" } : role | ||
| ) | ||
| }, | ||
| { | ||
| ...verse, | ||
| id: "chorus-1", | ||
| label: "chorus", | ||
| roles: verse.roles.map((role) => | ||
| role.id === "bass-guitar" | ||
| ? { ...role, simplification: "Hold roots through the chorus lift." } | ||
| : role | ||
| ) | ||
| } | ||
| ]; | ||
|
|
||
| expect(firstPassSimplification(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed for an unknown selected role", () => { | ||
| expect(firstPassSimplification(createDemoRehearsalSong(), "missing-role")).toEqual({ | ||
| status: "unavailable" | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("fillFirstPassCopy", () => { | ||
| it("fills owned tokens and leaves inherited members literal", () => { | ||
| expect( | ||
| fillFirstPassCopy("First pass for {roleName} in {sectionLabel}: {value} Play that simpler take before adding the rest.", { | ||
| roleName: "Bass Guitar", | ||
| sectionLabel: "verse", | ||
| value: "Stay on roots if the chorus entrance gets muddy." | ||
| }) | ||
| ).toBe( | ||
| "First pass for Bass Guitar in verse: Stay on roots if the chorus entrance gets muddy. Play that simpler take before adding the rest." | ||
| ); | ||
|
|
||
| expect(fillFirstPassCopy("keep {toString}", {})).toBe("keep {toString}"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Loaded projects inherit stale part selection
Loading another project preserves
activeRolefrom the previous song.firstPassSimplificationthen displays guidance for an unselected part or a nonexistent one.Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.