-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workspace): guide tonight's first intro on map and player #943
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
832109a
ff219f5
ebfd15b
990f6f5
1e1a937
a41090c
85c425d
9f4dd36
862cae4
9a7a465
8b48181
d3d3c40
0cb60b5
93f49dc
b35424c
c9180f7
26b7f10
cbea560
f06234b
37f6de8
36a93c9
41063fe
785dbef
3f6217a
bd5b1ea
8b1e402
b473ab5
0f62b37
45a877b
8cbe4b3
ff9ab44
9b7a1c6
3984ba0
3e81d49
96985e1
6d38625
14db3d7
7ad0956
b627d31
4855357
396d003
d22b6ca
39bad56
ff5e47d
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,143 @@ | ||
| 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 songWithIntro() { | ||
| const song = createDemoRehearsalSong(); | ||
| const verse = song.sections[0]!; | ||
| const intro = structuredClone(verse); | ||
| intro.id = "intro-1"; | ||
| intro.label = "intro"; | ||
| intro.timeRange = { start: 0, end: 8 }; | ||
| intro.roles = [ | ||
| { | ||
| ...verse.roles[0]!, | ||
| id: "drums", | ||
| name: "Drums", | ||
| rehearsalPriority: "high" | ||
| } | ||
| ]; | ||
| intro.partGraph = [ | ||
| { | ||
| role_id: "drums", | ||
| is_active: true, | ||
| handoff_to: [], | ||
| handoff_from: [] | ||
| } | ||
| ]; | ||
| song.sections = [intro, verse]; | ||
| 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 intro from this player.") | ||
| ).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("keeps the intro hear action unavailable without a player playback callback", () => { | ||
| render(<PlayerFeature title="Player" song={songWithIntro()} />); | ||
|
|
||
| expect(screen.queryByRole("button", { name: "Hear Drums start at 0:00" })).toBeNull(); | ||
| expect(screen.getByText("Drums starts the intro at 0:00.")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("delegates the intro hear action to the owning player callback", () => { | ||
| const onPlayFromSeconds = vi.fn(); | ||
| render(<PlayerFeature title="Player" song={songWithIntro()} onPlayFromSeconds={onPlayFromSeconds} />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Hear Drums start at 0:00" })); | ||
|
|
||
| expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); | ||
| expect(onPlayFromSeconds).toHaveBeenCalledWith(0); | ||
| }); | ||
|
|
||
| it("localizes the section count, labels, and playback hint instead of mixing English player copy", () => { | ||
| vi.stubGlobal("navigator", { language: "ko-KR" }); | ||
| try { | ||
| render(<PlayerFeature title="Player" song={songWithIntro()} />); | ||
| expect(screen.getByText("2개 섹션")).toBeTruthy(); | ||
| expect(screen.queryByText("2 sections")).toBeNull(); | ||
| expect(screen.getByText("인트로")).toBeTruthy(); | ||
| expect(screen.queryByText("intro")).toBeNull(); | ||
| expect(screen.getByText("오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.")).toBeTruthy(); | ||
| expect(screen.queryByText("Audio playback requires the desktop app with a local audio source.")).toBeNull(); | ||
| } finally { | ||
| vi.unstubAllGlobals(); | ||
| } | ||
| }); | ||
|
|
||
| it("renders a safe empty summary when the runtime section collection is not an array", () => { | ||
| const song = songWithIntro(); | ||
| (song as unknown as { sections: unknown }).sections = null; | ||
|
|
||
| render(<PlayerFeature title="Player" song={song} />); | ||
|
|
||
| expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); | ||
| expect(screen.getByText("0 sections")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("renders a safe empty summary when the runtime section collection is sparse", () => { | ||
| const song = songWithIntro(); | ||
| 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 intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); | ||
| expect(screen.getByText("0 sections")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("renders a safe empty summary when sections is a throwing own accessor", () => { | ||
| const song = songWithIntro(); | ||
| Object.defineProperty(song, "sections", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| throw new Error("sections getter must stay data"); | ||
| } | ||
| }); | ||
|
|
||
| expect(() => render(<PlayerFeature title="Player" song={song} />)).not.toThrow(); | ||
| expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); | ||
| expect(screen.getByText("0 sections")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("renders a safe empty summary when a song Proxy throws on sections access", () => { | ||
| const song = songWithIntro(); | ||
| const proxiedSong = new Proxy(song, { | ||
| get(target, key, receiver) { | ||
| if (key === "sections") { | ||
| throw new Error("sections get trap"); | ||
| } | ||
| return Reflect.get(target, key, receiver); | ||
| } | ||
| }); | ||
|
|
||
| expect(() => render(<PlayerFeature title="Player" song={proxiedSong} />)).not.toThrow(); | ||
| expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); | ||
| expect(screen.getByText("0 sections")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("omits malformed runtime section elements without crashing the player summary", () => { | ||
| const song = songWithIntro(); | ||
| song.sections = [null, song.sections[1]!] 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 = songWithIntro(); | ||
| (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(); | ||
| }); | ||
| }); |
|
devin-ai-integration[bot] marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,54 +1,131 @@ | ||
| import type { RehearsalSong } from "@bandscope/shared-types"; | ||
| import { | ||
| SECTION_FORM_LABELS, | ||
| type RehearsalSection, | ||
| type RehearsalSong, | ||
| type SectionFormLabel | ||
| } from "@bandscope/shared-types"; | ||
| import { FirstIntroCallout } from "../workspace/FirstIntroCallout"; | ||
| import { createTranslator, detectPreferredLocale, translateSectionFormLabel } 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; | ||
| }; | ||
|
|
||
| /** Read an own data property without invoking accessors or letting descriptor traps escape. */ | ||
| function readOwnDataProperty(value: object, key: PropertyKey): unknown { | ||
| try { | ||
| const descriptor = Object.getOwnPropertyDescriptor(value, key); | ||
| if (!descriptor || !("value" in descriptor)) { | ||
| return undefined; | ||
| } | ||
| Reflect.get(value, key); | ||
| return descriptor.value; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** 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>; | ||
|
Comment on lines
+24
to
+35
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. 📝 Info: Intentional read asymmetry between player and callout The player's Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| 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 invoking an untrusted collection accessor. */ | ||
| function playerSummarySections(song: RehearsalSong): RehearsalSection[] { | ||
| const sections = readOwnDataProperty(song, "sections"); | ||
| if (!Array.isArray(sections)) { | ||
| return []; | ||
| } | ||
| try { | ||
| const length = Number(sections.length); | ||
| if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { | ||
| return []; | ||
| } | ||
| const keys = Object.keys(sections); | ||
| if (keys.length !== length || !keys.every((key, index) => key === String(index))) { | ||
| return []; | ||
| } | ||
| return sections.filter(isPlayerSummarySection); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| /** Player surface that names tonight's first labeled intro and delegates playback to the owning player. */ | ||
| export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { | ||
| const locale = detectPreferredLocale(); | ||
| const t = createTranslator(locale); | ||
|
|
||
| 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("firstIntroNeedsSong")}</p> | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| const sections = playerSummarySections(song); | ||
| const rawSongTitle = readOwnDataProperty(song, "title"); | ||
| const songTitle = typeof rawSongTitle === "string" ? rawSongTitle : ""; | ||
| const calloutSong = sections.length === 0 | ||
| ? ({ sections: [] } as unknown as RehearsalSong) | ||
| : song; | ||
| const sectionCountLabel = t( | ||
| sections.length === 1 | ||
| ? "metricConfidenceSectionCountSingular" | ||
| : "metricConfidenceSectionCountPlural" | ||
| ).replace("{count}", String(sections.length)); | ||
|
|
||
| return ( | ||
| <section style={{ padding: "24px" }}> | ||
| <h2>{title}</h2> | ||
| <FirstIntroCallout song={calloutSong} actionMode="callback-only" onHearIntro={onPlayFromSeconds} /> | ||
| <div | ||
| style={{ | ||
|
seonghobae marked this conversation as resolved.
|
||
| 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"} | ||
| {sectionCountLabel} | ||
| </span> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| </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} | ||
| {translateSectionFormLabel(locale, section.label)} | ||
| </span> | ||
| ))} | ||
| </div> | ||
| <div style={{ marginTop: "16px", color: "#999", fontSize: "0.85em" }}> | ||
| Audio playback requires the desktop app with a local audio source. | ||
| {t("playerPlaybackRequiresDesktop")} | ||
| </div> | ||
| </div> | ||
| </section> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstIntroCallout } from "./FirstIntroCallout"; | ||
|
|
||
| describe("FirstIntroCallout Korean role copy", () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("keeps vowel-ending dynamic role names particle-safe before and after the intro action", () => { | ||
| vi.stubGlobal("navigator", { language: "ko-KR" }); | ||
| const song = createDemoRehearsalSong(); | ||
| const seed = song.sections[0]!; | ||
| const intro = structuredClone(seed); | ||
| intro.id = "intro-particle"; | ||
| intro.label = "intro"; | ||
| intro.timeRange = { start: 0, end: 8 }; | ||
| intro.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }]; | ||
| intro.partGraph = [ | ||
| { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] } | ||
| ]; | ||
| song.sections = [intro]; | ||
|
|
||
| const onHearIntro = vi.fn(); | ||
| render(<FirstIntroCallout song={song} actionMode="callback-only" onHearIntro={onHearIntro} />); | ||
|
|
||
| expect(screen.getByText("0:00 인트로에서 피아노 파트가 시작합니다.")).toBeTruthy(); | ||
| expect(screen.queryByText(/피아노이/)).toBeNull(); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "0:00에 피아노 시작 듣기" })); | ||
|
|
||
| expect(onHearIntro).toHaveBeenCalledWith(0); | ||
| expect(screen.getByText("0:00에서 피아노 파트와 함께 카운트인하세요. 같이 시작하세요.")).toBeTruthy(); | ||
| expect(screen.queryByText(/피아노과/)).toBeNull(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.