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/FirstRiffPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstRiffPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..daafa5112 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstRiffPlanCallout.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 { FirstRiffPlanCallout } from "./FirstRiffPlanCallout"; + +function songWithoutRiffPlan() { + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.riffPlan = ""; + } + } + return song; +} + +describe("FirstRiffPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English riff plan is unavailable", () => { + render(); + + expect(screen.getByText("No riff plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean riff plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 리프 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstRiffPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstRiffPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..c04d9fec0 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstRiffPlanCallout.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 { FirstRiffPlanCallout } from "./FirstRiffPlanCallout"; + +describe("FirstRiffPlanCallout 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 riff at 0:10" + }); + 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..1c556e688 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 riff 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( + "Bass locks the verse riff on the open fifth; keep it dry before the chorus lift." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar riff at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that riff on Bass Guitar at 0:10 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..11f12a836 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 { FirstRiffPlanCallout } from "./FirstRiffPlanCallout"; 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..0b9e79e61 --- /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 riff-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstRiffPlan.ts", + "src/features/workspace/FirstRiffPlanCallout.tsx" + ]) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRiffPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstRiffPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..9c5b366fd --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRiffPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstRiffPlan } from "./firstRiffPlan"; + +function songWithRiffPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "riff-own"; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + riffPlan: "Bass locks the verse riff on the open fifth; keep it dry before the chorus lift." + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstRiffPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithRiffPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstRiffPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithRiffPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithRiffPlan(); + Object.defineProperty(section.roles[0]!, "riffPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile riffPlan getter"); + } + }); + + expect(() => resolveFirstRiffPlan(song)).not.toThrow(); + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable riff-plan identity authority", () => { + const { song, section } = songWithRiffPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "riff-own"; + } + }); + + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("does not let inherited riff plans establish the named copy", () => { + const { song, section } = songWithRiffPlan(); + const inheritedRole = Object.create({ + riffPlan: "Inherited riff plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "bass-guitar" }, + name: { configurable: true, enumerable: true, value: "Bass Guitar" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithRiffPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithRiffPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRiffPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstRiffPlan.proxy-authority.test.ts new file mode 100644 index 000000000..4780bc207 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRiffPlan.proxy-authority.test.ts @@ -0,0 +1,52 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstRiffPlan } from "./firstRiffPlan"; + +const DEMO_RIFF_PLAN = + "Bass locks the verse riff on the open fifth; keep it dry before the chorus lift."; + +describe("resolveFirstRiffPlan own-data authority", () => { + it("uses the snapshotted own-data riff 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 riff-plan fixture is missing the expected Bass Guitar role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "riffPlan") { + return "Injected proxy riff."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstRiffPlan(song)?.riffPlan).toBe(DEMO_RIFF_PLAN); + }); + + it("uses snapshotted time-range data 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 riff-plan fixture is missing the expected verse section."); + } + + const trustedTimeRange = section.timeRange; + section.timeRange = new Proxy(trustedTimeRange, { + get(target, property, receiver) { + if (property === "start") { + return 20; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstRiffPlan(song)?.atSeconds).toBe(10); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRiffPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstRiffPlan.section-label.test.ts new file mode 100644 index 000000000..2cb251427 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRiffPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstRiffPlan } from "./firstRiffPlan"; + +describe("resolveFirstRiffPlan 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(resolveFirstRiffPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRiffPlan.test.ts b/apps/desktop/src/features/workspace/firstRiffPlan.test.ts new file mode 100644 index 000000000..24a5458d4 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRiffPlan.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatRiffPlanTime, resolveFirstRiffPlan } from "./firstRiffPlan"; + +const DEMO_RIFF_PLAN = + "Bass locks the verse riff on the open fifth; keep it dry before the chorus lift."; + +function withRiffSection( + overrides: { + id?: string; + start?: number; + end?: number; + riffPlan?: 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-riff"; + 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 ?? "bass-guitar"; + section.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Bass Guitar", + rehearsalPriority: overrides.priority ?? "medium", + cue: { kind: "transition", value: "Hold through the pickup before the downbeat." }, + range: { lowestNote: "C#2", highestNote: "E3" }, + setupNote: "Keep the attack short so the verse breathes.", + simplification: "Stay on roots if the chorus entrance gets muddy.", + overlapWarnings: ["Density warning: competing with Keyboard Left Hand in low register."], + harmony: { + chord: "C#m7", + functionLabel: overrides.functionLabel ?? "vi pedal anchor", + source: "model" + }, + harmonicExplanation: + "The bass holds the vi center so the rest of the section can lean into the pickup without losing the tonal floor.", + confidence: { + level: "medium", + source: "model", + notes: "Watch the slide into the turnaround." + }, + riffPlan: overrides.riffPlan ?? DEMO_RIFF_PLAN, + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstRiffPlan", () => { + it("picks the demo song's earliest riff plan and the part that owns it", () => { + const resolved = resolveFirstRiffPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("bass-guitar"); + expect(resolved?.riffPlan).toBe(DEMO_RIFF_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatRiffPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatRiffPlanTime(Number.NaN)).toBe("0:00"); + expect(formatRiffPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a riff plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, voicing plans, hook plans, capo plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withRiffSection(); + delete song.sections[0]!.roles[0]!.riffPlan; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.simplification = "Stay on roots if the chorus entrance gets muddy."; + song.sections[0]!.roles[0]!.setupNote = DEMO_RIFF_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 { 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 { voicingPlan?: string }).voicingPlan = + "Keep the third out of the left hand so the bass owns the floor."; + (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 { capoPlan?: string }).capoPlan = + "Capo 2 keeps the open-string riff under the singer."; + song.sections[0]!.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup before the downbeat." }; + song.sections[0]!.roles[0]!.range = { lowestNote: "C#2", highestNote: "E3" }; + song.sections[0]!.roles[0]!.overlapWarnings = ["Density warning: competing with Keyboard Left Hand in low register."]; + song.sections[0]!.roles[0]!.harmony = { + chord: "C#m7", + functionLabel: "vi pedal anchor", + source: "user" + }; + song.sections[0]!.roles[0]!.harmonicExplanation = "The bass holds the vi center."; + 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_RIFF_PLAN + }; + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("skips a blank riff plan", () => { + expect(resolveFirstRiffPlan(withRiffSection({ riffPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line riff plan", () => { + expect( + resolveFirstRiffPlan(withRiffSection({ riffPlan: "Keep the riff centered.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two riff plans", () => { + const song = withRiffSection({ + id: "verse-late", + start: 40, + end: 56, + roleId: "keys-right", + riffPlan: "Late riff." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "low", + riffPlan: "Earlier riff." + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstRiffPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("bass-guitar"); + expect(resolved?.riffPlan).toBe("Earlier riff."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time riff-plan ties with locale-independent id ordering", () => { + const song = withRiffSection({ id: "ä-riff", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-riff"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstRiffPlan(song)?.section.id).toBe("z-riff"); + }); + + it("prefers a high-priority riff part over a low-priority part in the same section", () => { + const song = withRiffSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + riffPlan: "Low-priority riff." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high" as const, + riffPlan: "High-priority riff." + }; + section.roles = [section.roles[0]!, highRole]; + section.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstRiffPlan(song)?.holdingRole.id).toBe("bass-guitar"); + expect(resolveFirstRiffPlan(song)?.riffPlan).toBe("High-priority riff."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withRiffSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + riffPlan: "ASCII riff." + }; + 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(resolveFirstRiffPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstRiffPlan(song)?.riffPlan).toBe("ASCII riff."); + }); + + it("skips a riff plan whose graph node is inactive", () => { + expect(resolveFirstRiffPlan(withRiffSection({ isActive: false }))).toBeNull(); + }); + + it("skips a riff plan whose rehearsal window is unbounded", () => { + expect(resolveFirstRiffPlan(withRiffSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a riff plan whose end precedes its start", () => { + expect(resolveFirstRiffPlan(withRiffSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length riff-plan window", () => { + expect(resolveFirstRiffPlan(withRiffSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a riff plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstRiffPlan( + withRiffSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstRiffPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withRiffSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("keeps the riff plan unnamed when role identities are duplicated", () => { + const song = withRiffSection(); + 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(resolveFirstRiffPlan(song)).toBeNull(); + }); + + it("bounds the riff plan to 180 Unicode code points", () => { + const song = withRiffSection({ riffPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstRiffPlan(song); + expect(resolved?.riffPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the riff-plan boundary", () => { + const song = withRiffSection({ riffPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstRiffPlan(song); + expect(Array.from(resolved?.riffPlan ?? "")).toHaveLength(180); + expect(resolved?.riffPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRiffPlan.ts b/apps/desktop/src/features/workspace/firstRiffPlan.ts new file mode 100644 index 000000000..de84c9df7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRiffPlan.ts @@ -0,0 +1,315 @@ +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_RIFF_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); + +/** Tonight's first riff plan: the earliest labeled section and the part that owns it. */ +export type FirstRiffPlan = { + section: RehearsalSection; + sectionIndex: number; + holdingRole: RehearsalRole; + riffPlan: string; + atSeconds: number; +}; + +/** Format a non-negative riff-plan time as m:ss for rehearsal copy. */ +export function formatRiffPlanTime(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; +} + +/** Return whether every numeric index is an own data element in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** 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); +} + +/** Return a bounded snapshotted own riff plan, or null when it cannot be shown. */ +function ownedRiffPlan(role: unknown): string | null { + if (!isRuntimeObject(role)) { + return null; + } + const descriptor = Object.getOwnPropertyDescriptor(role, "riffPlan"); + if (descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, "value")) { + return null; + } + const riffPlan = descriptor.value; + if (typeof riffPlan !== "string") { + return null; + } + const trimmed = riffPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_RIFF_PLAN_CHARACTERS); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwnData(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a canonical form label from the shared contract. */ +function hasSupportedSectionLabel(section: RehearsalSection): boolean { + return ( + hasOwnData(section, "label") && + typeof section.label === "string" && + SECTION_FORM_LABEL_SET.has(section.label) + ); +} + +/** 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 the earlier ranked role, then rehearsal priority, then a locale-independent id. */ +function pickHoldingRole(roles: RehearsalRole[]): RehearsalRole | 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): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Resolve a riff plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstRiffPlan(song: RehearsalSong): FirstRiffPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sectionsValue = ownDataValue(song, "sections"); + if (!isDenseRuntimeArray(sectionsValue)) { + return null; + } + const sections = sectionsValue as RehearsalSection[]; + + const candidates = sections + .map((section, sectionIndex) => ({ + section, + sectionIndex, + timeRange: isRuntimeObject(section) ? ownedBoundedTimeRange(section) : null + })) + .filter( + ({ section, timeRange }) => + isRuntimeObject(section) && + hasSupportedSectionLabel(section) && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + timeRange !== null + ) + .flatMap(({ section, sectionIndex, timeRange }) => { + if (!timeRange) { + return []; + } + const holdingRole = pickHoldingRole( + rankedActiveRoles(section).filter((role) => ownedRiffPlan(role) !== null) + ); + if (!holdingRole) { + return []; + } + const riffPlan = ownedRiffPlan(holdingRole); + if (!riffPlan) { + return []; + } + return [ + { + section, + sectionIndex, + holdingRole, + riffPlan, + atSeconds: timeRange.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.section.id, right.section.id); + }); + + return candidates[0] ?? null; +} + +/** Return the first named riff plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstRiffPlan(song: RehearsalSong): FirstRiffPlan | null { + try { + return resolveSafeFirstRiffPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..dbc383305 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-riff-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstRiffPlanOpenAction")).toBe("{at} {role} 리프 열기"); + expect(t("firstRiffPlanBody")).toBe("{at} {section}에서 {role} 파트의 리프 계획이 있습니다."); + expect(t("firstRiffPlanArmed")).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..bf5ea9bbb 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,11 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstRiffPlanLabel": "Tonight's first riff plan", + "firstRiffPlanOpenAction": "Open {role} riff at {at}", + "firstRiffPlanBody": "{role} still has a riff plan in the {section} at {at}.", + "firstRiffPlanArmed": "Lock that riff on {role} at {at} before the room starts.", + "firstRiffPlanUnavailable": "No riff 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..d8da7f927 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,11 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstRiffPlanLabel": "오늘 첫 리프 계획", + "firstRiffPlanOpenAction": "{at} {role} 리프 열기", + "firstRiffPlanBody": "{at} {section}에서 {role} 파트의 리프 계획이 있습니다.", + "firstRiffPlanArmed": "{at}에서 {role} 파트의 리프를 맞춘 다음 합주를 시작하세요.", + "firstRiffPlanUnavailable": "사용 가능한 리프 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", "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..b7003090f 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/firstRiffPlan.ts", + "src/features/workspace/FirstRiffPlanCallout.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..5050b12f6 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 Riff Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstRiffPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `riffPlan` copy, the labeled section start, and the time. Do not invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, `transpositionPlan`, `fillPlan`, `tuningPlan`, `dynamicsPlan`, `articulationPlan`, `voicingPlan`, `hookPlan`, `capoPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, first-articulation-plan, first-voicing-plan, and first-capo-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-riff-plan-navigation.md b/docs/doctoring/reduced-motion-first-riff-plan-navigation.md new file mode 100644 index 000000000..ebf36a568 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-riff-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first riff-plan navigation + +Open tonight's first riff 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..e7f77d58e 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; + riffPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -473,6 +474,7 @@ const demoRehearsalSongSeed: RehearsalSong = { rehearsalPriority: "high", simplification: "Stay on roots if the chorus entrance gets muddy.", setupNote: "Keep the attack short so the verse breathes.", + riffPlan: "Bass locks the verse riff on the open fifth; keep it dry before the chorus lift.", transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.", manualOverrides: [], overlapWarnings: [ @@ -1497,6 +1499,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "riffPlan", "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.riffPlan !== undefined && typeof value.riffPlan !== "string") { + return invalidField(`${path}.riffPlan`); + } 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..b67c953ca 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]?.riffPlan).toContain("verse riff"); 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].riffPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.riffPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => {