From aa805ec4ca4d256efcea3863f76dcae7330f1561 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:21:10 +0000 Subject: [PATCH 1/8] feat(workspace): open tonight's first lock-in on the roadmap The rehearsal priorities card named a focus section and stopped. Open that section on the existing Section Roadmap so a bandmate can count in on tonight's first lock-in without inventing playback or MIR. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../workspace/SectionRoadmap.test.tsx | 11 +++ .../src/features/workspace/SectionRoadmap.tsx | 13 ++- .../src/features/workspace/Workspace.test.tsx | 66 +++++++++++++ .../src/features/workspace/Workspace.tsx | 99 ++++++++++++++++++- apps/desktop/src/locales/en/common.json | 5 + apps/desktop/src/locales/ko/common.json | 5 + docs/design-system/component-contract.md | 2 +- 11 files changed, 198 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..84b66d156 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. +- Customer-facing workspace copy must name the next rehearsal action (open tonight's first lock-in, start a part, see first notes) instead of leaving priority or empty cards inert. - Do not reduce the product to a chord analyzer when form, timing, player coordination, 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 3302a6fc3..6036a7060 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,6 +85,7 @@ Last updated: 2026-03-11 - playable ranges and density or overlap warnings - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags + - a workspace priorities card that opens tonight's first lock-in section on the Section Roadmap - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form ## Confidence, edits, and provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..b65bdd0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- The rehearsal priorities card now opens tonight's first lock-in section on the Section Roadmap, with bilingual next-action copy. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..b22fd7aa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis -- ## Architecture -BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. +BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. The workspace priorities card must name and open tonight's first lock-in on the Section Roadmap. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. Three layers, decoupled through shared contracts: diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..603e099c3 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -60,4 +60,15 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + it("exposes focus targets for tonight's first lock-in section", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const card = document.getElementById("workspace-section-verse-1"); + expect(card).toBeTruthy(); + expect(card?.className).toContain("ring-amber-300/70"); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..e58e5aa1a 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -11,10 +11,11 @@ interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles onSongUpdate?: (song: RehearsalSong) => void; + focusedSectionId?: string | null; } /** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +export function SectionRoadmap({ song, activeRole, onSongUpdate, focusedSectionId = null }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); @@ -106,8 +107,14 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {song.sections.map((section) => ( diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..1a486a4aa 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -269,5 +269,71 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); + expect(screen.getByRole("button", { name: "구간 흐름에서 verse 열기, 0:10부터 0:30까지" })).toBeTruthy(); + }); + + it("opens tonight's first lock-in section on the roadmap", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + const focus = vi.fn(); + const originalGetElementById = document.getElementById.bind(document); + vi.spyOn(document, "getElementById").mockImplementation((id: string) => { + const node = originalGetElementById(id); + if (node && id === "workspace-section-verse-1") { + Object.defineProperty(node, "scrollIntoView", { configurable: true, value: scrollIntoView }); + Object.defineProperty(node, "focus", { configurable: true, value: focus }); + } + return node; + }); + + render(); + + expect(screen.getByText("Tonight's first lock-in is verse · 0:10–0:30.")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Open verse on the section roadmap from 0:10 to 0:30" })); + + expect(screen.getByText("Tonight's first lock-in is verse · 0:10–0:30. Count in on that card.")).toBeTruthy(); + expect(document.getElementById("workspace-section-verse-1")).toBeTruthy(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(focus).toHaveBeenCalledTimes(1); + }); + + it("falls back to the first mapped section when focus labels do not match", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.exportSummary = { + ...song.exportSummary, + focusSections: ["missing-bridge"] + }; + song.sections[0] = { + ...song.sections[0]!, + id: "intro-1", + label: "intro", + timeRange: { start: 0, end: 8 } + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open intro on the section roadmap from 0:00 to 0:08" })); + + expect(screen.getByText("Tonight's first lock-in is intro · 0:00–0:08. Count in on that card.")).toBeTruthy(); + expect(document.getElementById("workspace-section-intro-1")).toBeTruthy(); + }); + + it("keeps the priorities action closed when no sections are mapped", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + + render(); + + const locked = screen.getByRole("button", { name: "No lock-in section yet" }); + expect(locked.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(locked); + expect(screen.queryByText(/Count in on that card/i)).toBeNull(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..f2670853d 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -57,6 +57,39 @@ function nonBlankText(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } +/** Fill rehearsal copy with named placeholders. */ +function fillCopy(template: string, values: Record): string { + return Object.entries(values).reduce( + (text, [key, value]) => text.replaceAll(`{${key}}`, value), + template + ); +} + +/** Return tonight's first lock-in section from export focus, then the first mapped section. */ +function firstFocusSection(song: RehearsalSong): RehearsalSong["sections"][number] | undefined { + const requested = song.exportSummary?.focusSections?.[0]?.trim(); + if (requested) { + const match = song.sections.find( + (section) => section.label === requested || section.id === requested + ); + if (match) { + return match; + } + } + + return song.sections[0]; +} + +/** Scroll and focus the matching section card on the rehearsal roadmap. */ +function focusWorkspaceSection(sectionId: string): void { + const node = document.getElementById(`workspace-section-${sectionId}`); + if (!(node instanceof HTMLElement)) { + return; + } + node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + node.focus(); +} + /** Documented. */ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null { if (!value) { @@ -120,6 +153,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 [openedFocusSectionId, setOpenedFocusSectionId] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -212,6 +246,37 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const focusSection = firstFocusSection(song); + const openedFocusSection = + song.sections.find((section) => section.id === openedFocusSectionId) ?? null; + const focusCopyValues = focusSection + ? { + label: focusSection.label, + start: formatTimelineTime(focusSection.timeRange.start), + end: formatTimelineTime(focusSection.timeRange.end) + } + : null; + const focusActionLabel = focusCopyValues + ? fillCopy(t("workspaceOpenFocusAction"), focusCopyValues) + : t("workspaceOpenFocusUnavailable"); + const focusAriaLabel = focusCopyValues + ? fillCopy(t("workspaceOpenFocusAria"), focusCopyValues) + : t("workspaceOpenFocusUnavailable"); + const focusSummary = focusCopyValues + ? fillCopy(t("workspaceOpenFocusSummary"), focusCopyValues) + : t("workspaceOpenFocusUnavailable"); + const focusStatus = focusCopyValues + ? fillCopy(t("workspaceOpenFocusArmed"), focusCopyValues) + : t("workspaceOpenFocusUnavailable"); + + /** Open tonight's first lock-in on the section roadmap without inventing playback. */ + const openTonightFocus = (): void => { + if (!focusSection) { + return; + } + setOpenedFocusSectionId(focusSection.id); + focusWorkspaceSection(focusSection.id); + }; /** Documented. */ const handleExportCueSheet = () => { @@ -325,9 +390,36 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceRehearsalPrioritiesLabel")}

-

- Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}. -

+

{focusSummary}

+ {focusSection ? ( + + ) : ( + + )} + {openedFocusSection ? ( +

+ {focusStatus} +

+ ) : null}
@@ -484,6 +576,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp song={song} activeRole={activeRole} onSongUpdate={onSongUpdate} + focusedSectionId={openedFocusSectionId} /> diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..f31d7f284 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "Transpose / simplify", "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", + "workspaceOpenFocusAction": "Open {label} · {start}–{end}", + "workspaceOpenFocusAria": "Open {label} on the section roadmap from {start} to {end}", + "workspaceOpenFocusSummary": "Tonight's first lock-in is {label} · {start}–{end}.", + "workspaceOpenFocusArmed": "Tonight's first lock-in is {label} · {start}–{end}. Count in on that card.", + "workspaceOpenFocusUnavailable": "No lock-in section yet", "workspaceRolesHarmonyLabel": "Roles & Harmony", "sectionRoadmapTitle": "Section Roadmap", "sectionRoadmapScrollHint": "Scroll for more sections →", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..652c689d1 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "전조 / 단순화", "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", + "workspaceOpenFocusAction": "{label} 열기 · {start}–{end}", + "workspaceOpenFocusAria": "구간 흐름에서 {label} 열기, {start}부터 {end}까지", + "workspaceOpenFocusSummary": "오늘 먼저 잠글 구간은 {label} · {start}–{end}입니다.", + "workspaceOpenFocusArmed": "오늘 먼저 잠글 구간은 {label} · {start}–{end}입니다. 그 카드에서 카운트인하세요.", + "workspaceOpenFocusUnavailable": "아직 잠글 구간이 없습니다", "workspaceRolesHarmonyLabel": "역할과 화성", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..c3479ab45 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -29,7 +29,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Confidence Badge | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-239 | `apps/desktop/src/features/workspace/ConfidenceBadge.tsx` | Use `level: ConfidenceLevel`; no `score` or `label` prop exists in current code. | | Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. | | 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. | +| 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`, optional `onSongUpdate`, and optional `focusedSectionId`; `id="workspace-section-{section.id}"` is the focus target for tonight's first lock-in. | | 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. | | 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. | From 99888def0c159b65ba1c043d1f9bf459ce0e33f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:47:39 +0900 Subject: [PATCH 2/8] test(workspace): provide jsdom scrollIntoView boundary --- apps/desktop/src/setupTests.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/setupTests.ts b/apps/desktop/src/setupTests.ts index 753877d90..2dfafdc95 100644 --- a/apps/desktop/src/setupTests.ts +++ b/apps/desktop/src/setupTests.ts @@ -17,3 +17,10 @@ if (typeof window !== "undefined" && !window.matchMedia) { dispatchEvent: vi.fn(), })); } + +// jsdom also omits the browser scrolling API. Keep the test environment at +// the same DOM capability boundary as supported desktop WebViews so focus +// interactions exercise application behavior instead of throwing in jsdom. +if (typeof HTMLElement !== "undefined" && !HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = vi.fn(); +} From eae51f378bc2c24f3ac36a48f98adefa1b28a417 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:49:57 +0900 Subject: [PATCH 3/8] test(workspace): scope timeline fallback assertion --- apps/desktop/src/features/workspace/Workspace.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 1a486a4aa..11b7be0a3 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; @@ -82,7 +82,7 @@ describe("Workspace", () => { render(); - expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); + expect(within(screen.getByTestId("song-structure-grid")).getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); it("enables bass transcription from selected role metadata rather than role id text", () => { From 286bd03c63e4b5de9c4172f8c08aaf6d7b0d85cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:55:39 +0900 Subject: [PATCH 4/8] test(app): scope song-structure range assertion --- apps/desktop/src/App.test.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..82ac18f26 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(); @@ -775,9 +775,6 @@ describe("App", () => { render(); fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); - await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); - - fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3)); act(() => { @@ -810,9 +807,6 @@ describe("App", () => { render(); fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); - await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); - - fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3)); act(() => { From 03214c883ccc58efe36194eae13220313522e193 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:14:58 +0900 Subject: [PATCH 5/8] test(app): restore analysis start in stale-poll regressions --- apps/desktop/src/App.test.tsx | 43 +++++++---------------------------- 1 file changed, 8 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 82ac18f26..336d28865 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -361,8 +361,6 @@ describe("App", () => { it("short-circuits confidence evaluation when encountering a low confidence section", async () => { const loadedProject = succeededResult().result; // medium is first - // Add low and high sections. High shouldn't matter since low is lowest. - // And low will trigger the early break in the loop. loadedProject.sections.push( { ...loadedProject.sections[0], @@ -775,6 +773,9 @@ describe("App", () => { render(); fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3)); act(() => { @@ -807,6 +808,9 @@ describe("App", () => { render(); fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3)); act(() => { @@ -1185,9 +1189,6 @@ describe("App", () => { const input = screen.getByPlaceholderText(/YouTube URL.../i); fireEvent.change(input, { target: { value: " " } }); const button = screen.getByRole("button", { name: /Import YouTube/i }); - // Button is disabled if youtubeUrl is empty, but we simulate enabling it for coverage - // or we can test that the error is set when it somehow triggers, but actually it's disabled. - // Wait, the button is disabled if `!youtubeUrl`. `youtubeUrl` is " ", so button is NOT disabled! fireEvent.click(button); await waitFor(() => { @@ -1234,7 +1235,7 @@ describe("App", () => { it("rejects downgraded YouTube URL intake before invoking the bridge", async () => { render(); - const input = screen.getByPlaceholderText(/YouTube URL.../i); + const input = screen.getByPlaceholderText(/YouTube URL/i); fireEvent.change(input, { target: { value: "http://youtube.com/watch?v=abc123DEF45" } }); fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); @@ -1247,7 +1248,7 @@ describe("App", () => { it("rejects duplicate YouTube video parameters even when one is blank", async () => { render(); - const input = screen.getByPlaceholderText(/YouTube URL.../i); + const input = screen.getByRole("textbox", { name: /YouTube URL/i }); fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45&v=" } }); fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); @@ -1258,7 +1259,6 @@ describe("App", () => { expect(tauriInvoke).not.toHaveBeenCalled(); }); - it("loads a project and updates the UI", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); @@ -1288,7 +1288,6 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /open project/i })); - // Should not show error, should remain in empty state await waitFor(() => { expect(mockLoadProject).toHaveBeenCalledTimes(1); }); @@ -1353,15 +1352,12 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockResolvedValueOnce(undefined); - - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1373,15 +1369,12 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce(new Error("Permission denied")); - - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1393,15 +1386,12 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce(new Error("User cancelled")); - - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1416,15 +1406,12 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce("Disk full"); - - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1462,15 +1449,12 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce("User cancelled"); - - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1485,19 +1469,14 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); - // Mock prompt to simulate user entering a new chord const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7"); - - // Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit) fireEvent.click(screen.getAllByText("C#m7", { selector: 'button' })[0]); - // Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated) await waitFor(() => { expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0); }); @@ -1546,7 +1525,6 @@ describe("App", () => { }); }); - it("renders Settings and Help as focusable aria-disabled controls", () => { render(); const settingsButton = screen.getByRole("button", { name: "Settings coming soon" }); @@ -1583,8 +1561,6 @@ describe("App", () => { fireEvent.click(scoreButton); expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); - // Projects opened from a .bscope file have no live workspace, so score - // storage is gated behind the active-project notice. expect(screen.getByText(/Scores attach to the active analysis project/i)).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); @@ -1598,9 +1574,6 @@ describe("App", () => { expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); }); - // The compact nav is a separate rendered bar (shown on small viewports) with - // its own set of buttons; exercise it directly so the mobile navigation path - // is covered, not just the sidebar one. const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i }); const compactScoreButton = within(compactNav).getByRole("button", { name: /Score compact view/i }); expect(compactScoreButton).toBeEnabled(); From d85e08d1c5defd03ce0da8531369f5b9ea52e5b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:18:14 +0900 Subject: [PATCH 6/8] test(app): keep stale-poll repair surgical --- apps/desktop/src/App.test.tsx | 37 +++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 336d28865..9948eeba7 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -361,6 +361,8 @@ describe("App", () => { it("short-circuits confidence evaluation when encountering a low confidence section", async () => { const loadedProject = succeededResult().result; // medium is first + // Add low and high sections. High shouldn't matter since low is lowest. + // And low will trigger the early break in the loop. loadedProject.sections.push( { ...loadedProject.sections[0], @@ -1189,6 +1191,9 @@ describe("App", () => { const input = screen.getByPlaceholderText(/YouTube URL.../i); fireEvent.change(input, { target: { value: " " } }); const button = screen.getByRole("button", { name: /Import YouTube/i }); + // Button is disabled if youtubeUrl is empty, but we simulate enabling it for coverage + // or we can test that the error is set when it somehow triggers, but actually it's disabled. + // Wait, the button is disabled if `!youtubeUrl`. `youtubeUrl` is " ", so button is NOT disabled! fireEvent.click(button); await waitFor(() => { @@ -1235,7 +1240,7 @@ describe("App", () => { it("rejects downgraded YouTube URL intake before invoking the bridge", async () => { render(); - const input = screen.getByPlaceholderText(/YouTube URL/i); + const input = screen.getByPlaceholderText(/YouTube URL.../i); fireEvent.change(input, { target: { value: "http://youtube.com/watch?v=abc123DEF45" } }); fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); @@ -1248,7 +1253,7 @@ describe("App", () => { it("rejects duplicate YouTube video parameters even when one is blank", async () => { render(); - const input = screen.getByRole("textbox", { name: /YouTube URL/i }); + const input = screen.getByPlaceholderText(/YouTube URL.../i); fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45&v=" } }); fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); @@ -1259,6 +1264,7 @@ describe("App", () => { expect(tauriInvoke).not.toHaveBeenCalled(); }); + it("loads a project and updates the UI", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); @@ -1288,6 +1294,7 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /open project/i })); + // Should not show error, should remain in empty state await waitFor(() => { expect(mockLoadProject).toHaveBeenCalledTimes(1); }); @@ -1352,12 +1359,15 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); + // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockResolvedValueOnce(undefined); + + // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1369,12 +1379,15 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); + // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce(new Error("Permission denied")); + + // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1386,12 +1399,15 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); + // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce(new Error("User cancelled")); + + // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1406,12 +1422,15 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); + // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce("Disk full"); + + // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1449,12 +1468,15 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); + // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); mockSaveProject.mockRejectedValueOnce("User cancelled"); + + // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1469,14 +1491,19 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); + // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); + // Mock prompt to simulate user entering a new chord const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7"); + + // Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit) fireEvent.click(screen.getAllByText("C#m7", { selector: 'button' })[0]); + // Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated) await waitFor(() => { expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0); }); @@ -1525,6 +1552,7 @@ describe("App", () => { }); }); + it("renders Settings and Help as focusable aria-disabled controls", () => { render(); const settingsButton = screen.getByRole("button", { name: "Settings coming soon" }); @@ -1561,6 +1589,8 @@ describe("App", () => { fireEvent.click(scoreButton); expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); + // Projects opened from a .bscope file have no live workspace, so score + // storage is gated behind the active-project notice. expect(screen.getByText(/Scores attach to the active analysis project/i)).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); @@ -1574,6 +1604,9 @@ describe("App", () => { expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); }); + // The compact nav is a separate rendered bar (shown on small viewports) with + // its own set of buttons; exercise it directly so the mobile navigation path + // is covered, not just the sidebar one. const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i }); const compactScoreButton = within(compactNav).getByRole("button", { name: /Score compact view/i }); expect(compactScoreButton).toBeEnabled(); From 1bb7dce7c944c956fc3bba6eba28f3c94878b2de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:21:47 +0900 Subject: [PATCH 7/8] docs(workspace): explain roadmap focus contract --- apps/desktop/src/features/workspace/SectionRoadmap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index e58e5aa1a..030deda05 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -14,7 +14,7 @@ interface SectionRoadmapProps { focusedSectionId?: string | null; } -/** Documented. */ +/** Render the rehearsal section roadmap and highlight the card identified by `focusedSectionId` when provided. */ export function SectionRoadmap({ song, activeRole, onSongUpdate, focusedSectionId = null }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); From b40cade0b2f376f6c54885a3aa3786224200dc02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:14:05 +0900 Subject: [PATCH 8/8] test(workspace): prove fallback lock-in scrolls roadmap --- apps/desktop/src/features/workspace/Workspace.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 11b7be0a3..5c252a124 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -314,10 +314,19 @@ describe("Workspace", () => { render(); + const card = document.getElementById("workspace-section-intro-1"); + const scrollIntoView = vi.fn(); + expect(card).toBeTruthy(); + Object.defineProperty(card!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + fireEvent.click(screen.getByRole("button", { name: "Open intro on the section roadmap from 0:00 to 0:08" })); expect(screen.getByText("Tonight's first lock-in is intro · 0:00–0:08. Count in on that card.")).toBeTruthy(); expect(document.getElementById("workspace-section-intro-1")).toBeTruthy(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); }); it("keeps the priorities action closed when no sections are mapped", () => {