Repository navigation
feat(workspace): guide tonight's first entrance on map and player #912
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 59 commits
f56b4e9
fbaa553
5abfcf8
4050a31
6141af5
bd632dd
03250e8
e991f4f
5b44724
6810002
94185c1
8bc71d8
a71ce94
74cd028
cad86b8
961b846
71bc5bb
e59b974
d496ac6
6ccdfab
db05549
827f0d7
e03bd5b
ef3358e
03647cd
a51d2d8
aa466dd
55b73a8
0262b8e
b754127
3c2a006
dd9434e
a9b22e7
791fa11
6a749bd
960eb49
46a6829
199dbf5
5450fb7
1201fae
f98608b
3a418a1
a571ead
fd3d88f
f9a66f7
f27ce38
62ba822
b8f588a
5466880
f6ada09
e5d3d0e
ec1cc3b
88b5400
8fc1f21
9473742
a54ffbd
cead41a
697ef10
b63219f
6e20c4c
fcca36c
0c39a81
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,40 @@ | ||
| import { fireEvent, render, screen, waitFor } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { App } from "./App"; | ||
|
|
||
| vi.mock("./features/score/pdfjs", () => ({ | ||
| configureScorePdfWorker: vi.fn(), | ||
| loadScorePdf: vi.fn(() => ({ | ||
| promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), | ||
| destroy: vi.fn(() => Promise.resolve()) | ||
| })) | ||
| })); | ||
|
|
||
| vi.mock("./lib/analysis", async (importActual) => { | ||
| const actual = await importActual<typeof import("./lib/analysis")>(); | ||
|
|
||
| return { | ||
| ...actual, | ||
| loadProject: vi.fn(async () => createDemoRehearsalSong()) | ||
| }; | ||
| }); | ||
|
|
||
| describe("App player reachability", () => { | ||
| it("exposes the analyzed song's first-entrance guidance through the shipped Player view without a dead playback action", async () => { | ||
| render(<App />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: /open project/i })); | ||
|
|
||
| const playerButton = await screen.findByRole("button", { name: /^Player$/i }); | ||
|
Check failure on line 29 in apps/desktop/src/App.player-reachability.test.tsx
|
||
| fireEvent.click(playerButton); | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByRole("heading", { name: /^Player$/i })).toBeTruthy(); | ||
| }); | ||
| expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); | ||
| expect( | ||
| screen.queryByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" }) | ||
| ).toBeNull(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { PlayerFeature } from "./index"; | ||
|
|
||
| describe("PlayerFeature", () => { | ||
| it("asks the room to analyze first when no song is loaded", () => { | ||
| render(<PlayerFeature title="Player" />); | ||
| expect( | ||
| screen.getByText("Analyze tonight's song first, then hear the first entrance from this player.") | ||
| ).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("names tonight's first entrance without a dead action when playback is unavailable", () => { | ||
| render(<PlayerFeature title="Player" song={createDemoRehearsalSong()} />); | ||
|
|
||
| expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); | ||
| expect(screen.queryByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })).toBeNull(); | ||
| }); | ||
|
|
||
| it("uses the player playback callback for the first entrance action", () => { | ||
| const onPlayFromSeconds = vi.fn(); | ||
| render( | ||
| <PlayerFeature | ||
| title="Player" | ||
| song={createDemoRehearsalSong()} | ||
| onPlayFromSeconds={onPlayFromSeconds} | ||
| /> | ||
| ); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })); | ||
| expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); | ||
| expect(onPlayFromSeconds).toHaveBeenCalledWith(10); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,56 +1,34 @@ | ||
| import type { RehearsalSong } from "@bandscope/shared-types"; | ||
| import { FirstEntranceCallout } from "../workspace/FirstEntranceCallout"; | ||
| import { createTranslator, detectPreferredLocale } from "../../i18n"; | ||
|
|
||
| /** Documented. */ | ||
| export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { | ||
| const { title, song } = props; | ||
| type PlayerFeatureProps = { | ||
| title: string; | ||
| song?: RehearsalSong | null; | ||
| onPlayFromSeconds?: (startSeconds: number) => void; | ||
| }; | ||
|
|
||
| /** Player surface that names tonight's first entrance and delegates playback to the owning player. */ | ||
| export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { | ||
| const t = createTranslator(detectPreferredLocale()); | ||
|
|
||
| if (!song) { | ||
| return ( | ||
| <section style={{ padding: "24px" }}> | ||
| <h2>{title}</h2> | ||
| <p style={{ color: "#999" }}>No song loaded. Start an analysis to use the player.</p> | ||
| <section className="p-6"> | ||
| <h2 className="text-xl font-black text-white">{title}</h2> | ||
| <p className="mt-2 text-sm leading-6 text-slate-400">{t("firstEntranceNeedsSong")}</p> | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <section style={{ padding: "24px" }}> | ||
| <h2>{title}</h2> | ||
| <div | ||
| style={{ | ||
| padding: "16px", | ||
| backgroundColor: "#fafafa", | ||
| borderRadius: "8px", | ||
| border: "1px solid #e8e8e8", | ||
| }} | ||
| > | ||
| <div style={{ marginBottom: "12px" }}> | ||
| <strong>{song.title}</strong> | ||
| <span style={{ color: "#666", marginLeft: "8px" }}> | ||
| {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} | ||
| </span> | ||
| </div> | ||
| <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}> | ||
| {song.sections.map((section) => ( | ||
| <span | ||
| key={section.id} | ||
| style={{ | ||
| padding: "4px 12px", | ||
| borderRadius: "16px", | ||
| backgroundColor: "#fff", | ||
| border: "1px solid #d9d9d9", | ||
| fontSize: "0.85em", | ||
| textTransform: "capitalize", | ||
| }} | ||
| > | ||
| {section.label} | ||
| </span> | ||
| ))} | ||
| </div> | ||
| <div style={{ marginTop: "16px", color: "#999", fontSize: "0.85em" }}> | ||
| Audio playback requires the desktop app with a local audio source. | ||
| </div> | ||
| </div> | ||
| <section className="space-y-4 p-6"> | ||
| <h2 className="text-xl font-black text-white">{title}</h2> | ||
| <FirstEntranceCallout | ||
| song={song} | ||
| actionMode="callback-only" | ||
| onHearEntrance={onPlayFromSeconds} | ||
| /> | ||
| </section> | ||
| ); | ||
|
seonghobae marked this conversation as resolved.
Resolved
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { FirstEntranceCallout } from "./FirstEntranceCallout"; | ||
|
|
||
| describe("FirstEntranceCallout accessible name", () => { | ||
| it("keeps the concise first-entrance region name when no entrance exists", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| song.sections = []; | ||
|
|
||
| render(<FirstEntranceCallout song={song} />); | ||
|
|
||
| expect( | ||
| screen.getByRole("complementary", { name: "Tonight's first entrance" }) | ||
| ).toBeTruthy(); | ||
| expect( | ||
| screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.") | ||
| ).toBeTruthy(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstEntranceCallout } from "./FirstEntranceCallout"; | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| document.querySelector("#workspace-song-structure-grid")?.remove(); | ||
| }); | ||
|
|
||
| /** Mount the renderer-owned song-structure target used by workspace navigation. */ | ||
| function appendSongStructureTarget() { | ||
| const grid = document.createElement("div"); | ||
| grid.id = "workspace-song-structure-grid"; | ||
| const target = document.createElement("div"); | ||
| const scrollIntoView = vi.fn(); | ||
| Object.defineProperty(target, "scrollIntoView", { | ||
| configurable: true, | ||
| value: scrollIntoView | ||
| }); | ||
| grid.appendChild(target); | ||
| document.body.appendChild(grid); | ||
| return scrollIntoView; | ||
| } | ||
|
|
||
| describe("FirstEntranceCallout reduced-motion navigation", () => { | ||
| it("avoids smooth scrolling when the user requests reduced motion", () => { | ||
| const scrollIntoView = appendSongStructureTarget(); | ||
| vi.stubGlobal( | ||
| "matchMedia", | ||
| vi.fn((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() | ||
| })) | ||
| ); | ||
|
|
||
| render(<FirstEntranceCallout song={createDemoRehearsalSong()} />); | ||
| fireEvent.click( | ||
| screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" }) | ||
| ); | ||
|
|
||
| expect(scrollIntoView).toHaveBeenCalledWith({ | ||
| block: "nearest", | ||
| behavior: "auto" | ||
| }); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.