diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..9948eeba7 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -313,10 +313,10 @@ describe("App", () => { await waitFor(() => { expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy(); }); - expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy(); + const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); + expect(within(timelineRegion).getByText(/verse · 0:10–0:30/i)).toBeTruthy(); expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy(); expect(screen.queryByText(/Mock-board/i)).toBeNull(); - const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); expect(timelineRegion.className).toContain("overflow-x-auto"); expect(timelineRegion.getAttribute("tabindex")).toBe("0"); expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull(); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index a32deb8e7..38dbdc089 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SectionRoadmap } from "./SectionRoadmap"; @@ -15,6 +15,7 @@ function setNavigatorLanguage(language: string) { describe("SectionRoadmap", () => { afterEach(() => { setNavigatorLanguage(originalLanguage); + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -72,4 +73,97 @@ describe("SectionRoadmap", () => { expect(card?.getAttribute("tabindex")).toBe("-1"); expect(card?.id).not.toContain(song.sections[0].id); }); + + it("names tonight's count-in on the first section card", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const countIn = screen.getByRole("button", { + name: "Count in verse from 0:10 to 0:30 at tonight's tempo" + }); + expect(countIn).toBeTruthy(); + expect((countIn as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByText("Count in verse · 0:10–0:30")).toBeTruthy(); + }); + + it("counts four beats at the analyzed tempo then names the first pass", () => { + vi.useFakeTimers(); + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click( + screen.getByRole("button", { name: "Count in verse from 0:10 to 0:30 at tonight's tempo" }) + ); + + expect(screen.getByLabelText("Count-in beat 1 of 4")).toBeTruthy(); + expect(screen.getByText("Counting in verse · 1")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByLabelText("Count-in beat 2 of 4")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByLabelText("Count-in beat 3 of 4")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByLabelText("Count-in beat 4 of 4")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByText("Counted in verse · 0:10–0:30. Start the first pass.")).toBeTruthy(); + }); + + it("fails closed when tonight's song has no tempo", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + delete song.tempo; + + render(); + + const countIn = screen.getByRole("button", { name: "Add a tempo before counting in tonight." }); + expect((countIn as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(countIn); + expect(screen.queryByText(/Counting in/)).toBeNull(); + }); + + it("counts in the renderer-selected section even when analysis ids collide", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections.push({ + ...song.sections[0]!, + id: song.sections[0]!.id, + label: "chorus", + timeRange: { start: 30, end: 50 } + }); + + render(); + + expect( + screen.getByRole("button", { + name: "Count in chorus from 0:30 to 0:50 at tonight's tempo" + }) + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Count in verse from 0:10 to 0:30 at tonight's tempo" }) + ).toBeNull(); + }); + + it("localizes tonight's count-in action", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByRole("button", { name: "오늘 템포로 verse 0:10부터 0:30까지 카운트인" })).toBeTruthy(); + expect(screen.getByText("verse · 0:10–0:30 카운트인")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 97ca6664e..433f09bf8 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -1,12 +1,15 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; -import { useId, useMemo } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { AlertCircle, CheckCircle2, Music2, Wand2, Lightbulb, Info } from "lucide-react"; +const COUNT_IN_BEATS = 4; + interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles @@ -14,13 +17,110 @@ interface SectionRoadmapProps { loopedSectionIndex?: number | null; } -/** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIndex = null }: SectionRoadmapProps) { +/** Format a timeline instant as m:ss for rehearsal cards. */ +function formatTimelineTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Fill count-in copy with a section label and its start–end window. */ +function countInCopy( + template: string, + section: RehearsalSong["sections"][number] +): string { + return template + .replace("{label}", section.label) + .replace("{start}", formatTimelineTime(section.timeRange.start)) + .replace("{end}", formatTimelineTime(section.timeRange.end)); +} + +/** Return milliseconds per beat when the analyzed tempo can drive a count-in. */ +function countInBeatMs(tempo: number | undefined): number | null { + if (typeof tempo !== "number" || !Number.isFinite(tempo) || tempo <= 0) { + return null; + } + + return 60_000 / tempo; +} + +/** Return the renderer-owned position of the section this player should count in tonight. */ +function firstCountInSectionIndex( + song: RehearsalSong, + activeRole: string | null, + loopedSectionIndex: number | null +): number | undefined { + if ( + loopedSectionIndex !== null && + Number.isSafeInteger(loopedSectionIndex) && + loopedSectionIndex >= 0 && + loopedSectionIndex < song.sections.length + ) { + return loopedSectionIndex; + } + + if (activeRole) { + const forRoleIndex = song.sections.findIndex((section) => + section.roles.some((role) => role.id === activeRole) + ); + if (forRoleIndex !== -1) { + return forRoleIndex; + } + } + + return song.sections.length > 0 ? 0 : undefined; +} + +/** Render the rehearsal section roadmap and optional tempo-driven count-in. */ +export function SectionRoadmap({ + song, + activeRole, + onSongUpdate, + loopedSectionIndex = null +}: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const countInSectionIndex = firstCountInSectionIndex(song, activeRole, loopedSectionIndex); + const countInSection = + countInSectionIndex === undefined ? undefined : song.sections[countInSectionIndex]; + const beatMs = countInBeatMs(song.tempo); + const [countInPhase, setCountInPhase] = useState<"idle" | "counting" | "ready">("idle"); + const [countInBeat, setCountInBeat] = useState(0); + + useEffect(() => { + setCountInPhase("idle"); + setCountInBeat(0); + }, [countInSectionIndex]); + + useEffect(() => { + if (countInPhase !== "counting") { + return; + } + + if (beatMs === null) { + setCountInPhase("idle"); + setCountInBeat(0); + return; + } + + if (countInBeat >= COUNT_IN_BEATS) { + const readyTimer = window.setTimeout(() => { + setCountInPhase("ready"); + }, beatMs); + return () => window.clearTimeout(readyTimer); + } + + const nextTimer = window.setTimeout(() => { + setCountInBeat((current) => current + 1); + }, beatMs); + return () => window.clearTimeout(nextTimer); + }, [beatMs, countInBeat, countInPhase]); - /** Documented. */ + /** Build the localized accessible label for a role's chord-edit control. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { return t("chordEditAriaLabel") .replace("{roleName}", role.name) @@ -28,7 +128,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn .replace("{chord}", role.harmony.chord); }; - /** Documented. */ + /** Apply a user-entered chord override to the matching role. */ const handleChordEdit = (sectionId: string, role: RehearsalRole) => { if (!onSongUpdate) return; const newChord = window.prompt(t("chordEditPrompt"), role.harmony.chord); @@ -74,20 +174,31 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn if (changed) onSongUpdate(updatedSong); }; - /** Documented. */ + + /** Return the visual treatment for a rehearsal priority. */ const getPriorityColor = (priority: string) => { if (priority === "high") return "border-rose-400 bg-rose-400/[0.08] shadow-[0_0_30px_rgba(251,113,133,0.10)]"; if (priority === "medium") return "border-amber-300 bg-amber-300/[0.08] shadow-[0_0_30px_rgba(252,211,77,0.08)]"; return "border-emerald-300 bg-emerald-300/[0.08] shadow-[0_0_30px_rgba(110,231,183,0.08)]"; }; - /** Documented. */ + /** Return the icon that communicates rehearsal priority. */ const getPriorityIcon = (priority: string) => { if (priority === "high") return