Repository navigation
feat(workspace): name the selected part's first entrance cue #1150
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
Draft
seonghobae
wants to merge
27
commits into
develop
Choose a base branch
from
feat/workspace-selected-part-entrance-cue
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
abfb399
feat(workspace): name the selected part's first entrance cue
seonghobae 3a09ae1
feat(workspace): consolidate selected-part first-pass domain
seonghobae 5c9d96f
test(workspace): preserve selected-part first-pass mount
seonghobae baddc60
test(workspace): preserve selected-part first-pass regressions
seonghobae ff9260e
docs(workspace): preserve selected-part first-pass evidence
seonghobae 39f7fae
feat(workspace): mount consolidated selected-part first-pass guidance
seonghobae e583873
feat(i18n): consolidate selected-part first-pass copy
seonghobae 424821b
feat(i18n): consolidate Korean first-pass copy
seonghobae 7037ce7
feat(workspace): consolidate selected-part confirmed chord
seonghobae 9375cf1
test(workspace): preserve confirmed-chord mount
seonghobae 7ad3924
test(workspace): preserve confirmed-chord regressions
seonghobae a5d0750
docs(workspace): preserve confirmed-chord evidence
seonghobae 9a8b30e
feat(workspace): mount consolidated confirmed-chord guidance
seonghobae 958b096
feat(i18n): consolidate confirmed-chord copy
seonghobae d662173
feat(i18n): consolidate Korean confirmed-chord copy
seonghobae 8581afb
refactor(workspace): share canonical section labels
seonghobae 8821ae1
docs(ui): consolidate selected-part guidance contracts
seonghobae 7d03dc9
test(workspace): reject stale selected roles
seonghobae c264458
test(workspace): hide stale selected-part guidance
seonghobae 10d9ed0
test(i18n): require particle-safe confirmed chord copy
seonghobae 141d8e9
fix(workspace): fail closed on chord override conflicts
seonghobae c5ce688
test(workspace): cover conflicting chord overrides
seonghobae 81d022c
fix(i18n): make confirmed chord copy particle-safe
seonghobae 44b1233
fix(workspace): clear roles missing from replacement project
seonghobae 84443ce
docs(workspace): record selected-part ordering invariants
seonghobae f8a6c2b
docs(workspace): record confirmed chord conflict policy
seonghobae 1f78675
Merge protected develop into selected-part guidance owner
seonghobae 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
Some comments aren't visible on the classic Files Changed page.
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.entrance-cue.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 entrance cue", () => { | ||
| afterEach(() => { | ||
| setNavigatorLanguage(originalLanguage); | ||
| }); | ||
|
|
||
| it("hides the entrance cue until a named part is selected", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
|
|
||
| expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull(); | ||
| }); | ||
|
|
||
| it("names the selected bass part's transition as the next entrance", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); | ||
|
|
||
| const callout = screen.getByTestId("selected-part-entrance-cue"); | ||
| expect(callout).toHaveTextContent("Tonight's entrance cue"); | ||
| expect(callout).toHaveTextContent( | ||
| "Catch this transition in verse before Bass Guitar enters: Hold through the pickup before the downbeat." | ||
| ); | ||
| }); | ||
|
|
||
| it("names the selected vocal lyric as the next entrance", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); | ||
|
|
||
| expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent( | ||
| 'Listen for "city lights" in verse, then Lead Vocal enters.' | ||
| ); | ||
| }); | ||
|
|
||
| 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-entrance-cue"); | ||
| expect(callout).toHaveTextContent("오늘 이 파트의 첫 입장 큐"); | ||
| expect(callout).toHaveTextContent("Bass Guitar 파트"); | ||
| expect(callout).not.toHaveTextContent("Bass Guitar으로"); | ||
| expect(callout).toHaveTextContent( | ||
| "verse에서 이 전환을 잡고 Bass Guitar 파트로 들어오세요: Hold through the pickup before the downbeat." | ||
| ); | ||
| }); | ||
|
|
||
| it("tells the player to confirm a missing cue instead of hiding the next action", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| const song = createDemoRehearsalSong(); | ||
| song.sections[0]!.roles[0] = { | ||
| ...song.sections[0]!.roles[0]!, | ||
| cue: { kind: "transition", value: "none" } | ||
| }; | ||
|
|
||
| render(<Workspace song={song} />); | ||
| fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); | ||
|
|
||
| expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent( | ||
| "This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance." | ||
| ); | ||
| }); | ||
| }); |
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
209 changes: 209 additions & 0 deletions
209
apps/desktop/src/features/workspace/firstEntranceCue.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,209 @@ | ||
| import { createDemoRehearsalSong, type RehearsalRole, type RehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { fillEntranceCueCopy, firstEntranceCue, isAdmittedCueKind } from "./firstEntranceCue"; | ||
|
|
||
| /** 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("isAdmittedCueKind", () => { | ||
| it("admits only lyric, count, and transition", () => { | ||
| expect(isAdmittedCueKind("lyric")).toBe(true); | ||
| expect(isAdmittedCueKind("count")).toBe(true); | ||
| expect(isAdmittedCueKind("transition")).toBe(true); | ||
| expect(isAdmittedCueKind("groove")).toBe(false); | ||
| expect(isAdmittedCueKind("")).toBe(false); | ||
| expect(isAdmittedCueKind(null)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("firstEntranceCue", () => { | ||
| it("names the selected bass part's first transition cue", () => { | ||
| expect(firstEntranceCue(createDemoRehearsalSong(), "bass-guitar")).toEqual({ | ||
| status: "ready", | ||
| kind: "transition", | ||
| value: "Hold through the pickup before the downbeat.", | ||
| sectionLabel: "verse", | ||
| roleName: "Bass Guitar" | ||
| }); | ||
| }); | ||
|
|
||
| it("names the selected keys part's first count cue", () => { | ||
| expect(firstEntranceCue(createDemoRehearsalSong(), "keys-right")).toEqual({ | ||
| status: "ready", | ||
| kind: "count", | ||
| value: "Enter on beat 2 after the pickup.", | ||
| sectionLabel: "verse", | ||
| roleName: "Keyboard 1 Right Hand" | ||
| }); | ||
| }); | ||
|
|
||
| it("names the selected vocal part's first lyric cue", () => { | ||
| expect(firstEntranceCue(createDemoRehearsalSong(), "lead-vocal")).toEqual({ | ||
| status: "ready", | ||
| kind: "lyric", | ||
| value: "city lights", | ||
| sectionLabel: "verse", | ||
| roleName: "Lead Vocal" | ||
| }); | ||
| }); | ||
|
|
||
| it("fails closed without a selected named part", () => { | ||
| expect(firstEntranceCue(createDemoRehearsalSong(), null)).toEqual({ status: "unavailable" }); | ||
| expect(firstEntranceCue(createDemoRehearsalSong(), " ")).toEqual({ status: "unavailable" }); | ||
| expect(firstEntranceCue(createDemoRehearsalSong(), "none")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed on blank, none, or unknown cue values and kinds", () => { | ||
| const blank = withRolePatch(createDemoRehearsalSong(), "bass-guitar", (role) => ({ | ||
| ...role, | ||
| cue: { kind: "transition", value: " " } | ||
| })); | ||
| const none = withRolePatch(createDemoRehearsalSong(), "lead-vocal", (role) => ({ | ||
| ...role, | ||
| cue: { kind: "lyric", value: "none" } | ||
| })); | ||
| const unknownKind = withRolePatch(createDemoRehearsalSong(), "keys-right", (role) => ({ | ||
| ...role, | ||
| cue: { kind: "groove" as RehearsalRole["cue"]["kind"], value: "on the one" } | ||
| })); | ||
|
|
||
| expect(firstEntranceCue(blank, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| expect(firstEntranceCue(none, "lead-vocal")).toEqual({ status: "unavailable" }); | ||
| expect(firstEntranceCue(unknownKind, "keys-right")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed when cue is inherited instead of owned", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const role = { ...song.sections[0]!.roles[0]! }; | ||
| const { cue: _dropped, ...withoutCue } = role; | ||
| void _dropped; | ||
| Object.setPrototypeOf(withoutCue, { cue: { kind: "lyric", value: "sneaky lyric" } }); | ||
| song.sections[0] = { | ||
| ...song.sections[0]!, | ||
| roles: [withoutCue as RehearsalRole, ...song.sections[0]!.roles.slice(1)] | ||
| }; | ||
|
|
||
| expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed when cue kind is inherited", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const inheritedKind = Object.create({ kind: "lyric" }) as RehearsalRole["cue"]; | ||
| inheritedKind.value = "city lights"; | ||
| song.sections[0]!.roles[0] = { | ||
| ...song.sections[0]!.roles[0]!, | ||
| cue: inheritedKind | ||
| }; | ||
|
|
||
| expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed on a malformed song root", () => { | ||
| expect(firstEntranceCue(null, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| expect(firstEntranceCue({ title: "no sections" }, "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(firstEntranceCue(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(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("skips a non-canonical section label instead of showing it as the entrance", () => { | ||
| 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(firstEntranceCue(song, "bass-guitar")).toEqual({ | ||
| status: "ready", | ||
| kind: "transition", | ||
| value: "Hold through the pickup before the downbeat.", | ||
| sectionLabel: "chorus", | ||
| roleName: "Bass Guitar" | ||
| }); | ||
| }); | ||
|
|
||
| it("does not skip an untrusted first canonical cue 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, cue: { kind: "transition", value: "none" } } : role | ||
| ) | ||
| }, | ||
| { | ||
| ...verse, | ||
| id: "chorus-1", | ||
| label: "chorus", | ||
| roles: verse.roles.map((role) => | ||
| role.id === "bass-guitar" | ||
| ? { ...role, cue: { kind: "transition", value: "Catch the chorus lift." } } | ||
| : role | ||
| ) | ||
| } | ||
| ]; | ||
|
|
||
| expect(firstEntranceCue(song, "bass-guitar")).toEqual({ status: "unavailable" }); | ||
| }); | ||
|
|
||
| it("fails closed for an unknown selected role", () => { | ||
| expect(firstEntranceCue(createDemoRehearsalSong(), "missing-role")).toEqual({ | ||
| status: "unavailable" | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("fillEntranceCueCopy", () => { | ||
| it("fills owned tokens and leaves inherited members literal", () => { | ||
| expect( | ||
| fillEntranceCueCopy("Listen for \"{value}\" in {sectionLabel}, then {roleName} enters.", { | ||
| value: "city lights", | ||
| sectionLabel: "verse", | ||
| roleName: "Lead Vocal" | ||
| }) | ||
| ).toBe('Listen for "city lights" in verse, then Lead Vocal enters.'); | ||
|
|
||
| expect(fillEntranceCueCopy("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.
Uh oh!
There was an error while loading. Please reload this page.