-
Notifications
You must be signed in to change notification settings - Fork 1
feat(workspace): name tonight's first leftover return on the map #1101
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
base: develop
Are you sure you want to change the base?
Changes from all commits
f640e57
2e27dde
779363d
18c93e7
4670497
118daa7
385cb3d
17a019a
9e24c1d
8d7a3c8
3013268
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { render, screen } from "@testing-library/react"; | ||
| 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 leftover-return empty state", () => { | ||
| afterEach(() => { | ||
| setNavigatorLanguage(originalLanguage); | ||
| }); | ||
|
|
||
| it("explains when a trustworthy all-active song needs no leftover-return cue", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
|
|
||
| const callout = screen.getByTestId("first-leftover-return"); | ||
| expect(callout).toHaveTextContent( | ||
| "No leftover return is needed: every named part stays active. Rehearse from the first section without a count-back cue." | ||
| ); | ||
| expect(callout).not.toHaveTextContent("still needs a named leftover part"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { hasTrustworthyAllActiveTimeline } from "./firstLeftoverReturn"; | ||
|
|
||
| describe("hasTrustworthyAllActiveTimeline", () => { | ||
| it("accepts a complete named timeline when every graph role stays active", () => { | ||
| expect(hasTrustworthyAllActiveTimeline(createDemoRehearsalSong())).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects non-song roots and empty timelines", () => { | ||
| expect(hasTrustworthyAllActiveTimeline(null)).toBe(false); | ||
|
|
||
| const empty = createDemoRehearsalSong(); | ||
| empty.sections = []; | ||
| expect(hasTrustworthyAllActiveTimeline(empty)).toBe(false); | ||
| }); | ||
|
|
||
| it("rejects timelines without a named section", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| song.sections = song.sections.map((section) => ({ ...section, label: " " })); | ||
|
|
||
| expect(hasTrustworthyAllActiveTimeline(song)).toBe(false); | ||
| }); | ||
|
|
||
| it("rejects an inactive named role", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const firstSection = song.sections[0]!; | ||
| firstSection.partGraph = firstSection.partGraph.map((node, index) => | ||
| index === 0 ? { ...node, is_active: false } : node | ||
| ); | ||
|
|
||
| expect(hasTrustworthyAllActiveTimeline(song)).toBe(false); | ||
| }); | ||
|
|
||
| it("fails closed on malformed section and graph evidence", () => { | ||
| expect( | ||
| hasTrustworthyAllActiveTimeline({ sections: [null] }) | ||
| ).toBe(false); | ||
|
|
||
| const song = createDemoRehearsalSong(); | ||
| const malformed = song as unknown as { | ||
| sections: Array<{ | ||
| partGraph: Array<Record<string, unknown>>; | ||
| }>; | ||
| }; | ||
| delete malformed.sections[0]!.partGraph[0]!.is_active; | ||
|
|
||
| expect(hasTrustworthyAllActiveTimeline(song)).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { firstLeftoverReturn } from "./firstLeftoverReturn"; | ||
|
|
||
| function sectionWithInactiveRoles( | ||
| template: RehearsalSong["sections"][number], | ||
| id: string, | ||
| label: RehearsalSong["sections"][number]["label"], | ||
| start: number, | ||
| inactiveRoleIds: readonly string[] | ||
| ): RehearsalSong["sections"][number] { | ||
| const inactive = new Set(inactiveRoleIds); | ||
| return { | ||
| ...template, | ||
| id, | ||
| label, | ||
| timeRange: { start, end: start + 20 }, | ||
| partGraph: template.partGraph.map((node) => ({ | ||
| ...node, | ||
| is_active: !inactive.has(node.role_id) | ||
| })) | ||
| }; | ||
| } | ||
|
|
||
| describe("firstLeftoverReturn selected-role search", () => { | ||
| it("keeps searching after the selected part newly drops out during an earlier leftover sit-out", () => { | ||
| const seed = createDemoRehearsalSong(); | ||
| const template = seed.sections[0]!; | ||
| const song: RehearsalSong = { | ||
| ...seed, | ||
| sections: [ | ||
| sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ | ||
| "bass-guitar", | ||
| "keys-right" | ||
| ]), | ||
| sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ | ||
| "lead-vocal", | ||
| "keys-right" | ||
| ]), | ||
| sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["keys-right"]), | ||
| sectionWithInactiveRoles(template, "outro-1", "outro", 60, []) | ||
| ] | ||
| }; | ||
|
|
||
| expect(firstLeftoverReturn(song, "lead-vocal")).toEqual({ | ||
|
Check failure on line 45 in apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts
|
||
| sectionLabel: "outro", | ||
| leftoverSectionLabel: "bridge", | ||
| fromSectionLabel: "verse", | ||
| leftoverRoleId: "keys-right", | ||
| leftoverRoleName: "Keyboard 1 Right Hand" | ||
| }); | ||
| }); | ||
|
|
||
| it("does not show another cohort's leftover return to a continuously active selected role", () => { | ||
| const seed = createDemoRehearsalSong(); | ||
| const template = seed.sections[0]!; | ||
| const song: RehearsalSong = { | ||
| ...seed, | ||
| sections: [ | ||
| sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ | ||
| "bass-guitar", | ||
| "keys-right" | ||
| ]), | ||
| sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]), | ||
| sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) | ||
| ] | ||
| }; | ||
|
|
||
| expect(firstLeftoverReturn(song, "lead-vocal")).toBeNull(); | ||
| }); | ||
|
|
||
| it("returns the first eligible leftover even when a later graph entry returns first", () => { | ||
| const seed = createDemoRehearsalSong(); | ||
| const template = seed.sections[0]!; | ||
| const song: RehearsalSong = { | ||
| ...seed, | ||
| sections: [ | ||
| sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ | ||
| "bass-guitar", | ||
| "keys-right", | ||
| "lead-vocal" | ||
| ]), | ||
| sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ | ||
| "keys-right", | ||
| "lead-vocal" | ||
| ]), | ||
| sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["keys-right"]) | ||
| ] | ||
| }; | ||
|
|
||
| expect(firstLeftoverReturn(song)).toEqual({ | ||
| sectionLabel: "bridge", | ||
| leftoverSectionLabel: "chorus", | ||
| fromSectionLabel: "verse", | ||
| leftoverRoleId: "lead-vocal", | ||
| leftoverRoleName: "Lead Vocal" | ||
| }); | ||
| }); | ||
|
|
||
| it("does not tell a new dropout to come back from an earlier leftover sit-out", () => { | ||
| const seed = createDemoRehearsalSong(); | ||
| const template = seed.sections[0]!; | ||
| const song: RehearsalSong = { | ||
| ...seed, | ||
| sections: [ | ||
| sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ | ||
| "bass-guitar", | ||
| "keys-right" | ||
| ]), | ||
| sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ | ||
| "lead-vocal", | ||
| "keys-right" | ||
| ]), | ||
| sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) | ||
| ] | ||
| }; | ||
|
|
||
| expect(firstLeftoverReturn(song, "lead-vocal")).toBeNull(); | ||
| }); | ||
| }); | ||
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.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
fromSectionLabel보존 로직을 수정하세요.Line 48의 기대값은 PR의 의도와 일치합니다. 그러나
apps/desktop/src/features/workspace/firstLeftoverReturn.ts는 chorus에서 새로 빠진lead-vocal을 처리할 때reducedFrom을"chorus"로 재설정합니다. 따라서 이 fixture는"verse"대신"chorus"를 반환하고 테스트가 실패합니다.선택한 역할이 새로 빠져도 기존 leftover인
keys-right의 시작 섹션은 유지하세요. 테스트 기대값을 변경하지 마세요.🤖 Prompt for AI Agents