Repository navigation
feat(workspace): name tonight's first solo plan on the map #1020
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 14 commits
6320116
f885423
65a98cf
89d994c
e83705d
e10c4b9
58f6a98
46354e0
bf2e68c
67a6d83
c5d95a6
636caf0
7a682f6
7d3d6de
8bb9ba5
4f9b374
0c2ee78
66eaeac
ab886be
9292c9e
3c3ea64
12ed1fb
bb6aa70
8a998b6
97e5d61
4be3ae3
9852c69
79ed0b9
7bb4008
de8e242
ae62ae4
22f9266
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,21 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { expect, it } from "vitest"; | ||
| import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; | ||
|
|
||
| it("gives co-mounted solo-plan callouts distinct DOM identities", () => { | ||
| render( | ||
| <> | ||
| <FirstSoloPlanCallout song={createDemoRehearsalSong()} /> | ||
| <FirstSoloPlanCallout song={createDemoRehearsalSong()} /> | ||
| </> | ||
| ); | ||
|
|
||
| const callouts = screen.getAllByRole("complementary", { | ||
| name: "Tonight's first solo plan" | ||
| }); | ||
| const ids = callouts.map((callout) => callout.id); | ||
|
|
||
| expect(ids.every((id) => id.length > 0)).toBe(true); | ||
| expect(new Set(ids).size).toBe(callouts.length); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { render } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; | ||
|
|
||
| describe("FirstSoloPlanCallout resolver reuse", () => { | ||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it("does not rescan role metadata when a parent rerenders the same song object", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const role = song.sections[0]!.roles.find((candidate) => candidate.id === "lead-vocal")!; | ||
| const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor"); | ||
|
|
||
| const { rerender } = render(<FirstSoloPlanCallout song={song} />); | ||
| const firstScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; | ||
| expect(firstScanCount).toBeGreaterThan(0); | ||
|
|
||
| rerender(<FirstSoloPlanCallout song={song} />); | ||
| const secondScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; | ||
|
|
||
| expect(secondScanCount).toBe(firstScanCount); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; | ||
|
|
||
| describe("FirstSoloPlanCallout Korean role copy", () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("keeps vowel-ending role names particle-safe before and after the solo action", () => { | ||
| vi.stubGlobal("navigator", { language: "ko-KR" }); | ||
| const song = createDemoRehearsalSong(); | ||
| const seed = song.sections[0]!; | ||
| seed.roles = [ | ||
| { | ||
| ...seed.roles[2]!, | ||
| id: "piano", | ||
| name: "피아노", | ||
| rehearsalPriority: "high", | ||
| soloPlan: "Hold the verse solo; everyone else drops to a two-bar pad so the run can land." | ||
| } | ||
| ]; | ||
| seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; | ||
|
|
||
| const grid = document.createElement("div"); | ||
| grid.dataset.testid = "song-structure-grid"; | ||
| grid.setAttribute("role", "region"); | ||
| grid.setAttribute("aria-label", "Scrollable song structure timeline"); | ||
| const target = document.createElement("div"); | ||
| target.dataset.sectionIndex = "0"; | ||
| Object.defineProperty(target, "scrollIntoView", { | ||
| configurable: true, | ||
| value: vi.fn() | ||
| }); | ||
| grid.appendChild(target); | ||
| document.body.appendChild(grid); | ||
|
|
||
| render(<FirstSoloPlanCallout song={song} />); | ||
|
|
||
| expect(screen.getByText("0:10 벌스에서 피아노 파트의 솔로 계획이 있습니다.")).toBeTruthy(); | ||
| expect(screen.queryByText(/피아노이/)).toBeNull(); | ||
| expect(screen.queryByText(/피아노가/)).toBeNull(); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 솔로 열기" })); | ||
|
|
||
| expect(screen.getByText("0:10에서 피아노 파트의 솔로를 맞춘 다음 합주를 시작하세요.")).toBeTruthy(); | ||
|
Check failure on line 47 in apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx
|
||
| expect(screen.queryByText(/피아노과/)).toBeNull(); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; | ||
|
|
||
| describe("FirstSoloPlanCallout reduced motion", () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("scrolls immediately when the operating system requests reduced motion", () => { | ||
| vi.stubGlobal("matchMedia", (query: string) => ({ | ||
| matches: query === "(prefers-reduced-motion: reduce)", | ||
| media: query, | ||
| onchange: null, | ||
| addListener: vi.fn(), | ||
| removeListener: vi.fn(), | ||
| addEventListener: vi.fn(), | ||
| removeEventListener: vi.fn(), | ||
| dispatchEvent: vi.fn() | ||
| })); | ||
|
|
||
| const grid = document.createElement("div"); | ||
| grid.dataset.testid = "song-structure-grid"; | ||
| grid.setAttribute("role", "region"); | ||
| grid.setAttribute("aria-label", "Scrollable song structure timeline"); | ||
| const target = document.createElement("div"); | ||
| target.dataset.sectionIndex = "0"; | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
|
||
| const scrollIntoView = vi.fn(); | ||
| Object.defineProperty(target, "scrollIntoView", { | ||
| configurable: true, | ||
| value: scrollIntoView | ||
| }); | ||
| grid.appendChild(target); | ||
| document.body.appendChild(grid); | ||
|
|
||
| render(<FirstSoloPlanCallout song={createDemoRehearsalSong()} />); | ||
| fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); | ||
| expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); | ||
|
Check failure on line 39 in apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx
|
||
|
|
||
| grid.remove(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import type { Meta, StoryObj } from "@storybook/react-vite"; | ||
| import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; | ||
| import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; | ||
| import { SectionRoadmap } from "./SectionRoadmap"; | ||
|
|
||
| function withoutSoloPlans(): RehearsalSong { | ||
| const song = createDemoRehearsalSong(); | ||
| return { | ||
| ...song, | ||
| sections: song.sections.map((section) => ({ | ||
| ...section, | ||
| roles: section.roles.map((role) => ({ ...role, soloPlan: "" })) | ||
| })) | ||
| }; | ||
| } | ||
|
|
||
| function StorySurface({ song }: { song: RehearsalSong }) { | ||
| return ( | ||
| <div className="min-h-screen bg-slate-950 p-6 text-slate-100"> | ||
| <div className="mx-auto max-w-5xl space-y-6"> | ||
| <FirstSoloPlanCallout song={song} /> | ||
| <div data-testid="song-structure-grid" aria-hidden="true" className="hidden" /> | ||
| <SectionRoadmap song={song} activeRole={null} /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const meta = { | ||
| title: "Workspace/First Solo Plan Callout", | ||
| component: FirstSoloPlanCallout, | ||
| parameters: { layout: "fullscreen" }, | ||
| render: ({ song }) => <StorySurface song={song} /> | ||
| } satisfies Meta<typeof FirstSoloPlanCallout>; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| export const Available: Story = { | ||
| args: { song: createDemoRehearsalSong() } | ||
| }; | ||
|
|
||
| export const Unavailable: Story = { | ||
| args: { song: withoutSoloPlans() } | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.