-
Notifications
You must be signed in to change notification settings - Fork 1
feat(workspace): count in tonight's first click before the range #1070
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
8
commits into
develop
Choose a base branch
from
feat/workspace-first-count-in
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 all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
64576f2
feat(workspace): count in tonight's first click before the range
seonghobae 35bde90
test(count-in): reproduce cancellation and cleanup races
seonghobae ceaff8a
fix(count-in): cancel stale playback and release audio nodes
seonghobae fbdd543
docs(count-in): pin Web Audio 1.1 citation
seonghobae 79a863b
test(count-in): reproduce equivalent-plan interruption
seonghobae b35d8b0
fix(count-in): preserve playback across equivalent plans
seonghobae 92d8ffe
fix(count-in): document exported tempo bounds
seonghobae a2cc3f8
chore(workspace): restack count-in on protected develop
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
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
41 changes: 41 additions & 0 deletions
41
apps/desktop/src/features/workspace/CountInClick.semantic-plan.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,41 @@ | ||
| import { fireEvent, render, screen, waitFor } from "@testing-library/react"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { createTranslator } from "../../i18n"; | ||
| import { CountInClick } from "./CountInClick"; | ||
| import type { CountInClickEngine } from "./countInClickEngine"; | ||
| import type { FirstCountInPlan } from "./firstCountIn"; | ||
|
|
||
| const t = createTranslator("en"); | ||
| const plan: FirstCountInPlan = { | ||
| tempoBpm: 120, | ||
| beats: 4, | ||
| intervalMs: 500, | ||
| sectionLabel: "verse" | ||
| }; | ||
|
|
||
| describe("CountInClick semantic plan lifecycle", () => { | ||
| it("keeps an active count-in running when an equivalent plan object replaces the prior object", async () => { | ||
| let finishPlay: (() => void) | undefined; | ||
| const engine: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn( | ||
| () => | ||
| new Promise<void>((resolve) => { | ||
| finishPlay = resolve; | ||
| }) | ||
| ), | ||
| stop: vi.fn() | ||
| }; | ||
| const { rerender } = render(<CountInClick plan={plan} t={t} engine={engine} />); | ||
| fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); | ||
|
|
||
| rerender(<CountInClick plan={{ ...plan }} t={t} engine={engine} />); | ||
|
|
||
| expect(engine.stop).not.toHaveBeenCalled(); | ||
| expect(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })).toHaveTextContent("Counting in"); | ||
| finishPlay?.(); | ||
| await waitFor(() => { | ||
| expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy(); | ||
| }); | ||
| }); | ||
| }); |
180 changes: 180 additions & 0 deletions
180
apps/desktop/src/features/workspace/CountInClick.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,180 @@ | ||
| import { fireEvent, render, screen, waitFor } from "@testing-library/react"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { CountInClick } from "./CountInClick"; | ||
| import type { CountInClickEngine } from "./countInClickEngine"; | ||
| import type { FirstCountInPlan } from "./firstCountIn"; | ||
| import { createTranslator } from "../../i18n"; | ||
|
|
||
| const t = createTranslator("en"); | ||
| const plan: FirstCountInPlan = { | ||
| tempoBpm: 120, | ||
| beats: 4, | ||
| intervalMs: 500, | ||
| sectionLabel: "verse" | ||
| }; | ||
|
|
||
| function renderCountIn(engine: CountInClickEngine, nextPlan: FirstCountInPlan | null = plan) { | ||
| return render(<CountInClick plan={nextPlan} t={t} engine={engine} />); | ||
| } | ||
|
|
||
| describe("CountInClick", () => { | ||
| it("names the count-in next action and plays a local click", async () => { | ||
| const engine: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn(async () => undefined), | ||
| stop: vi.fn() | ||
| }; | ||
| renderCountIn(engine); | ||
|
|
||
| const region = screen.getByTestId("first-count-in"); | ||
| expect(region).toHaveTextContent("Tonight's first count-in"); | ||
| expect(region).toHaveTextContent( | ||
| "Count in 4 at 120 BPM, then check tonight's first range before the verse." | ||
| ); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); | ||
| expect(engine.play).toHaveBeenCalledWith(plan); | ||
| await waitFor(() => { | ||
| expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy(); | ||
| }); | ||
| }); | ||
|
|
||
| it("asks the room to name a section when tempo is trusted but unlabeled", () => { | ||
| const engine: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn(async () => undefined), | ||
| stop: vi.fn() | ||
| }; | ||
| renderCountIn(engine, { ...plan, sectionLabel: undefined }); | ||
| expect(screen.getByTestId("first-count-in")).toHaveTextContent( | ||
| "Count in 4 at 120 BPM, then name the first section so the room knows where it starts." | ||
| ); | ||
| }); | ||
|
|
||
| it("fails closed without a tempo and does not start a click", () => { | ||
| const engine: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn(async () => undefined), | ||
| stop: vi.fn() | ||
| }; | ||
| renderCountIn(engine, null); | ||
| expect(screen.getByTestId("first-count-in")).toHaveTextContent( | ||
| "Tonight's first count-in still needs a tempo. Count the first section in by ear before you start." | ||
| ); | ||
| fireEvent.click(screen.getByRole("button", { name: /^count in$/i })); | ||
| expect(engine.play).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("blocks when the host cannot synthesize a click and when play throws", async () => { | ||
| const unavailable: CountInClickEngine = { | ||
| available: false, | ||
| play: vi.fn(async () => undefined), | ||
| stop: vi.fn() | ||
| }; | ||
| const { rerender } = renderCountIn(unavailable); | ||
| fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); | ||
| expect(screen.getByText(/this browser cannot play a click/i)).toBeTruthy(); | ||
|
|
||
| const failing: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn(async () => { | ||
| throw new Error("context failed"); | ||
| }), | ||
| stop: vi.fn() | ||
| }; | ||
| rerender(<CountInClick plan={plan} t={t} engine={failing} />); | ||
| fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); | ||
| await waitFor(() => { | ||
| expect(screen.getByText(/this browser cannot play a click/i)).toBeTruthy(); | ||
| }); | ||
| }); | ||
|
|
||
| it("ignores a second count-in click while the first is in flight", async () => { | ||
| let finishPlay: (() => void) | undefined; | ||
| const engine: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn( | ||
| () => | ||
| new Promise<void>((resolve) => { | ||
| finishPlay = resolve; | ||
| }) | ||
| ), | ||
| stop: vi.fn() | ||
| }; | ||
| renderCountIn(engine); | ||
| const button = screen.getByRole("button", { name: /count in 4 at 120 bpm/i }); | ||
| fireEvent.click(button); | ||
| fireEvent.click(button); | ||
| expect(engine.play).toHaveBeenCalledTimes(1); | ||
| finishPlay?.(); | ||
| await waitFor(() => { | ||
| expect(screen.getByText("Now check that span on your instrument.")).toBeTruthy(); | ||
| }); | ||
| }); | ||
|
|
||
| it("stops a playing count-in and ignores a stale completion", async () => { | ||
| let finishPlay: (() => void) | undefined; | ||
| const engine: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn( | ||
| () => | ||
| new Promise<void>((resolve) => { | ||
| finishPlay = resolve; | ||
| }) | ||
| ), | ||
| stop: vi.fn() | ||
| }; | ||
| renderCountIn(engine); | ||
| fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); | ||
| expect(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })).toHaveTextContent("Counting in"); | ||
| fireEvent.click(screen.getByRole("button", { name: /stop count-in/i })); | ||
| expect(engine.stop).toHaveBeenCalled(); | ||
| finishPlay?.(); | ||
| await waitFor(() => { | ||
| expect(screen.queryByText("Now check that span on your instrument.")).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| it("stops the old engine and invalidates completion when the active plan changes", async () => { | ||
| let finishPlay: (() => void) | undefined; | ||
| const engine: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn( | ||
| () => | ||
| new Promise<void>((resolve) => { | ||
| finishPlay = resolve; | ||
| }) | ||
| ), | ||
| stop: vi.fn() | ||
| }; | ||
| const { rerender } = renderCountIn(engine); | ||
| fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); | ||
|
|
||
| const nextPlan: FirstCountInPlan = { | ||
| tempoBpm: 90, | ||
| beats: 4, | ||
| intervalMs: 60_000 / 90, | ||
| sectionLabel: "chorus" | ||
| }; | ||
| rerender(<CountInClick plan={nextPlan} t={t} engine={engine} />); | ||
|
|
||
| expect(engine.stop).toHaveBeenCalledTimes(1); | ||
| expect(screen.getByRole("button", { name: /count in 4 at 90 bpm/i })).toHaveTextContent("Count in"); | ||
| finishPlay?.(); | ||
| await waitFor(() => { | ||
| expect(screen.queryByText("Now check that span on your instrument.")).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| it("stops the active engine when the count-in surface unmounts", () => { | ||
| const engine: CountInClickEngine = { | ||
| available: true, | ||
| play: vi.fn(() => new Promise<void>(() => undefined)), | ||
| stop: vi.fn() | ||
| }; | ||
| const { unmount } = renderCountIn(engine); | ||
| fireEvent.click(screen.getByRole("button", { name: /count in 4 at 120 bpm/i })); | ||
| unmount(); | ||
| expect(engine.stop).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
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.