diff --git a/.jules/bolt.md b/.jules/bolt.md index d54cf10fc..2a88db5bc 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -61,3 +61,7 @@ ## 2026-07-13 - Array.from mapping optimization **Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components. **Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations. + +## 2026-09-05 - Min/max scans remain linear +**Learning:** Replacing `Array.prototype.reduce()` with an indexed loop for an extremum search can remove callback dispatch, but both forms still scan every element and remain O(N). A simple `if (value > max)` is not automatically semantics-preserving: it ignores `NaN`, whereas `Math.max` propagates it. A microbenchmark on an arbitrary array does not establish buyer-path improvement. +**Action:** Preserve the existing reduction semantics first, then profile representative GrooveMap transcription sizes in the actual Chromium/Electron render path before claiming material latency, heap, or GC gains. diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..72e69fd45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- After a part is selected, the role strip must name tonight's setup from `setupNote` / transposition / simplification and point at the earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op, and do not invent Stem Lab isolation here. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..1aa737eed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-18 ## Brand source @@ -68,6 +68,7 @@ Last updated: 2026-03-11 - BandScope is not only a shell around chord labels, stems, and ranges. - The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority. - These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer. +- Ready-workspace role-strip setup must arm tonight's `setupNote` (then transposition / simplification) and name the first analyzed entrance on the groove map. Isolation playback stays out of this lane. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index 34331fb86..57606c642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- The ready workspace can set up tonight's selected part from the analyzed setup cue and name the first entrance on the groove map, instead of leaving `Transcribe Bass` inert. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -15,7 +16,17 @@ ### Fixed +- Bind an activated part setup to its song, project and exact cue/start evidence; clear stale status and entrance emphasis after replacement while preserving unrelated practice-progress edits. +- Keep Groove Map loading truthfully indeterminate unless real progress exists, and expose Cancel only when a cancellation callback is actually available. +- Keep disabled Stem Player controls discoverable by their visible labels for + assistive technology and speech input while retaining the translated + unavailable reason in each accessible name and tooltip. +- Reject sentinel, malformed, and inverted setup ranges before they can enable + a buyer-visible rehearsal action or render as playable evidence. +- Localize Groove Map states and the unavailable Loop control through the + owned translation boundary, with literal fail-closed placeholder handling. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. +- Keep the Groove Map role-aware for non-bass parts, preserve a visible keyboard focus indicator, emit only one first-entrance DOM anchor for simultaneous notes, and fail closed when setup lacks both an analyzed entrance and a playable range. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..5b9d65151 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. +After a part is selected, the role-strip setup control must name tonight's setup cue and the earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op. + Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. ## Common commands diff --git a/apps/desktop/src/features/workspace/GrooveMap.test.tsx b/apps/desktop/src/features/workspace/GrooveMap.test.tsx new file mode 100644 index 000000000..723f0aac8 --- /dev/null +++ b/apps/desktop/src/features/workspace/GrooveMap.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { GrooveMap, maximumNoteOffset } from "./GrooveMap"; + +describe("maximumNoteOffset", () => { + it("preserves the ten-second floor and finite maximum", () => { + expect(maximumNoteOffset([])).toBe(10); + expect( + maximumNoteOffset([ + { onset: 0, offset: 1.5, pitch: "C4", velocity: 100 }, + { onset: 1.5, offset: 12, pitch: "D4", velocity: 100 } + ]) + ).toBe(12); + }); + + it("preserves Math.max non-finite semantics until shared admission rejects them", () => { + expect( + Number.isNaN( + maximumNoteOffset([ + { onset: 0, offset: Number.NaN, pitch: "C4", velocity: 100 }, + { onset: 1, offset: 12, pitch: "D4", velocity: 100 } + ]) + ) + ).toBe(true); + expect( + maximumNoteOffset([ + { onset: 0, offset: Number.POSITIVE_INFINITY, pitch: "C4", velocity: 100 } + ]) + ).toBe(Number.POSITIVE_INFINITY); + }); +}); + +describe("GrooveMap maximum-offset geometry", () => { + it("uses an offset beyond the ten-second floor for rendered note geometry", () => { + render( + + ); + + expect(screen.getByTitle("D4 (10.00s - 20.00s)")).toHaveStyle({ + left: "50%", + width: "50%" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx index 2745d4d79..ccb92b3d8 100644 --- a/apps/desktop/src/features/workspace/GrooveMap.tsx +++ b/apps/desktop/src/features/workspace/GrooveMap.tsx @@ -2,23 +2,36 @@ import { memo, useMemo } from "react"; import type { TranscriptionNote } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; +import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n"; const EMPTY_NOTES: TranscriptionNote[] = []; -/** Documented. */ +/** Preserve the former reduction semantics while avoiding reducer callback dispatch. */ +function maximumNoteOffset(notes: readonly TranscriptionNote[]): number { + let max = 10; + for (let i = 0; i < notes.length; i++) { + // Keep Math.max here: shared timing admission does not yet reject every non-finite value. + // A simple `>` comparison would therefore silently change NaN handling at this UI boundary. + max = Math.max(max, notes[i]!.offset); + } + return max; +} + +/** Inputs for the selected role's rehearsal groove map. */ interface GrooveMapProps { notes?: TranscriptionNote[]; isLoading?: boolean; + entranceOnset?: number; + roleName: string; + onCancel?: () => void; } -/** Documented. */ -function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { +/** Render the selected role's transcription and optional first-entrance emphasis. */ +function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCancel }: GrooveMapProps) { const renderedNotes = notes ?? EMPTY_NOTES; + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - // Find max offset to determine timeline width - const maxTime = useMemo(() => { - return renderedNotes.reduce((max, n) => Math.max(max, n.offset), 10); - }, [renderedNotes]); + const maxTime = useMemo(() => maximumNoteOffset(renderedNotes), [renderedNotes]); // Unique pitches to determine vertical lanes (avoiding 88-key piano roll) const uniquePitches = useMemo(() => { @@ -36,6 +49,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { return map; }, [uniquePitches]); + const entranceIndex = useMemo(() => { + if (entranceOnset === undefined) { + return -1; + } + return renderedNotes.findIndex((note) => note.onset === entranceOnset); + }, [entranceOnset, renderedNotes]); + if (isLoading) { return (
- + {onCancel ? ( + + ) : null}
); } if (renderedNotes.length === 0) { return ( -
- No bass line transcription yet. Use it when you want to check the groove before rehearsal. +
+ {fillTranslation(t("grooveMapEmpty"), { role: roleName })}
); } @@ -68,13 +94,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" role="region" tabIndex={0} - aria-label="Bass transcription groove map" + aria-label={fillTranslation(t("grooveMapRegionLabel"), { role: roleName })} >
- Transcription complete. {renderedNotes.length} notes analyzed. + {fillTranslation(t("grooveMapComplete"), { count: renderedNotes.length })}

- {renderedNotes.length} notes mapped for rehearsal + {fillTranslation(t("grooveMapMapped"), { count: renderedNotes.length })}

@@ -95,20 +121,26 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { const leftPercent = (note.onset / maxTime) * 100; const widthPercent = ((note.offset - note.onset) / maxTime) * 100; const noteLabel = `${note.pitch} (${note.onset.toFixed(2)}s - ${note.offset.toFixed(2)}s)`; + const isEntrance = entranceOnset !== undefined && note.onset === entranceOnset; return (
- {noteLabel} + {isEntrance ? fillTranslation(t("grooveMapEntranceAnnouncement"), { note: noteLabel }) : noteLabel}
); @@ -120,4 +152,4 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { const GrooveMap = memo(GrooveMapComponent); -export { GrooveMap }; +export { GrooveMap, maximumNoteOffset }; diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx new file mode 100644 index 000000000..a10f2d38e --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx @@ -0,0 +1,726 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { useState } from "react"; +import { flushSync } from "react-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GrooveMap } from "./GrooveMap"; +import { Workspace } from "./Workspace"; + +/** Replace every copy of one rehearsal role so cross-section aggregation stays deterministic. */ +function replaceRole(song: ReturnType, roleId: string, replace: (role: (typeof song.sections)[number]["roles"][number]) => (typeof song.sections)[number]["roles"][number]) { + song.sections = song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => (role.id === roleId ? replace(role) : role)) + })); +} + +describe("Workspace review regressions", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("clears an armed setup when another song reuses the selected role id", () => { + const song = createDemoRehearsalSong(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toBeTruthy(); + + rerender(); + + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-role-setup")?.className).not.toContain("ring-amber-300/70"); + }); + + it("clears an armed setup when its cue changes without a new activation", () => { + const song = createDemoRehearsalSong(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toBeTruthy(); + + const changedSong = structuredClone(song); + changedSong.sections[0]!.roles[0]!.setupNote = "Use a softer attack."; + rerender(); + + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-role-setup")?.className).not.toContain("ring-amber-300/70"); + }); + + it("preserves an armed setup across unrelated immutable practice-progress edits", () => { + const song = createDemoRehearsalSong(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + + const changedSong = structuredClone(song); + changedSong.sections[0]!.roles[0]!.practiceProgress = 60; + rerender(); + + expect(screen.getByRole("status")).toBeTruthy(); + expect(document.getElementById("workspace-role-setup")?.className).toContain("ring-amber-300/70"); + }); + + it("does not restore a previous activation after its start evidence changes and returns", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + replaceRole(song, role.id, (current) => ({ ...current, transcription: [ + { pitch: "E2", onset: 1, offset: 2, velocity: 0.7 } + ] })); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: role.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(document.getElementById("workspace-groove-entrance")).toBeTruthy(); + + const changedSong = structuredClone(song); + replaceRole(changedSong, role.id, (current) => ({ ...current, transcription: [ + { pitch: "A2", onset: 3, offset: 4, velocity: 0.7 } + ] })); + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-groove-entrance")).toBeNull(); + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-groove-entrance")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toBeTruthy(); + expect(document.getElementById("workspace-groove-entrance")).toBeTruthy(); + }); + + it("clears setup when source project context changes for the same song", () => { + const song = createDemoRehearsalSong(); + const source = { + projectId: "project-1", sourceMode: "reference" as const, + projectRoot: "/owned/project", cacheRoot: "/owned/cache", tempRoot: "/owned/temp", + source: { sourcePath: "/owned/song.wav", fileName: "song.wav", extension: "wav" as const, fileSizeBytes: 100 } + }; + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toBeTruthy(); + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("clears setup when its playable range disappears", () => { + const song = createDemoRehearsalSong(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + const changedSong = structuredClone(song); + changedSong.sections[0]!.roles[0]!.range = { lowestNote: "", highestNote: "" }; + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByRole("button", { name: /No first entrance or playable range/ })).toBeDisabled(); + }); + + it("requires fresh activation when the selected role name changes and returns", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: role.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toHaveTextContent("Tonight's Bass Guitar setup"); + + const changedSong = structuredClone(song); + replaceRole(changedSong, role.id, (current) => ({ ...current, name: "Low-end Guitar" })); + rerender(); + expect(screen.getByRole("tab", { name: "Low-end Guitar" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("button", { name: /^Set up Low-end Guitar/ })).toBeEnabled(); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-role-setup")).not.toHaveClass("ring-amber-300/70"); + + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toHaveTextContent("Tonight's Bass Guitar setup"); + }); + + it.each([ + ["lower", { lowestNote: "B1", highestNote: "E3" }], + ["upper", { lowestNote: "C#2", highestNote: "G3" }] + ])("requires fresh activation when the playable range's %s bound changes and returns", (_, range) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + replaceRole(song, role.id, (current) => ({ ...current, transcription: undefined })); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: role.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toHaveTextContent("Start in C#2–E3"); + + const changedSong = structuredClone(song); + replaceRole(changedSong, role.id, (current) => ({ ...current, range })); + rerender(); + expect(screen.getByRole("button", { name: /^Set up Bass Guitar/ })).toHaveAccessibleName( + expect.stringContaining(`then start in ${range.lowestNote}–${range.highestNote}`) + ); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-role-setup")).not.toHaveClass("ring-amber-300/70"); + + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toHaveTextContent("Start in C#2–E3"); + }); + + it("requires fresh activation when only the first note offset changes and returns", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + replaceRole(song, role.id, (current) => ({ + ...current, + transcription: [{ pitch: "E2", onset: 1, offset: 2, velocity: 0.7 }] + })); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: role.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + const originalAction = screen.getByRole("button", { name: /^Set up Bass Guitar/ }).getAttribute("aria-label"); + expect(screen.getByRole("status")).toHaveTextContent("Start on E2 from 0:01"); + expect(document.getElementById("workspace-groove-entrance")).toBeTruthy(); + + const changedSong = structuredClone(song); + replaceRole(changedSong, role.id, (current) => ({ + ...current, + transcription: [{ pitch: "E2", onset: 1, offset: 2.5, velocity: 0.7 }] + })); + rerender(); + expect(screen.getByRole("button", { name: /^Set up Bass Guitar/ })).toHaveAttribute("aria-label", originalAction); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-groove-entrance")).toBeNull(); + expect(document.getElementById("workspace-role-setup")).not.toHaveClass("ring-amber-300/70"); + + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-groove-entrance")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toHaveTextContent("Start on E2 from 0:01"); + expect(document.getElementById("workspace-groove-entrance")).toBeTruthy(); + }); + + it("uses the earliest note across unsorted section transcriptions for setup and entrance focus", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + replaceRole(song, role.id, (current) => ({ + ...current, + transcription: [ + { pitch: "A2", onset: 8, offset: 9, velocity: 0.7 }, + { pitch: "E2", onset: 4, offset: 5, velocity: 0.7 } + ] + })); + song.sections.push({ + ...song.sections[0]!, id: "earlier-evidence-review", label: "intro", + roles: [{ ...role, transcription: [{ pitch: "C#2", onset: 1, offset: 2, velocity: 0.7 }] }] + }); + const snapshot = structuredClone(song); + render(); + fireEvent.click(screen.getByRole("tab", { name: role.name })); + const setup = screen.getByRole("button", { name: /^Set up Bass Guitar · then start on C#2 from 0:01/ }); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + + fireEvent.click(setup); + + expect(screen.getByRole("status")).toHaveTextContent("Start on C#2 from 0:01 on the groove map"); + expect(document.getElementById("workspace-groove-entrance")).toHaveAttribute( + "title", expect.stringContaining("C#2") + ); + expect(song).toEqual(snapshot); + }); + + it("keeps unavailable stem controls keyboard-discoverable and cancels their actions", () => { + const song = createDemoRehearsalSong(); + render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + + for (const name of [/^Play stem\./, /^Loop section\./, /^Solo \/ mute others\./]) { + const button = screen.getByRole("button", { name }); + expect(button).toHaveAttribute("aria-disabled", "true"); + expect(button).toBeEnabled(); + button.focus(); + expect(button).toHaveFocus(); + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + expect(fireEvent(button, click)).toBe(false); + expect(click.defaultPrevented).toBe(true); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-groove-entrance")).toBeNull(); + } + }); + + it("clears setup when selecting another role or the full role board", () => { + const song = createDemoRehearsalSong(); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + expect(screen.getByRole("tab", { name: "Lead Vocal" })).toHaveAttribute("aria-selected", "true"); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-role-setup")).not.toHaveClass("ring-amber-300/70"); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + expect(screen.queryByRole("status")).toBeNull(); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + + fireEvent.click(screen.getByRole("tab", { name: "All Roles" })); + expect(screen.getByRole("tab", { name: "All Roles" })).toHaveAttribute("aria-selected", "true"); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-role-setup")).toBeNull(); + expect(screen.queryByRole("button", { name: /^Set up / })).toBeNull(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it.each([true, false, undefined])("focuses setup with reduced-motion preference %s", (matches) => { + const song = createDemoRehearsalSong(); + const matchMedia = vi.fn((query: string) => ({ matches, media: query })); + vi.stubGlobal("matchMedia", matches === undefined ? undefined : matchMedia); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + const target = document.getElementById("workspace-role-setup")!; + const scrollIntoView = vi.fn(); + target.scrollIntoView = scrollIntoView; + + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: matches ? "auto" : "smooth", block: "nearest" }); + expect(target).toHaveFocus(); + expect(screen.getByRole("status")).toBeTruthy(); + if (matches === undefined) { + expect(matchMedia).not.toHaveBeenCalled(); + } else { + expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + } + }); + + it("safely arms setup when its DOM focus target is unavailable", () => { + const song = createDemoRehearsalSong(); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + const target = document.getElementById("workspace-role-setup")!; + const scrollIntoView = vi.fn(); + target.scrollIntoView = scrollIntoView; + // The focus anchor can be absent during host DOM updates; keep React's mounted node intact. + target.removeAttribute("id"); + + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + + expect(screen.getByRole("status")).toBeTruthy(); + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(target).not.toHaveFocus(); + target.id = "workspace-role-setup"; + }); + + it("leaves progress unchanged when no persistence callback exists", () => { + const song = createDemoRehearsalSong(); + replaceRole(song, "bass-guitar", (role) => ({ ...role, practiceProgress: 50 })); + const snapshot = structuredClone(song); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + fireEvent.click(screen.getByRole("button", { name: "Increase progress" })); + + expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "50"); + expect(song).toEqual(snapshot); + }); + + it("updates every selected-role copy immutably while preserving untouched references and armed setup", () => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ ...role, practiceProgress: 50 })); + song.sections.push({ + ...song.sections[0]!, id: "chorus-review", label: "chorus", + roles: song.sections[0]!.roles.map((role) => ({ ...role })) + }, { + ...song.sections[0]!, id: "vocal-only-review", label: "outro", + roles: song.sections[0]!.roles.filter((role) => role.id !== roleId) + }); + const snapshot = structuredClone(song); + const onSongUpdate = vi.fn<(nextSong: RehearsalSong) => void>(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + const status = screen.getByRole("status").textContent; + + fireEvent.click(screen.getByRole("button", { name: "Increase progress" })); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + const nextSong = onSongUpdate.mock.calls[0]![0]; + expect(nextSong).not.toBe(song); + expect(nextSong.sections).not.toBe(song.sections); + for (const [index, section] of song.sections.entries()) { + const nextSection = nextSong.sections[index]!; + if (!section.roles.some((role) => role.id === roleId)) { + expect(nextSection).toBe(section); + expect(nextSection.roles).toBe(section.roles); + continue; + } + expect(nextSection).not.toBe(section); + expect(nextSection.roles).not.toBe(section.roles); + for (const [roleIndex, role] of section.roles.entries()) { + const nextRole = nextSection.roles[roleIndex]!; + if (role.id === roleId) { + expect(nextRole).not.toBe(role); + expect(nextRole).toEqual({ ...role, practiceProgress: 60 }); + expect(nextRole.range).toBe(role.range); + expect(nextRole.harmony).toBe(role.harmony); + } else { + expect(nextRole).toBe(role); + } + } + } + expect(nextSong.collaboration).toBe(song.collaboration); + expect(song).toEqual(snapshot); + expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "50"); + + rerender(); + expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "60"); + expect(screen.getByRole("status").textContent).toBe(status); + expect(document.getElementById("workspace-role-setup")).toHaveClass("ring-amber-300/70"); + }); + + it("drops a removed selected role's stale setup and still allows returning to all roles", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: role.name })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + + const changedSong = structuredClone(song); + changedSong.sections = changedSong.sections.map((section) => ({ + ...section, roles: section.roles.filter((current) => current.id !== role.id) + })); + rerender(); + + expect(screen.queryByRole("tab", { name: role.name })).toBeNull(); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByText(role.id)).toBeTruthy(); + expect(screen.getByRole("button", { name: "No setup cue yet. Stay on tonight's map." })).toBeDisabled(); + fireEvent.click(screen.getByRole("tab", { name: "All Roles" })); + expect(document.getElementById("workspace-role-setup")).toBeNull(); + }); + + it.each(["pointerdown", "click"] as const)("rejects a setup click when a host invalidates evidence during %s capture", (eventType) => { + const song = createDemoRehearsalSong(); + const unavailableSong = structuredClone(song); + replaceRole(unavailableSong, "bass-guitar", (role) => ({ + ...role, setupNote: " ", transpositionPlan: " ", simplification: " " + })); + let updates = 0; + /** A public host update can arrive while the user is activating a part. */ + function Host() { + const [currentSong, setCurrentSong] = useState(song); + const invalidate = (target: EventTarget) => { + if (!(target instanceof HTMLElement) || !target.closest("button")?.getAttribute("aria-label")?.startsWith("Set up Bass Guitar")) return; + updates += 1; + flushSync(() => setCurrentSong(unavailableSong)); + }; + return ( +
invalidate(event.target) : undefined} + onClickCapture={eventType === "click" ? (event) => invalidate(event.target) : undefined} + > + +
+ ); + } + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + const setup = screen.getByRole("button", { name: /^Set up Bass Guitar/ }); + const scrollIntoView = vi.fn(); + document.getElementById("workspace-role-setup")!.scrollIntoView = scrollIntoView; + expect(setup).toBeEnabled(); + + if (eventType === "pointerdown") fireEvent.pointerDown(setup); + fireEvent.click(setup); + + expect(updates).toBe(1); + expect(screen.getByRole("button", { name: "No setup cue yet. Stay on tonight's map." })).toBeDisabled(); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.getElementById("workspace-role-setup")).not.toHaveClass("ring-amber-300/70"); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("uses the first section as the rehearsal focus when no export focus is available", () => { + const song = createDemoRehearsalSong(); + song.exportSummary = undefined; + song.sections = song.sections.slice(0, 1); + render(); + expect(screen.getByText(`Focus: ${song.sections[0]!.label}.`)).toBeTruthy(); + expect(screen.getByText(/1 section mapped with groove/)).toBeTruthy(); + }); + + it("uses a first-pass rehearsal focus when neither export focus nor sections exist", () => { + const song = createDemoRehearsalSong(); + song.exportSummary = undefined; + song.sections = []; + render(); + expect(screen.getByText("Focus: first pass.")).toBeTruthy(); + expect(screen.getByRole("tab", { name: "All Roles" })).toHaveAttribute("aria-selected", "true"); + expect(screen.queryByRole("button", { name: /^Set up / })).toBeNull(); + }); + + it.each([ + ["Export Cue Sheet (CSV)", "text/csv;charset=utf-8;", "cuesheet.csv"], + ["Export Chart (JSON)", "application/json;charset=utf-8;", "chart.json"], + ["Export Handoff (JSON)", "application/json;charset=utf-8;", "handoff.json"] + ])("downloads %s with a safe filename and releases its temporary URL", async (label, mimeType, suffix) => { + const song = createDemoRehearsalSong(); + song.title = "Rehearsal/../../Night:*?"; + song.sections[0]!.roles[0]!.cue.value = "=HYPERLINK(\"https://invalid.example\")"; + const createObjectURL = vi.fn<(blob: Blob) => string>(() => "blob:workspace-review-export"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal("URL", class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }); + const downloads: { anchor: HTMLAnchorElement; connected: boolean; filename: string; href: string }[] = []; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { + downloads.push({ anchor: this, connected: this.isConnected, filename: this.download, href: this.href }); + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: label })); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + const blob = createObjectURL.mock.calls[0]![0]; + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe(mimeType); + const contents = await blob.text(); + if (suffix === "cuesheet.csv") { + expect(contents.split("\n")[0]).toBe("Section,Groove,Role,Harmony,Cue,Priority,Notes"); + expect(contents).toContain("Bass Guitar"); + expect(contents).toContain("\"'=HYPERLINK(\"\"https://invalid.example\"\")\""); + } else if (suffix === "chart.json") { + const chart = JSON.parse(contents); + expect(chart.title).toBe(song.title); + expect(chart.sections[0].roles[0]).toMatchObject({ + name: song.sections[0]!.roles[0]!.name, + chord: song.sections[0]!.roles[0]!.harmony.chord, + cue: song.sections[0]!.roles[0]!.cue.value + }); + expect(chart.sections).toHaveLength(song.sections.length); + } else { + const handoff = JSON.parse(contents); + expect(handoff.artifactKind).toBe("bandscope.metadata-handoff"); + expect(handoff.workspace).toMatchObject({ id: song.id, title: song.title }); + expect(handoff.sourceAssets).toEqual([]); + expect(contents).not.toContain("sourcePath"); + expect(contents).not.toContain("projectRoot"); + } + expect(downloads).toHaveLength(1); + expect(downloads[0]).toMatchObject({ + connected: true, + filename: `Rehearsal_______Night____${suffix}`, + href: "blob:workspace-review-export" + }); + expect(downloads[0]!.anchor.isConnected).toBe(false); + expect(revokeObjectURL).toHaveBeenCalledExactlyOnceWith("blob:workspace-review-export"); + expect(document.querySelector('a[download]')).toBeNull(); + }); + + it("clears setup when a source project is removed", () => { + const song = createDemoRehearsalSong(); + const source = { + projectId: "project-review", sourceMode: "reference" as const, + projectRoot: "/owned/project", cacheRoot: "/owned/cache", tempRoot: "/owned/temp", + source: { sourcePath: "/owned/song.wav", fileName: "song.wav", extension: "wav" as const, fileSizeBytes: 100 } + }; + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + document.getElementById("workspace-role-setup")!.scrollIntoView = vi.fn(); + fireEvent.click(screen.getByRole("button", { name: /^Set up Bass Guitar/ })); + expect(screen.getByRole("status")).toBeTruthy(); + + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByRole("button", { name: /^Set up Bass Guitar/ })).toBeEnabled(); + rerender(); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("keeps copy interpolation free of dynamically constructed regular expressions", () => { + const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8"); + expect(source).not.toContain("new RegExp("); + }); + + it("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => { + render( + + ); + + const region = screen.getByRole("region", { name: "Lead Guitar transcription groove map" }); + expect(region.className).toContain("focus-visible:ring-2"); + expect(document.querySelectorAll("#workspace-groove-entrance")).toHaveLength(1); + expect(screen.getAllByTitle(/Tonight's entrance/)).toHaveLength(2); + }); + + it("uses the selected role name in groove-map empty and loading copy without inventing progress", () => { + const { rerender } = render(); + expect(screen.getByText("No Lead Guitar transcription yet. Use it when you want to check the groove before rehearsal.")).toBeTruthy(); + + rerender(); + expect(screen.getByText("Checking the Lead Guitar line...")).toBeTruthy(); + }); + + it("does not expose a cancel action without cancellation capability and invokes it when provided", () => { + const onCancel = vi.fn(); + const { rerender } = render(); + + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("localizes GrooveMap states and the unavailable loop control", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const roleName = song.sections[0]!.roles[0]!.name; + const { rerender } = render(); + + expect(screen.getByText(`${roleName} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.`)).toBeTruthy(); + + rerender( {}} />); + expect(screen.getByText(`${roleName} 파트를 확인하는 중...`)).toBeTruthy(); + expect(screen.getByRole("button", { name: "취소" })).toBeTruthy(); + + rerender(); + fireEvent.click(screen.getByRole("tab", { name: roleName })); + expect(screen.getByRole("button", { name: /구간 반복/ })).toHaveTextContent("구간 반복"); + }); + + it("keeps range-backed setup available when no exact first note exists", () => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ + ...role, + setupNote: "Tune down a whole step.", + transcription: undefined, + range: { + ...role.range, + lowestNote: "C#2", + highestNote: "E3" + } + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + + const setupButton = screen.getByRole("button", { name: /then start in C#2–E3/i }); + expect(setupButton).toBeEnabled(); + const visibleLabel = setupButton.textContent?.trim() ?? ""; + expect(visibleLabel).not.toBe(""); + expect(setupButton.getAttribute("aria-label")).toContain(visibleLabel); + }); + + it("keeps placeholder-looking role names literal in setup copy", () => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ + ...role, + name: "{low}", + setupNote: "Tune down a whole step.", + transcription: undefined, + range: { + ...role.range, + lowestNote: "C#2", + highestNote: "E3" + } + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "{low}" })); + + expect( + screen.getByRole("button", { + name: "Set up {low} · then start in C#2–E3. Setup: Tune down a whole step. Use tonight's map" + }) + ).toBeEnabled(); + }); + + it("keeps disabled stem controls discoverable by their visible labels", () => { + const song = createDemoRehearsalSong(); + + render(); + + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + expect(screen.getByRole("button", { name: /Play stem/ })).toHaveTextContent("Play stem"); + expect(screen.getByRole("button", { name: /Solo \/ mute others/ })).toHaveTextContent( + "Solo / mute others" + ); + }); + + it("natively disables setup when a cue has neither an entrance nor a playable range", () => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ + ...role, + setupNote: "Tune down a whole step.", + transcription: undefined, + range: { + ...role.range, + lowestNote: " ", + highestNote: " " + } + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + + const setupButton = screen.getByRole("button", { + name: "No first entrance or playable range yet. Stay on tonight's map." + }); + expect(setupButton).toBeDisabled(); + }); + + it.each([ + ["none", "none"], + ["E3", "C#2"], + ["low", "high"] + ])("rejects malformed setup range %s–%s", (lowestNote, highestNote) => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ + ...role, + setupNote: "Tune down a whole step.", + transcription: undefined, + range: { + ...role.range, + lowestNote, + highestNote + } + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + + const setupButton = screen.getByRole("button", { + name: "No first entrance or playable range yet. Stay on tonight's map." + }); + expect(setupButton).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..38e439216 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -8,6 +8,7 @@ import { generateMetadataHandoffJson } from "../../lib/export"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { @@ -28,6 +29,7 @@ describe("Workspace", () => { configurable: true, value: originalRevokeObjectUrl }); + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; }); it("updates practice progress immutably through onSongUpdate", () => { @@ -85,20 +87,23 @@ describe("Workspace", () => { expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); - it("enables bass transcription from selected role metadata rather than role id text", () => { + it("enables tonight's setup from the role setup cue rather than the role name", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, id: "low-end", - name: "Bass Guitar" + name: "Bass Guitar", + setupNote: "Keep the attack short so the verse breathes." }; render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement; - expect(transcribeButton.disabled).toBe(false); - expect(transcribeButton.title).toBe("Transcribe part"); + const setupButton = screen.getByRole("button", { + name: /Set up Bass Guitar · then start in C#2–E3\. Setup: Keep the attack short so the verse breathes/i + }) as HTMLButtonElement; + expect(setupButton.disabled).toBe(false); + expect(screen.queryByRole("button", { name: "Transcribe Bass" })).toBeNull(); }); it("renders bass transcription in the dark rehearsal cockpit system", () => { @@ -115,7 +120,7 @@ describe("Workspace", () => { render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i }); + const grooveMap = screen.getByRole("region", { name: /bass guitar transcription groove map/i }); expect(grooveMap.className).toContain("bg-slate-950"); expect(screen.getByText("E2")).toBeTruthy(); expect(screen.getByText("G2")).toBeTruthy(); @@ -326,4 +331,81 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("arms tonight's setup and names the first later-section entrance", () => { + const song = createDemoRehearsalSong(); + const verseRole = song.sections[0]!.roles[0]!; + song.sections[0]!.roles[0] = { + ...verseRole, + transcription: undefined + }; + song.sections.push({ + ...song.sections[0]!, + id: "chorus-1", + label: "chorus", + timeRange: { start: 40, end: 64 }, + roles: [ + { + ...verseRole, + transcription: [{ pitch: "A2", onset: 42, offset: 42.75, velocity: 0.7 }] + } + ] + }); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + fireEvent.click( + screen.getByRole("button", { + name: /Set up Bass Guitar · then start on A2 from 0:42\. Setup: Keep the attack short so the verse breathes/i + }) + ); + + expect( + screen.getByText( + "Tonight's Bass Guitar setup: Keep the attack short so the verse breathes. Start on A2 from 0:42 on the groove map." + ) + ).toBeTruthy(); + expect(document.activeElement?.id).toBe("workspace-role-setup"); + expect(document.getElementById("workspace-groove-entrance")).toBeTruthy(); + expect(scrollIntoView).toHaveBeenCalled(); + expect( + screen.getByRole("button", { + name: "Play stem. Isolation is not ready. Set up tonight's part first." + }) + ).toBeTruthy(); + }); + + it("keeps setup unavailable when the role has no setup cue", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + setupNote: " ", + transpositionPlan: "", + simplification: " " + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const setupButton = screen.getByRole("button", { name: "No setup cue yet. Stay on tonight's map." }); + expect(setupButton).toBeDisabled(); + fireEvent.click(setupButton); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("localizes tonight's setup action without broken Korean particles", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect( + screen.getByRole("button", { + name: /Bass Guitar 세팅 · .*세팅: Keep the attack short so the verse breathes/ + }) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..3fab5db58 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,11 +1,12 @@ -import { useState, useMemo, memo, type MouseEvent } from "react"; +import { useState, useMemo, useEffect, memo, type MouseEvent } from "react"; import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; -import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { fillRangeCopy, firstRangeSqueeze, playableRange } from "./firstRangeSqueeze"; +import { activateTonightSetup, earliestAnalyzedNote } from "./tonightSetup"; +import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card"; @@ -41,6 +42,7 @@ function downloadTextFile(contents: string, type: string, filename: string): voi } type Translator = ReturnType; +type TranscriptionNote = NonNullable[number]; /** Documented. */ function preventUnavailableAction(event: MouseEvent): void { @@ -58,6 +60,32 @@ function nonBlankText(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } +/** Remove terminal sentence punctuation before embedding a cue in a larger sentence. */ +function sentenceFragment(value: string): string { + return value.replace(/[.!?。!?]+$/u, "").trimEnd(); +} + +/** Prefer the role's setup cue, then transpose, then simplification. */ +function roleSetupCue(role: RehearsalRole | undefined): string | undefined { + return nonBlankText(role?.setupNote) ?? nonBlankText(role?.transpositionPlan) ?? nonBlankText(role?.simplification); +} + +/** Scroll and focus the setup card the player should follow next. */ +function focusRoleSetup(): void { + const node = document.getElementById("workspace-role-setup"); + if (!(node instanceof HTMLElement)) { + return; + } + const prefersReducedMotion = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + node.scrollIntoView({ + behavior: prefersReducedMotion ? "auto" : "smooth", + block: "nearest" + }); + node.focus(); +} + /** Documented. */ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null { if (!value) { @@ -121,6 +149,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); + const [armedSetup, setArmedSetup] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -150,7 +179,33 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp if (!activeRole) return undefined; return roleMap.get(activeRole); }, [activeRole, roleMap]); - const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false; + const activeRoleTranscription = useMemo(() => { + if (!activeRole) return undefined; + const notes: TranscriptionNote[] = []; + for (const section of song.sections) { + for (const role of section.roles) { + if (role.id !== activeRole || !role.transcription) continue; + for (const note of role.transcription) { + notes.push(note); + } + } + } + if (notes.length === 0) return undefined; + notes.sort((left, right) => left.onset - right.onset); + return notes; + }, [activeRole, song.sections]); + const firstNote = earliestAnalyzedNote(activeRoleTranscription); + const activeRoleRange = playableRange( + activeRoleDetails?.range.lowestNote, + activeRoleDetails?.range.highestNote + ); + const roleRangeLow = activeRoleRange?.lowestNote; + const roleRangeHigh = activeRoleRange?.highestNote; + const setupCue = roleSetupCue(activeRoleDetails); + const setupSentenceCue = setupCue ? sentenceFragment(setupCue) : ""; + const hasPlayableRange = activeRoleRange !== null; + const hasStartEvidence = Boolean(firstNote || hasPlayableRange); + const canArmTonightSetup = Boolean(setupCue && hasStartEvidence); const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]); const firstRangeCopy = firstRange ? fillRangeCopy( @@ -225,6 +280,85 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const roleName = nonBlankText(activeRoleDetails?.name) ?? t("workspaceThisRole"); + const setupUnavailableLabel = setupCue && !hasStartEvidence + ? t("workspaceSetupStartUnavailable") + : t("workspaceSetupUnavailable"); + const setupActionLabel = !canArmTonightSetup + ? setupUnavailableLabel + : firstNote + ? fillTranslation(t("workspaceSetupActionWithNote"), { + role: roleName, + pitch: firstNote.pitch, + start: formatTimelineTime(firstNote.onset) + }) + : fillTranslation(t("workspaceSetupActionWithRange"), { + role: roleName, + low: roleRangeLow!, + high: roleRangeHigh! + }); + const setupAriaLabel = !canArmTonightSetup + ? setupUnavailableLabel + : firstNote + ? fillTranslation(t("workspaceSetupAriaWithNote"), { + role: roleName, + pitch: firstNote.pitch, + start: formatTimelineTime(firstNote.onset), + setup: setupSentenceCue + }) + : fillTranslation(t("workspaceSetupAriaWithRange"), { + role: roleName, + low: roleRangeLow!, + high: roleRangeHigh!, + setup: setupSentenceCue + }); + const setupStatus = firstNote + ? fillTranslation(t("workspaceSetupArmedWithNote"), { + role: roleName, + pitch: firstNote.pitch, + start: formatTimelineTime(firstNote.onset), + setup: setupSentenceCue + }) + : fillTranslation(t("workspaceSetupArmedWithRange"), { + role: roleName, + low: roleRangeLow!, + high: roleRangeHigh!, + setup: setupSentenceCue + }); + + // Activation belongs to this song/source and the exact setup/start evidence, + // not the role id alone. Unrelated progress edits preserve this identity. + const setupIdentity = JSON.stringify([ + song.id, + sourceBootstrap?.projectId ?? null, + activeRole, + roleName, + setupCue ?? null, + firstNote ? [firstNote.pitch, firstNote.onset, firstNote.offset] : [roleRangeLow, roleRangeHigh] + ]); + const isSetupArmed = canArmTonightSetup && armedSetup === setupIdentity; + useEffect(() => { + setArmedSetup(null); + }, [setupIdentity]); + + /** Arm tonight's setup and move focus to the setup card. */ + const armTonightSetup = (): void => { + activateTonightSetup({ + roleId: activeRole, + canArmTonightSetup, + /** Commit the current setup identity before moving focus to its card. */ + onActivate: () => { + setArmedSetup(setupIdentity); + focusRoleSetup(); + } + }); + }; + + /** Keep the role board and armed setup on the same selected part. */ + const handleRoleChange = (roleId: string | null): void => { + setActiveRole(roleId); + setArmedSetup(null); + }; /** Documented. */ const handleExportCueSheet = () => { @@ -364,7 +498,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -376,8 +510,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp - {canTranscribeBass ? ( + {canArmTonightSetup ? ( ) : ( )}
@@ -438,14 +574,25 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp {roleHarmonicExplanation}

-
+

- {roleTranspositionPlan} + {setupCue ?? roleTranspositionPlan}

+ {roleTranspositionPlan && roleTranspositionPlan !== setupCue ? ( +

{roleTranspositionPlan}

+ ) : null}
{song.collaboration && ( @@ -498,7 +645,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp )} - + {isSetupArmed ? ( +

+ {setupStatus} +

+ ) : null} + )} diff --git a/apps/desktop/src/features/workspace/tonightSetup.test.ts b/apps/desktop/src/features/workspace/tonightSetup.test.ts new file mode 100644 index 000000000..a25f81d97 --- /dev/null +++ b/apps/desktop/src/features/workspace/tonightSetup.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import * as tonightSetup from "./tonightSetup"; + +describe("earliest analyzed note for tonight's setup", () => { + it("finds an earlier attack in unsorted analysis without mutating the notes", () => { + const later = { pitch: "A2", onset: 8, offset: 9, velocity: 0.7 }; + const earliest = { pitch: "E2", onset: 1, offset: 2, velocity: 0.7 }; + const notes = [later, earliest]; + const snapshot = structuredClone(notes); + + expect(tonightSetup).toHaveProperty("earliestAnalyzedNote", expect.any(Function)); + expect(tonightSetup.earliestAnalyzedNote(notes)).toBe(earliest); + expect(notes).toEqual(snapshot); + expect(notes[0]).toBe(later); + expect(notes[1]).toBe(earliest); + }); + + it("has no first attack when analysis is absent", () => { + expect(tonightSetup.earliestAnalyzedNote(undefined)).toBeUndefined(); + }); + + it("has no first attack when analysis contains no notes", () => { + expect(tonightSetup.earliestAnalyzedNote([])).toBeUndefined(); + }); + + it("retains the only analyzed note by reference", () => { + const note = { pitch: "E2", onset: 1, offset: 2, velocity: 0.7 }; + expect(tonightSetup.earliestAnalyzedNote([note])).toBe(note); + }); + + it("retains the first analyzed note when attacks are already sorted", () => { + const earliest = { pitch: "E2", onset: 1, offset: 2, velocity: 0.7 }; + const later = { pitch: "A2", onset: 8, offset: 9, velocity: 0.7 }; + expect(tonightSetup.earliestAnalyzedNote([earliest, later])).toBe(earliest); + }); + + it("retains the first encountered earliest attack when onsets tie without mutating analysis", () => { + const later = { pitch: "A2", onset: 8, offset: 9, velocity: 0.7 }; + const earliest = { pitch: "E2", onset: 1, offset: 2, velocity: 0.7 }; + const tied = { pitch: "G2", onset: 1, offset: 3, velocity: 0.8 }; + const notes = [later, earliest, tied]; + const snapshot = structuredClone(notes); + notes.forEach(Object.freeze); + Object.freeze(notes); + + expect(tonightSetup.earliestAnalyzedNote(notes)).toBe(earliest); + expect(notes).toEqual(snapshot); + expect(notes[0]).toBe(later); + expect(notes[2]).toBe(tied); + }); +}); + +describe("capability-bound tonight's setup activation", () => { + it("does not activate a setup without a selected role", () => { + let activations = 0; + + expect(tonightSetup).toHaveProperty("activateTonightSetup", expect.any(Function)); + tonightSetup.activateTonightSetup({ + roleId: null, + canArmTonightSetup: true, + onActivate: () => { activations += 1; } + }); + + expect(activations).toBe(0); + }); + + it("does not activate a setup for an empty selected role id", () => { + let activations = 0; + tonightSetup.activateTonightSetup({ + roleId: "", + canArmTonightSetup: true, + onActivate: () => { activations += 1; } + }); + expect(activations).toBe(0); + }); + + it("does not activate a selected role without setup capability", () => { + let activations = 0; + tonightSetup.activateTonightSetup({ + roleId: "bass-guitar", + canArmTonightSetup: false, + onActivate: () => { activations += 1; } + }); + expect(activations).toBe(0); + }); + + it("does not activate when both role selection and setup capability are absent", () => { + let activations = 0; + tonightSetup.activateTonightSetup({ + roleId: null, + canArmTonightSetup: false, + onActivate: () => { activations += 1; } + }); + expect(activations).toBe(0); + }); + + it("activates a capable selected role exactly once per command", () => { + let activations = 0; + const command = { + roleId: "bass-guitar", + canArmTonightSetup: true, + onActivate: () => { activations += 1; } + }; + Object.freeze(command); + + tonightSetup.activateTonightSetup(command); + + expect(activations).toBe(1); + expect(command.roleId).toBe("bass-guitar"); + expect(command.canArmTonightSetup).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/tonightSetup.ts b/apps/desktop/src/features/workspace/tonightSetup.ts new file mode 100644 index 000000000..d86b8368c --- /dev/null +++ b/apps/desktop/src/features/workspace/tonightSetup.ts @@ -0,0 +1,32 @@ +import type { RehearsalRole } from "@bandscope/shared-types"; + +type TranscriptionNote = NonNullable[number]; + +/** Return the earliest analyzed note so setup can name the first attack. */ +export function earliestAnalyzedNote(notes: RehearsalRole["transcription"]): TranscriptionNote | undefined { + if (!notes || notes.length === 0) { + return undefined; + } + + let earliest = notes[0]!; + for (const note of notes) { + if (note.onset < earliest.onset) { + earliest = note; + } + } + return earliest; +} + +interface TonightSetupActivation { + roleId: string | null; + canArmTonightSetup: boolean; + onActivate: () => void; +} + +/** Activate the selected part's setup only when its setup/start evidence permits it. */ +export function activateTonightSetup({ roleId, canArmTonightSetup, onActivate }: TonightSetupActivation): void { + if (!roleId || !canArmTonightSetup) { + return; + } + onActivate(); +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..a0a1369c1 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, fillTranslation } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,14 @@ describe("i18n", () => { } }); }); + + describe("fillTranslation", () => { + it("keeps unknown placeholders and placeholder-shaped values literal", () => { + expect( + fillTranslation("Set up {role} in {range}", { + role: "{range}" + }) + ).toBe("Set up {range} in {range}"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..0dcf9ad30 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -6,6 +6,8 @@ export type Locale = "en" | "ko"; /** Documented. */ export type TranslationKey = keyof typeof enCommon; +const TRANSLATION_PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9]*)\}/g; + const dictionaries = { en: enCommon, ko: koCommon @@ -18,6 +20,16 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Interpolate owned translation placeholders once while preserving missing and placeholder-shaped values literally. */ +export function fillTranslation( + template: string, + values: Readonly> +): string { + return template.replace(TRANSLATION_PLACEHOLDER_PATTERN, (placeholder, key: string) => + Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : placeholder + ); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..0c6f636f7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -50,6 +50,27 @@ "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceRolesHarmonyLabel": "Roles & Harmony", + "workspaceThisRole": "This part", + "workspaceSetupActionWithNote": "Set up {role} · then start on {pitch} from {start}", + "workspaceSetupActionWithRange": "Set up {role} · then start in {low}–{high}", + "workspaceSetupAriaWithNote": "Set up {role} · then start on {pitch} from {start}. Setup: {setup}. Use tonight's groove map", + "workspaceSetupAriaWithRange": "Set up {role} · then start in {low}–{high}. Setup: {setup}. Use tonight's map", + "workspaceSetupArmedWithNote": "Tonight's {role} setup: {setup}. Start on {pitch} from {start} on the groove map.", + "workspaceSetupArmedWithRange": "Tonight's {role} setup: {setup}. Start in {low}–{high} on the map.", + "workspaceSetupUnavailable": "No setup cue yet. Stay on tonight's map.", + "workspaceSetupStartUnavailable": "No first entrance or playable range yet. Stay on tonight's map.", + "workspacePlayStemUnavailable": "Isolation is not ready. Set up tonight's part first.", + "workspaceSoloUnavailable": "Solo stays off until isolation is honest. Set up tonight's part first.", + "workspaceLoopAction": "Loop section", + "workspaceLoopUnavailable": "Loop section. Looping is not ready. Set up tonight's part first.", + "grooveMapLoading": "Checking the {role} line...", + "grooveMapCancel": "Cancel", + "grooveMapEmpty": "No {role} transcription yet. Use it when you want to check the groove before rehearsal.", + "grooveMapRegionLabel": "{role} transcription groove map", + "grooveMapComplete": "Transcription complete. {count} notes analyzed.", + "grooveMapMapped": "{count} notes mapped for rehearsal", + "grooveMapEntranceTitle": "Tonight's entrance · {note}", + "grooveMapEntranceAnnouncement": "Tonight's entrance. {note}", "sectionRoadmapTitle": "Section Roadmap", "sectionRoadmapScrollHint": "Scroll for more sections →", "sectionGrooveLabel": "Groove", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..34a0147cd 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -50,6 +50,27 @@ "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceRolesHarmonyLabel": "역할과 화성", + "workspaceThisRole": "이 파트", + "workspaceSetupActionWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작", + "workspaceSetupActionWithRange": "{role} 세팅 · {low}–{high}에서 시작", + "workspaceSetupAriaWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작. 세팅: {setup}. 오늘 그루브 맵을 사용하세요", + "workspaceSetupAriaWithRange": "{role} 세팅 · {low}–{high}에서 시작. 세팅: {setup}. 오늘 지도를 사용하세요", + "workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.", + "workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.", + "workspaceSetupUnavailable": "아직 세팅 큐가 없습니다. 오늘 지도에 머무르세요.", + "workspaceSetupStartUnavailable": "첫 진입점이나 연주 가능 음역이 아직 없습니다. 오늘 지도를 확인하세요.", + "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 먼저 오늘 파트를 세팅하세요.", + "workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 오늘 파트를 세팅하세요.", + "workspaceLoopAction": "구간 반복", + "workspaceLoopUnavailable": "구간 반복. 반복 재생은 아직 준비되지 않았습니다. 먼저 오늘 파트를 세팅하세요.", + "grooveMapLoading": "{role} 파트를 확인하는 중...", + "grooveMapCancel": "취소", + "grooveMapEmpty": "{role} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.", + "grooveMapRegionLabel": "{role} 채보 그루브 맵", + "grooveMapComplete": "채보가 완료되었습니다. 음표 {count}개를 분석했습니다.", + "grooveMapMapped": "합주용 음표 {count}개 매핑됨", + "grooveMapEntranceTitle": "오늘의 진입점 · {note}", + "grooveMapEntranceAnnouncement": "오늘의 진입점. {note}", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", "sectionGrooveLabel": "그루브", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..84b267ba2 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -31,7 +31,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | -| Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use required `roleName`, optional `notes?: TranscriptionNote[]`, `isLoading?: boolean`, optional `entranceOnset`, and `onCancel` only when a real cancellation capability exists. Loading copy must not invent a progress percentage. Preserve scrollable region semantics, focus-visible styling, and note labels. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | @@ -75,6 +75,8 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro ### Workspace States +- An activated role setup belongs to the current song/project and exact role name, setup cue and first-note or playable-range evidence. Source/song replacement or changed evidence clears the status, setup-card emphasis and first-entrance highlight until the user activates again. Unrelated immutable practice-progress changes preserve activation; restoring old evidence after an intervening change does not resurrect it. +- This is renderer session state only, not a claim that audio played or setup was musically confirmed. - Figma page `34 Workspace State Matrix` maps `EmptyState`, `LoadingState`, `ErrorState`, ready `Workspace`, `GrooveMap`, and Source Control Stack substates. - `App.tsx` must preserve the current routing order: `jobError` -> `ErrorState`, `analysisInFlight || isStarting` -> `LoadingState`, `jobResult` -> `Workspace`, otherwise `EmptyState`. - `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`. diff --git a/services/analysis-engine/tests/test_release_metadata.py b/services/analysis-engine/tests/test_release_metadata.py index ec6b39a32..370d556c7 100644 --- a/services/analysis-engine/tests/test_release_metadata.py +++ b/services/analysis-engine/tests/test_release_metadata.py @@ -62,6 +62,18 @@ def test_changelog_contains_root_package_release_entry() -> None: assert f"## [{root_package_version()}]" in changelog +def test_changelog_preserves_dependency_security_baseline_as_fixed() -> None: + """Keep the shipped dependency-security repair classified as a fix in Unreleased.""" + changelog = (repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + release_marker = "## [0.1.3]" + assert release_marker in changelog + unreleased = changelog.split(release_marker, maxsplit=1)[0] + fixed = unreleased.split("### Fixed", maxsplit=1)[1] + security_fix = "Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108" + + assert security_fix in fixed + + def test_changelog_level_three_headings_are_surrounded_by_blank_lines() -> None: """Ensure changelog subsections stay compatible with Markdown heading lint.""" lines = (repo_root() / "CHANGELOG.md").read_text(encoding="utf-8").splitlines()