-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workspace): guide tonight's first labeled handoff on map and player #937
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
912b93f
151feab
658f5c0
048dbfb
9cfb68f
fa558f2
cda8be6
b093e9f
fb9e9d9
197edd3
b80c2b8
5a1ae88
1e1bb46
712ac35
516574b
3a68b9a
0f19f4f
c368517
50cae0c
2643c0c
ae18668
90f29ba
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,97 @@ | ||
| 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"; | ||
|
|
||
| function songWithHandoff() { | ||
| const song = createDemoRehearsalSong(); | ||
| const verse = song.sections[0]!; | ||
| const handoff = structuredClone(verse); | ||
| handoff.id = "handoff-1"; | ||
| handoff.label = "handoff"; | ||
| handoff.timeRange = { start: 22, end: 24 }; | ||
| handoff.roles = [ | ||
| { | ||
| ...verse.roles[2]!, | ||
| id: "lead-vocal", | ||
| name: "Lead Vocal", | ||
| rehearsalPriority: "high" | ||
| } | ||
| ]; | ||
| handoff.partGraph = [ | ||
| { | ||
| role_id: "lead-vocal", | ||
| is_active: true, | ||
| handoff_to: [], | ||
| handoff_from: [] | ||
| } | ||
| ]; | ||
| song.sections = [verse, handoff]; | ||
| return song; | ||
| } | ||
|
|
||
| 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 handoff from this player.") | ||
| ).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("keeps the handoff hear action unavailable without a player playback callback", () => { | ||
| render(<PlayerFeature title="Player" song={songWithHandoff()} />); | ||
|
|
||
| expect(screen.queryByRole("button", { name: "Hear Lead Vocal pass at 0:22" })).toBeNull(); | ||
| expect(screen.getByText("Lead Vocal passes the handoff at 0:22.")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("delegates the handoff hear action to the owning player callback", () => { | ||
| const onPlayFromSeconds = vi.fn(); | ||
| render(<PlayerFeature title="Player" song={songWithHandoff()} onPlayFromSeconds={onPlayFromSeconds} />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal pass at 0:22" })); | ||
|
|
||
| expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); | ||
| expect(onPlayFromSeconds).toHaveBeenCalledWith(22); | ||
| }); | ||
|
|
||
| it("renders a safe empty summary when the runtime section collection is not an array", () => { | ||
| const song = songWithHandoff(); | ||
| (song as unknown as { sections: unknown }).sections = null; | ||
|
|
||
| render(<PlayerFeature title="Player" song={song} />); | ||
|
|
||
| expect(screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.")).toBeTruthy(); | ||
| expect(screen.getByText("0 sections")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("renders a safe empty summary when the runtime section collection is sparse", () => { | ||
| const song = songWithHandoff(); | ||
| const sparseSections: typeof song.sections = new Array(2); | ||
| sparseSections[1] = song.sections[1]!; | ||
| song.sections = sparseSections; | ||
|
|
||
| render(<PlayerFeature title="Player" song={song} />); | ||
|
|
||
| expect(screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.")).toBeTruthy(); | ||
| expect(screen.getByText("0 sections")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("omits malformed runtime section elements without crashing the player summary", () => { | ||
| const song = songWithHandoff(); | ||
| song.sections = [null, song.sections[0]!] as unknown as typeof song.sections; | ||
|
|
||
| render(<PlayerFeature title="Player" song={song} />); | ||
|
|
||
| expect(screen.getByText("1 section")).toBeTruthy(); | ||
| expect(screen.getByText("verse")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("does not pass an object-valued runtime song title into React copy", () => { | ||
| const song = songWithHandoff(); | ||
| (song as unknown as { title: unknown }).title = { unsafe: "not-copy" }; | ||
|
|
||
| expect(() => render(<PlayerFeature title="Player" song={song} />)).not.toThrow(); | ||
| expect(screen.queryByText("not-copy")).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,46 +1,96 @@ | ||
| import type { RehearsalSong } from "@bandscope/shared-types"; | ||
| import { | ||
| SECTION_FORM_LABELS, | ||
| type RehearsalSection, | ||
| type RehearsalSong, | ||
| type SectionFormLabel | ||
| } from "@bandscope/shared-types"; | ||
| import { FirstHandoffCallout } from "../workspace/FirstHandoffCallout"; | ||
| 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; | ||
| }; | ||
|
|
||
| /** Return whether one runtime section is safe to summarize in the player. */ | ||
| function isPlayerSummarySection(value: unknown): value is RehearsalSection { | ||
| if (value === null || typeof value !== "object") { | ||
| return false; | ||
| } | ||
| const section = value as Partial<RehearsalSection>; | ||
| return ( | ||
| typeof section.id === "string" && | ||
| section.id.trim().length > 0 && | ||
| typeof section.label === "string" && | ||
| SECTION_FORM_LABELS.includes(section.label as SectionFormLabel) | ||
| ); | ||
| } | ||
|
|
||
| /** Return dense, individually valid sections without trusting runtime collection metadata. */ | ||
| function playerSummarySections(song: RehearsalSong): RehearsalSection[] { | ||
| const sections = song.sections as unknown; | ||
| if (!Array.isArray(sections)) { | ||
| return []; | ||
| } | ||
| const length = Number(sections.length); | ||
| if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { | ||
| return []; | ||
| } | ||
| for (let index = 0; index < length; index += 1) { | ||
| if (!(index in sections)) { | ||
| return []; | ||
| } | ||
| } | ||
| return sections.filter(isPlayerSummarySection); | ||
| } | ||
|
|
||
| /** Player surface that names tonight's first labeled handoff 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> | ||
| <p style={{ color: "#999" }}>{t("firstHandoffNeedsSong")}</p> | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| const sections = playerSummarySections(song); | ||
| const songTitle = typeof song.title === "string" ? song.title : ""; | ||
|
|
||
| return ( | ||
| <section style={{ padding: "24px" }}> | ||
| <h2>{title}</h2> | ||
| <FirstHandoffCallout song={song} actionMode="callback-only" onHearHandoff={onPlayFromSeconds} /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Player Hear action has no production caller
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| <div | ||
| style={{ | ||
| padding: "16px", | ||
| backgroundColor: "#fafafa", | ||
| borderRadius: "8px", | ||
| border: "1px solid #e8e8e8", | ||
| marginTop: "16px" | ||
| }} | ||
| > | ||
| <div style={{ marginBottom: "12px" }}> | ||
| <strong>{song.title}</strong> | ||
| <strong>{songTitle}</strong> | ||
| <span style={{ color: "#666", marginLeft: "8px" }}> | ||
| {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} | ||
| {sections.length} {sections.length === 1 ? "section" : "sections"} | ||
| </span> | ||
| </div> | ||
| <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}> | ||
| {song.sections.map((section) => ( | ||
| {sections.map((section, sectionIndex) => ( | ||
| <span | ||
| key={section.id} | ||
| key={`${section.id}-${sectionIndex}`} | ||
| style={{ | ||
| padding: "4px 12px", | ||
| borderRadius: "16px", | ||
| backgroundColor: "#fff", | ||
| border: "1px solid #d9d9d9", | ||
| fontSize: "0.85em", | ||
| textTransform: "capitalize", | ||
| textTransform: "capitalize" | ||
| }} | ||
| > | ||
| {section.label} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import type { RehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { FirstHandoffCallout } from "./FirstHandoffCallout"; | ||
|
|
||
| /** Cast runtime input through the static song contract to exercise the renderer trust boundary. */ | ||
| function runtimeSong(value: unknown): RehearsalSong { | ||
| return value as RehearsalSong; | ||
| } | ||
|
|
||
| describe("FirstHandoffCallout malformed song root", () => { | ||
| it("renders unavailable guidance instead of crashing when the runtime song root is null", () => { | ||
| expect(() => render(<FirstHandoffCallout song={runtimeSong(null)} />)).not.toThrow(); | ||
| expect(screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.")).toBeTruthy(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstHandoffCallout } from "./FirstHandoffCallout"; | ||
|
|
||
| function songWithHandoff() { | ||
| const song = createDemoRehearsalSong(); | ||
| const verse = song.sections[0]!; | ||
| const handoff = structuredClone(verse); | ||
| handoff.id = "handoff-1"; | ||
| handoff.label = "handoff"; | ||
| handoff.timeRange = { start: 22, end: 24 }; | ||
| handoff.roles = [ | ||
| { | ||
| ...verse.roles[2]!, | ||
| id: "lead-vocal", | ||
| name: "Lead Vocal", | ||
| rehearsalPriority: "high" | ||
| } | ||
| ]; | ||
| handoff.partGraph = [ | ||
| { | ||
| role_id: "lead-vocal", | ||
| is_active: true, | ||
| handoff_to: [], | ||
| handoff_from: [] | ||
| } | ||
| ]; | ||
| song.sections = [verse, handoff]; | ||
| return song; | ||
| } | ||
|
|
||
| describe("FirstHandoffCallout reduced motion", () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("scrolls immediately when the operating system requests reduced motion", () => { | ||
| const matchMedia = vi.fn().mockReturnValue({ matches: true }); | ||
| vi.stubGlobal("matchMedia", matchMedia); | ||
|
|
||
| const grid = document.createElement("div"); | ||
| grid.dataset.testid = "song-structure-grid"; | ||
| const first = document.createElement("div"); | ||
| const target = document.createElement("div"); | ||
| const scrollIntoView = vi.fn(); | ||
| Object.defineProperty(target, "scrollIntoView", { | ||
| configurable: true, | ||
| value: scrollIntoView | ||
| }); | ||
| grid.appendChild(first); | ||
| grid.appendChild(target); | ||
| document.body.appendChild(grid); | ||
|
|
||
| render(<FirstHandoffCallout song={songWithHandoff()} />); | ||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal handoff at 0:22" })); | ||
|
|
||
| expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); | ||
| expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); | ||
| grid.remove(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.