diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index 5c1c16989..000000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,39 +0,0 @@ -## 2024-05-18 - Added focus visible styles for keyboard navigation -**Learning:** Interactive inline buttons (like the chord editor) and scrollable regions with `tabIndex={0}` do not automatically get focus visible styles, meaning keyboard users tabbing through won't know they are focused on them. Unlike central ` + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstCutoffPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstCutoffPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..64aa90244 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCutoffPlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstCutoffPlanCallout } from "./FirstCutoffPlanCallout"; + +function songWithoutCutoffPlan() { + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.cutoffPlan = ""; + } + } + return song; +} + +describe("FirstCutoffPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English cutoff plan is unavailable", () => { + render(); + + expect(screen.getByText("No cutoff plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean cutoff plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 컷오프 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstCutoffPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstCutoffPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..832ee7477 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCutoffPlanCallout.workspace-scope.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstCutoffPlanCallout } from "./FirstCutoffPlanCallout"; + +describe("FirstCutoffPlanCallout workspace scope", () => { + it("opens the song-structure renderer owned by the current workspace", () => { + const firstSong = createDemoRehearsalSong(); + const secondSong = createDemoRehearsalSong(); + secondSong.id = "second-workspace-song"; + + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="0"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { + name: "Open Bass Guitar cutoff at 0:30" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..83ed6aa35 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -326,4 +326,34 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first cutoff plan as workspace navigation", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getAllByText( + "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar cutoff at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Cut that off on Bass Guitar at 0:30 before the room starts./) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..13b5d52d0 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstCutoffPlanCallout } from "./FirstCutoffPlanCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -91,8 +92,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -353,6 +358,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/coverageContract.test.ts b/apps/desktop/src/features/workspace/coverageContract.test.ts new file mode 100644 index 000000000..4e7568986 --- /dev/null +++ b/apps/desktop/src/features/workspace/coverageContract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; + +describe("desktop owned production coverage", () => { + it("keeps the first cutoff-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstCutoffPlan.ts", + "src/features/workspace/FirstCutoffPlanCallout.tsx" + ]) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCutoffPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstCutoffPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..9a30b8c01 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCutoffPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstCutoffPlan } from "./firstCutoffPlan"; + +function songWithCutoffPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "cutoff-own"; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + cutoffPlan: "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup." + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstCutoffPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithCutoffPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstCutoffPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithCutoffPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithCutoffPlan(); + Object.defineProperty(section.roles[0]!, "cutoffPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile cutoffPlan getter"); + } + }); + + expect(() => resolveFirstCutoffPlan(song)).not.toThrow(); + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable cutoff-plan identity authority", () => { + const { song, section } = songWithCutoffPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "cutoff-own"; + } + }); + + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("does not let inherited cutoff plans establish the named copy", () => { + const { song, section } = songWithCutoffPlan(); + const inheritedRole = Object.create({ + cutoffPlan: "Inherited cutoff plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "lead-vocal" }, + name: { configurable: true, enumerable: true, value: "Lead Vocal" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the landing part", () => { + const { song, section } = songWithCutoffPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithCutoffPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCutoffPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstCutoffPlan.proxy-authority.test.ts new file mode 100644 index 000000000..cf9decc2d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCutoffPlan.proxy-authority.test.ts @@ -0,0 +1,78 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstCutoffPlan } from "./firstCutoffPlan"; + +const DEMO_CUTOFF_PLAN = + "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup."; + +describe("resolveFirstCutoffPlan own-data authority", () => { + it("uses the snapshotted own-data cutoff plan instead of a Proxy get trap", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "bass-guitar") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo cutoff-plan fixture is missing the expected Bass Guitar role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "cutoffPlan") { + return "Injected proxy cutoff."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstCutoffPlan(song)?.cutoffPlan).toBe(DEMO_CUTOFF_PLAN); + }); + + it("uses the snapshotted own-data time range instead of a Proxy get trap", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + expect(section).toBeDefined(); + if (!section) { + throw new Error("Demo cutoff-plan fixture is missing the expected verse section."); + } + const expectedEnd = section.timeRange.end; + section.timeRange = new Proxy(section.timeRange, { + get(target, property, receiver) { + if (property === "end") { + return expectedEnd + 15; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstCutoffPlan(song)?.atSeconds).toBe(expectedEnd); + }); + + it("returns snapshotted role identity and display copy instead of Proxy get values", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "bass-guitar") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo cutoff-plan fixture is missing the expected Bass Guitar role."); + } + const expectedId = role.id; + const expectedName = role.name; + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "name") { + return "Injected proxy role"; + } + return Reflect.get(target, property, receiver); + } + }); + + const resolved = resolveFirstCutoffPlan(song); + expect(resolved?.cutoffPlan).toBe(DEMO_CUTOFF_PLAN); + expect(resolved?.landingRoleId).toBe(expectedId); + expect(resolved?.landingRoleName).toBe(expectedName); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCutoffPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstCutoffPlan.section-label.test.ts new file mode 100644 index 000000000..c5f88c72b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCutoffPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstCutoffPlan } from "./firstCutoffPlan"; + +describe("resolveFirstCutoffPlan section-label authority", () => { + it("fails closed when runtime metadata supplies a label outside the shared SectionFormLabel contract", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + + (section as unknown as { label: string }).label = "verse-legacy"; + + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCutoffPlan.snapshot.test.ts b/apps/desktop/src/features/workspace/firstCutoffPlan.snapshot.test.ts new file mode 100644 index 000000000..5875d47e8 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCutoffPlan.snapshot.test.ts @@ -0,0 +1,41 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstCutoffPlan } from "./firstCutoffPlan"; + +describe("resolveFirstCutoffPlan descriptor snapshots", () => { + it("uses the cutoff-plan snapshot that admitted the role", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const role = section.roles[0]!; + const roleId = role.id; + let cutoffPlanDescriptorReads = 0; + const proxiedRole = new Proxy(role, { + getOwnPropertyDescriptor(target, key) { + if (key === "cutoffPlan") { + cutoffPlanDescriptorReads += 1; + return { + configurable: true, + enumerable: true, + writable: true, + value: + cutoffPlanDescriptorReads === 1 + ? "Cut this off with Lead Vocal; don't linger past the last beat." + : "Changed after validation." + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + } + }); + + section.roles = [proxiedRole]; + section.partGraph = [ + { role_id: roleId, is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [section]; + + expect(resolveFirstCutoffPlan(song)?.cutoffPlan).toBe( + "Cut this off with Lead Vocal; don't linger past the last beat." + ); + expect(cutoffPlanDescriptorReads).toBe(1); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCutoffPlan.test.ts b/apps/desktop/src/features/workspace/firstCutoffPlan.test.ts new file mode 100644 index 000000000..5da9dd02d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCutoffPlan.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatCutoffPlanTime, resolveFirstCutoffPlan } from "./firstCutoffPlan"; + +const DEMO_CUTOFF_PLAN = + "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup."; + +function withCutoffSection( + overrides: { + id?: string; + start?: number; + end?: number; + cutoffPlan?: string; + label?: "intro" | "verse" | "pre-chorus" | "chorus" | "bridge" | "outro" | "tag" | "pickup" | "stop" | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + functionLabel?: string; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-cutoff"; + section.label = overrides.label ?? "verse"; + section.groove = "Straight eighths with a late snare feel"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + const roleId = overrides.roleId ?? "lead-vocal"; + section.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "medium", + cue: { kind: "lyric", value: "city lights" }, + range: { lowestNote: "G#3", highestNote: "C#5" }, + setupNote: "Watch the breath before the last line of the verse.", + simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.", + overlapWarnings: ["Melodic overlap: competing with Keyboard 1 Right Hand."], + harmony: { + chord: "C#m7", + functionLabel: overrides.functionLabel ?? "vi melodic pull", + source: "model" + }, + harmonicExplanation: + "The melody leans on the ninth over vi, so the vocal line should feel like a lift rather than a strict chord-tone outline.", + confidence: { + level: "high", + source: "user", + notes: "Singer confirmed the pickup phrasing in rehearsal notes." + }, + cutoffPlan: overrides.cutoffPlan ?? DEMO_CUTOFF_PLAN, + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstCutoffPlan", () => { + it("picks the demo song's earliest cutoff plan and the part that leaves it", () => { + const resolved = resolveFirstCutoffPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.landingRole.id).toBe("bass-guitar"); + expect(resolved?.cutoffPlan).toBe(DEMO_CUTOFF_PLAN); + expect(resolved?.atSeconds).toBe(30); + expect(formatCutoffPlanTime(resolved?.atSeconds ?? -1)).toBe("0:30"); + expect(formatCutoffPlanTime(Number.NaN)).toBe("0:00"); + expect(formatCutoffPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a cutoff plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withCutoffSection(); + delete song.sections[0]!.roles[0]!.cutoffPlan; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.simplification = "Keep the sustained note centered."; + song.sections[0]!.roles[0]!.setupNote = DEMO_CUTOFF_PLAN; + song.sections[0]!.roles[0]!.transpositionPlan = + "If the singer drops to B minor, keep the shape a whole step lower."; + (song.sections[0]!.roles[0] as { vampPlan?: string }).vampPlan = + "Keep this part going until Lead Vocal enters in the next section."; + (song.sections[0]!.roles[0] as { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (song.sections[0]!.roles[0] as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (song.sections[0]!.roles[0] as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (song.sections[0]!.roles[0] as { articulationPlan?: string }).articulationPlan = + "Shorten the last chorus vowel so the band can hear the cutoff."; + (song.sections[0]!.roles[0] as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + (song.sections[0]!.roles[0] as { soloPlan?: string }).soloPlan = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + (song.sections[0]!.roles[0] as { padPlan?: string }).padPlan = + "Drop to a two-bar pad so the Keyboard 1 Right Hand run can land."; + (song.sections[0]!.roles[0] as { hitPlan?: string }).hitPlan = + "Land this hit with Lead Vocal on the verse downbeat; don't drift past the pickup."; + song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "city lights" }; + song.sections[0]!.roles[0]!.range = { lowestNote: "G#3", highestNote: "C#5" }; + song.sections[0]!.roles[0]!.overlapWarnings = ["Melodic overlap: competing with Keyboard 1 Right Hand."]; + song.sections[0]!.roles[0]!.harmony = { + chord: "C#m7", + functionLabel: "vi melodic pull", + source: "user" + }; + song.sections[0]!.roles[0]!.harmonicExplanation = "The ninth is the reason this lift works."; + song.sections[0]!.roles[0]!.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + song.sections[0]!.roles[0]!.confidence = { + level: "high", + source: "user", + notes: DEMO_CUTOFF_PLAN + }; + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("skips a blank cutoff plan", () => { + expect(resolveFirstCutoffPlan(withCutoffSection({ cutoffPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line cutoff plan", () => { + expect( + resolveFirstCutoffPlan(withCutoffSection({ cutoffPlan: "Keep the melody centered.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two cutoff plans", () => { + const song = withCutoffSection({ + id: "verse-late-cutoff", + start: 40, + end: 56, + roleId: "keys-right", + cutoffPlan: "Late cutoff." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + cutoffPlan: "Earlier cutoff." + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstCutoffPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.cutoffPlan).toBe("Earlier cutoff."); + expect(resolved?.atSeconds).toBe(24); + }); + + it("breaks same-time cutoff-plan ties with locale-independent id ordering", () => { + const song = withCutoffSection({ id: "ä-cutoff", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-cutoff"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstCutoffPlan(song)?.section.id).toBe("z-cutoff"); + }); + + it("prefers a high-priority cutoff part over a low-priority part in the same section", () => { + const song = withCutoffSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + cutoffPlan: "Low-priority cutoff." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + cutoffPlan: "High-priority cutoff." + }; + section.roles = [section.roles[0]!, highRole]; + section.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstCutoffPlan(song)?.landingRole.id).toBe("lead-vocal"); + expect(resolveFirstCutoffPlan(song)?.cutoffPlan).toBe("High-priority cutoff."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withCutoffSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + cutoffPlan: "ASCII cutoff." + }; + section.roles = [section.roles[0]!, asciiRole]; + section.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstCutoffPlan(song)?.landingRole.id).toBe("z-role"); + expect(resolveFirstCutoffPlan(song)?.cutoffPlan).toBe("ASCII cutoff."); + }); + + it("skips a cutoff plan whose graph node is inactive", () => { + expect(resolveFirstCutoffPlan(withCutoffSection({ isActive: false }))).toBeNull(); + }); + + it("skips a cutoff plan whose rehearsal window is unbounded", () => { + expect(resolveFirstCutoffPlan(withCutoffSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a cutoff plan whose end precedes its start", () => { + expect(resolveFirstCutoffPlan(withCutoffSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length cutoff-plan window", () => { + expect(resolveFirstCutoffPlan(withCutoffSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a cutoff plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstCutoffPlan( + withCutoffSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstCutoffPlan(null as never)).toBeNull(); + }); + + it("skips non-object roles and graph nodes without inventing a landing part", () => { + const song = withCutoffSection(); + song.sections[0]!.roles = [null as never, song.sections[0]!.roles[0]!]; + song.sections[0]!.partGraph = [null as never, song.sections[0]!.partGraph[0]!]; + expect(resolveFirstCutoffPlan(song)?.landingRole.id).toBe("lead-vocal"); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withCutoffSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("keeps the cutoff plan unnamed when role identities are duplicated", () => { + const song = withCutoffSection(); + const role = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstCutoffPlan(song)).toBeNull(); + }); + + it("bounds the cutoff plan to 180 Unicode code points", () => { + const song = withCutoffSection({ cutoffPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstCutoffPlan(song); + expect(resolved?.cutoffPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the cutoff-plan boundary", () => { + const song = withCutoffSection({ cutoffPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstCutoffPlan(song); + expect(Array.from(resolved?.cutoffPlan ?? "")).toHaveLength(180); + expect(resolved?.cutoffPlan.endsWith("😀")).toBe(true); + }); + + it("keeps the generated activity sentence recognizable after bounding a long partner name", () => { + const target = `Lead-${"A".repeat(180)}`; + const song = withCutoffSection({ + cutoffPlan: `Cut this off with ${target}; don't linger past the last beat.` + }); + const resolved = resolveFirstCutoffPlan(song); + expect(resolved?.cutoffPlan.startsWith("Cut this off with Lead-")).toBe(true); + expect(resolved?.cutoffPlan.endsWith("; don't linger past the last beat.")).toBe(true); + expect(Array.from(resolved?.cutoffPlan ?? "").length).toBeLessThanOrEqual(180); + }); + + it("preserves a short generated shared-cutoff sentence", () => { + const song = withCutoffSection({ + cutoffPlan: "Cut this off with Lead Vocal; don't linger past the last beat." + }); + expect(resolveFirstCutoffPlan(song)?.cutoffPlan).toBe( + "Cut this off with Lead Vocal; don't linger past the last beat." + ); + }); + + it("does not treat an empty generated partner as structured guidance", () => { + const song = withCutoffSection({ + cutoffPlan: "Cut this off with ; don't linger past the last beat." + }); + expect(resolveFirstCutoffPlan(song)?.cutoffPlan).toBe( + "Cut this off with ; don't linger past the last beat." + ); + }); + + it("contains exceptions from the runtime root instead of crashing", () => { + const song = new Proxy(withCutoffSection(), { + getOwnPropertyDescriptor() { + throw new Error("hostile descriptor"); + } + }); + expect(() => resolveFirstCutoffPlan(song as never)).not.toThrow(); + expect(resolveFirstCutoffPlan(song as never)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCutoffPlan.ts b/apps/desktop/src/features/workspace/firstCutoffPlan.ts new file mode 100644 index 000000000..8f94c541e --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCutoffPlan.ts @@ -0,0 +1,357 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_CUTOFF_PLAN_CHARACTERS = 180; +const GENERATED_ACTIVITY_CUTOFF_PLAN_PREFIX = "Cut this off with "; +const GENERATED_ACTIVITY_CUTOFF_PLAN_SUFFIX = "; don't linger past the last beat."; +const GENERATED_ACTIVITY_CUTOFF_PLAN_FIXED_CHARACTERS = Array.from( + GENERATED_ACTIVITY_CUTOFF_PLAN_PREFIX + GENERATED_ACTIVITY_CUTOFF_PLAN_SUFFIX +).length; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +/** Tonight's first cutoff plan: the earliest labeled section and the part that leaves it. */ +export type FirstCutoffPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + cutoffPlan: string; + atSeconds: number; +}; + +/** Format a non-negative cutoff-plan time as m:ss for rehearsal copy. */ +export function formatCutoffPlanTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Snapshot one owned data-property value without invoking a getter or Proxy get trap. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; +} + +/** Snapshot every numeric own data element from a bounded runtime array. */ +function ownedDenseRuntimeArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return null; + } + items.push(ownDataValue(value, index)); + } + return items; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Keep a bounded engine-owned cutoff sentence structurally recognizable for localization. */ +function boundedGeneratedActivityCutoffPlan(value: string): string | null { + if ( + !value.startsWith(GENERATED_ACTIVITY_CUTOFF_PLAN_PREFIX) || + !value.endsWith(GENERATED_ACTIVITY_CUTOFF_PLAN_SUFFIX) + ) { + return null; + } + const target = value + .slice( + GENERATED_ACTIVITY_CUTOFF_PLAN_PREFIX.length, + value.length - GENERATED_ACTIVITY_CUTOFF_PLAN_SUFFIX.length + ) + .trim(); + if (target.length === 0) { + return null; + } + const boundedTarget = truncateCodePoints( + target, + MAX_CUTOFF_PLAN_CHARACTERS - GENERATED_ACTIVITY_CUTOFF_PLAN_FIXED_CHARACTERS + ); + return `${GENERATED_ACTIVITY_CUTOFF_PLAN_PREFIX}${boundedTarget}${GENERATED_ACTIVITY_CUTOFF_PLAN_SUFFIX}`; +} + +/** Return a bounded snapshotted own cutoff plan, or null when it cannot be shown. */ +function ownedCutoffPlan(role: unknown): string | null { + if (!isRuntimeObject(role)) { + return null; + } + const cutoffPlan = ownDataValue(role, "cutoffPlan"); + if (typeof cutoffPlan !== "string") { + return null; + } + const trimmed = cutoffPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return ( + boundedGeneratedActivityCutoffPlan(trimmed) ?? + truncateCodePoints(trimmed, MAX_CUTOFF_PLAN_CHARACTERS) + ); +} + +/** Snapshot trusted role identity, display name, and priority without Proxy get authority. */ +function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownDataValue(role, "id"); + const name = ownDataValue(role, "name"); + const rehearsalPriority = ownDataValue(role, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK + }; +} + +/** Snapshot a section's bounded positive-length integer rehearsal window. */ +function ownedBoundedTimeRange( + section: RehearsalSection +): RehearsalSection["timeRange"] | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; + } + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer rehearsal priority, then a locale-independent stable id. */ +function pickLandingRole(roles: Role[]): Role | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = + PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!roles || !partGraph) { + return []; + } + + const safeRoleIds = roles.flatMap((role) => { + if (!isRuntimeObject(role)) { + return []; + } + const id = ownDataValue(role, "id"); + return typeof id === "string" && id.trim().length > 0 ? [id] : []; + }); + const safeGraphRoleIds = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && roleId.trim().length > 0 ? [roleId] : []; + }); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + partGraph.flatMap((node) => { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); + + return roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && + !repeatedRoleIds.has(metadata.id) && + activeIds.has(metadata.id) + ? [metadata] + : []; + }); +} + +/** Resolve a cutoff plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstCutoffPlan(song: RehearsalSong): FirstCutoffPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { + return null; + } + + const candidates = sections + .flatMap((section, sectionIndex) => { + if (!isRuntimeObject(section)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null + ) { + return []; + } + + const landingRole = pickLandingRole( + rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + const cutoffPlan = ownedCutoffPlan(metadata.role); + return cutoffPlan === null ? [] : [{ ...metadata, cutoffPlan }]; + }) + ); + if (!landingRole) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + landingRole: landingRole.role, + landingRoleId: landingRole.id, + landingRoleName: landingRole.name, + cutoffPlan: landingRole.cutoffPlan, + atSeconds: timeRange.end + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.sectionId, right.sectionId); + }); + + return candidates[0] ?? null; +} + +/** Return the first named cutoff plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstCutoffPlan(song: RehearsalSong): FirstCutoffPlan | null { + try { + return resolveSafeFirstCutoffPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..fa392a6e8 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, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,52 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + }); + + it("does not read inherited Object keys as section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("en", inheritedKey)).toBe("toString"); + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-cutoff-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstCutoffPlanOpenAction")).toBe("{at} {role} 컷오프 열기"); + expect(t("firstCutoffPlanBody")).toBe("{at} {section}에서 {role} 파트의 컷오프 계획이 있습니다."); + expect(t("firstCutoffPlanArmed")).toBe("{at}에서 {role} 파트의 컷오프를 맞춘 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..f5656ce01 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,13 +12,46 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a translator for the requested locale, falling back to the English dictionary for missing entries. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** 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..199bcbc35 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,13 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstCutoffPlanLabel": "Tonight's first cutoff plan", + "firstCutoffPlanOpenAction": "Open {role} cutoff at {at}", + "firstCutoffPlanBody": "{role} has a shared cutoff in the {section} at {at}.", + "firstCutoffPlanArmed": "Cut that off on {role} at {at} before the room starts.", + "firstCutoffPlanGeneratedGuidance": "Cut this off with {target}; don't linger past the last beat.", + "firstCutoffPlanGeneratedBandGuidance": "Cut this off with the rest of the band; don't linger past the last beat.", + "firstCutoffPlanUnavailable": "No cutoff plan is available. Stay on tonight's map for the next rehearsal cue.", "workspaceFirstRangeTitle": "Tonight's first range", "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..427e7e5cf 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,13 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstCutoffPlanLabel": "오늘 첫 컷오프 계획", + "firstCutoffPlanOpenAction": "{at} {role} 컷오프 열기", + "firstCutoffPlanBody": "{at} {section}에서 {role} 파트의 컷오프 계획이 있습니다.", + "firstCutoffPlanArmed": "{at}에서 {role} 파트의 컷오프를 맞춘 다음 합주를 시작하세요.", + "firstCutoffPlanGeneratedGuidance": "{target} 파트와 이 컷오프를 맞추세요. 마지막 박 뒤로 남기지 마세요.", + "firstCutoffPlanGeneratedBandGuidance": "나머지 밴드와 이 컷오프를 맞추세요. 마지막 박 뒤로 남기지 마세요.", + "firstCutoffPlanUnavailable": "사용 가능한 컷오프 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..fe07dc291 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -6,6 +6,18 @@ import { fileURLToPath } from "node:url"; const configDirectory = path.dirname(fileURLToPath(import.meta.url)); +/** Production files whose V8 coverage is owned by the desktop test gate. */ +export const DESKTOP_OWNED_PRODUCTION_COVERAGE = [ + "src/App.tsx", + "src/lib/export.ts", + "src/i18n/index.ts", + "src/features/score/ScoreViewer.tsx", + "src/features/score/ScoreView.tsx", + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstCutoffPlan.ts", + "src/features/workspace/FirstCutoffPlanCallout.tsx" +]; + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -19,14 +31,7 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: [ - "src/App.tsx", - "src/lib/export.ts", - "src/i18n/index.ts", - "src/features/score/ScoreViewer.tsx", - "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" - ], + include: DESKTOP_OWNED_PRODUCTION_COVERAGE, thresholds: { lines: 90, functions: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..880bb4c61 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -30,6 +30,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | 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. | +| First Cutoff Plan Callout (feature-local pattern) | No dedicated Figma node exists yet; keep feature-local until the design catalog is updated. | `apps/desktop/src/features/workspace/FirstCutoffPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `cutoffPlan` copy, the labeled section end, and the time. Do not invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, `transpositionPlan`, `vampPlan`, `fillPlan`, `tuningPlan`, `dynamicsPlan`, `articulationPlan`, `hookPlan`, `soloPlan`, `padPlan`, `hitPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-hit-plan, first-vamp-plan, first-pad-plan, first-solo-plan, first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, and first-articulation-plan. | | 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. | diff --git a/docs/doctoring/reduced-motion-first-cutoff-plan-navigation.md b/docs/doctoring/reduced-motion-first-cutoff-plan-navigation.md new file mode 100644 index 000000000..655d7c5eb --- /dev/null +++ b/docs/doctoring/reduced-motion-first-cutoff-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first cutoff-plan navigation + +Open tonight's first cutoff plan with `behavior: "auto"` when `prefers-reduced-motion: reduce` matches. Do not keep a smooth scroll for that next action. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..8467ae806 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,7 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + cutoffPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -474,6 +475,7 @@ const demoRehearsalSongSeed: RehearsalSong = { simplification: "Stay on roots if the chorus entrance gets muddy.", setupNote: "Keep the attack short so the verse breathes.", transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.", + cutoffPlan: "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup.", manualOverrides: [], overlapWarnings: [ "Density warning: competing with Keyboard Left Hand in low register." @@ -1497,6 +1499,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "cutoffPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1555,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.cutoffPlan !== undefined && typeof value.cutoffPlan !== "string") { + return invalidField(`${path}.cutoffPlan`); + } if (!isDenseArray(value.manualOverrides)) { return invalidField(`${path}.manualOverrides`); } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..b556600df 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -738,6 +738,7 @@ describe("shared type helpers", () => { expect(song.sections[0]?.roles[2]?.harmony?.source).toBe("model"); expect(song.sections[0]?.roles[0]?.harmonicExplanation).toContain("tonal floor"); expect(song.sections[0]?.roles[0]?.transpositionPlan).toContain("whole step lower"); + expect(song.sections[0]?.roles[0]?.cutoffPlan).toContain("verse last beat"); expect(song.collaboration?.assignments).toHaveLength(2); expect(song.collaboration?.comments[0]?.status).toBe("open"); expect(song.sections[0]?.roles[2]?.manualOverrides?.[0]).toMatchObject({ @@ -1257,6 +1258,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].cutoffPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.cutoffPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..9bfc72219 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -22,6 +22,12 @@ logger = logging.getLogger(__name__) +_OTHER_STEM_ROLE_IDS = frozenset({"keys-left", "keys-right", "acoustic-guitar"}) +_OTHER_STEM_SOURCE_LABEL = "Accompaniment" +_CUTOFF_PLAN_PREFIX = "Cut this off with " +_CUTOFF_PLAN_SUFFIX = "; don't linger past the last beat." +_CUTOFF_PLAN_BAND_TARGET = "the rest of the band" + class RoleExtractor: """Extracts roles and builds the part graph for song sections.""" @@ -330,6 +336,61 @@ def _build_roles( "acoustic_guitar": acoustic_guitar_role, } + @staticmethod + def _activity_cutoff_plan( + role_id: str, + roles: dict[str, RehearsalRole], + role_activity: dict[str, bool], + next_role_activity: dict[str, bool] | None, + ) -> str | None: + """Return bounded cutoff guidance only for a shared simultaneous deactivation. + + A cutoff plan is emitted only when real stem activity shows this role + becoming inactive with at least one other distinct source into the next + section. Mixed-source simultaneous deactivation is the evidence. Heuristic + fallback topology and last-section (no next activity) produce no plan. + """ + if ( + next_role_activity is None + or not role_activity.get(role_id, False) + or next_role_activity.get(role_id, False) + ): + return None + + deactivating_role_ids = [ + candidate_id + for candidate_id, is_active in role_activity.items() + if is_active and not next_role_activity.get(candidate_id, False) + ] + named_source_ids = [ + candidate_id + for candidate_id in deactivating_role_ids + if candidate_id not in _OTHER_STEM_ROLE_IDS + ] + other_stem_deactivating = any( + candidate_id in _OTHER_STEM_ROLE_IDS for candidate_id in deactivating_role_ids + ) + source_count = len(named_source_ids) + (1 if other_stem_deactivating else 0) + if source_count < 2: + return None + if source_count >= 3: + return f"{_CUTOFF_PLAN_PREFIX}{_CUTOFF_PLAN_BAND_TARGET}{_CUTOFF_PLAN_SUFFIX}" + + partner_ids = [candidate_id for candidate_id in named_source_ids if candidate_id != role_id] + other_name: str | None = None + if partner_ids: + other_id = partner_ids[0] + other_name = next( + (role["name"] for role in roles.values() if role["id"] == other_id), + None, + ) + elif other_stem_deactivating and role_id not in _OTHER_STEM_ROLE_IDS: + other_name = _OTHER_STEM_SOURCE_LABEL + + if other_name is None: + return None + return f"{_CUTOFF_PLAN_PREFIX}{other_name}{_CUTOFF_PLAN_SUFFIX}" + def _build_activity_topology( self, section_id: str, @@ -357,7 +418,17 @@ def _build_activity_topology( handoff_to, handoff_from = handoffs.get(role_id, ([], [])) if is_active: - active_roles.append(roles[role_key]) + role = roles[role_key] + cutoff_plan = self._activity_cutoff_plan( + role_id, + roles, + role_activity, + next_role_activity, + ) + if cutoff_plan is not None: + role = role.copy() + role["cutoffPlan"] = cutoff_plan + active_roles.append(role) part_graph.append( { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..f35971c75 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict class RoleType(str, Enum): @@ -83,6 +83,7 @@ class RehearsalRole(TypedDict): setupNote: str manualOverrides: list[ManualOverride] overlapWarnings: list[str] + cutoffPlan: NotRequired[str] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index 45a2ddada..297d20fa9 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -133,3 +133,162 @@ def test_role_extractor_falls_back_when_activity_detection_fails() -> None: assert result["topologies"][0]["section_id"] == "verse-1" assert result["topologies"][0]["part_graph"][0]["role_id"] == "bass-guitar" + + +def _extract_with_activity( + stem_activity: list[dict[str, bool]], + section_ids: list[str] | None = None, +) -> dict[str, dict[str, object]]: + """Run RoleExtractor against a patched stem-activity map.""" + extractor = RoleExtractor() + sections = [{"id": section_id} for section_id in (section_ids or ["verse-1", "chorus-1"])] + audio_features = { + "stems": {"bass": np.ones(200, dtype=np.float32)}, + "sr": 10, + "boundaries": [ + (float(index * 10), float((index + 1) * 10)) for index in range(len(sections)) + ], + } + with ( + patch( + "bandscope_analysis.roles.extractor.detect_stem_activity", + return_value=stem_activity, + ), + patch.object( + RoleExtractor, + "_extract_features", + return_value=( + {"lowestNote": "", "highestNote": ""}, + "", + {"lowestNote": "E1", "highestNote": "E3"}, + "Em", + ), + ), + ): + result = extractor.extract(sections, audio_features) + return {role["id"]: role for role in result["topologies"][0]["active_roles"]} + + +def test_role_extractor_emits_activity_corroborated_cutoff_plan() -> None: + """Emit a cutoff plan only when two distinct sources deactivate together.""" + verse_roles = _extract_with_activity( + [ + {"bass": True, "vocals": True, "other": False}, + {"bass": False, "vocals": False, "other": False}, + ] + ) + assert verse_roles["bass-guitar"]["cutoffPlan"] == ( + "Cut this off with Lead Vocal; don't linger past the last beat." + ) + assert verse_roles["lead-vocal"]["cutoffPlan"] == ( + "Cut this off with Bass Guitar; don't linger past the last beat." + ) + + +def test_role_extractor_groups_shared_other_stem_deactivation_for_cutoff_plan() -> None: + """Name the shared accompaniment stem without inventing a specific instrument.""" + verse_roles = _extract_with_activity( + [ + {"bass": True, "vocals": False, "other": True}, + {"bass": False, "vocals": False, "other": False}, + ] + ) + assert verse_roles["bass-guitar"]["cutoffPlan"] == ( + "Cut this off with Accompaniment; don't linger past the last beat." + ) + assert verse_roles["keys-right"]["cutoffPlan"] == ( + "Cut this off with Bass Guitar; don't linger past the last beat." + ) + + +def test_role_extractor_keeps_mixed_deactivations_as_shared_cutoff_evidence() -> None: + """Mixed simultaneous deactivation is the shared-cutoff evidence, not an ambiguity.""" + verse_roles = _extract_with_activity( + [ + {"bass": True, "vocals": True, "other": True}, + {"bass": False, "vocals": False, "other": False}, + ] + ) + assert verse_roles["bass-guitar"]["cutoffPlan"] == ( + "Cut this off with the rest of the band; don't linger past the last beat." + ) + assert verse_roles["lead-vocal"]["cutoffPlan"] == ( + "Cut this off with the rest of the band; don't linger past the last beat." + ) + assert verse_roles["acoustic-guitar"]["cutoffPlan"] == ( + "Cut this off with the rest of the band; don't linger past the last beat." + ) + + +def test_role_extractor_keeps_single_exit_cutoff_plan_unnamed() -> None: + """A lone exit is not a shared cutoff.""" + verse_roles = _extract_with_activity( + [ + {"bass": True, "vocals": True, "other": False}, + {"bass": True, "vocals": False, "other": False}, + ] + ) + assert "cutoffPlan" not in verse_roles["bass-guitar"] + assert "cutoffPlan" not in verse_roles["lead-vocal"] + + +def test_role_extractor_keeps_last_section_cutoff_plan_unnamed() -> None: + """Without a next section there is no shared-deactivation evidence.""" + extractor = RoleExtractor() + sections = [{"id": "outro"}] + audio_features = { + "stems": {"bass": np.ones(100, dtype=np.float32)}, + "sr": 10, + "boundaries": [(0.0, 10.0)], + } + with ( + patch( + "bandscope_analysis.roles.extractor.detect_stem_activity", + return_value=[{"bass": True, "vocals": True, "other": True}], + ), + patch.object( + RoleExtractor, + "_extract_features", + return_value=( + {"lowestNote": "", "highestNote": ""}, + "", + {"lowestNote": "E1", "highestNote": "E3"}, + "Em", + ), + ), + ): + result = extractor.extract(sections, audio_features) + outro_roles = {role["id"]: role for role in result["topologies"][0]["active_roles"]} + assert "cutoffPlan" not in outro_roles["bass-guitar"] + + +def test_role_extractor_keeps_heuristic_cutoff_plan_unnamed() -> None: + """Heuristic fallback topology must not invent a shared cutoff.""" + extractor = RoleExtractor() + result = extractor.extract([{"id": "intro"}, {"id": "verse-1"}]) + intro_roles = {role["id"]: role for role in result["topologies"][0]["active_roles"]} + verse_roles = {role["id"]: role for role in result["topologies"][1]["active_roles"]} + assert all("cutoffPlan" not in role for role in intro_roles.values()) + assert all("cutoffPlan" not in role for role in verse_roles.values()) + + +def test_activity_cutoff_plan_fails_closed_without_a_named_partner() -> None: + """Unknown deactivation partners stay unnamed instead of inventing copy.""" + assert ( + RoleExtractor._activity_cutoff_plan( + "bass-guitar", + {}, + {"bass-guitar": True, "lead-vocal": True}, + {"bass-guitar": False, "lead-vocal": False}, + ) + is None + ) + assert ( + RoleExtractor._activity_cutoff_plan( + "keys-right", + {}, + {"keys-right": True, "lead-vocal": True}, + {"keys-right": False, "lead-vocal": False}, + ) + is None + )