diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d7b9ca91..9fb404b3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -93,6 +93,7 @@ Last updated: 2026-03-11 - GitHub-facing setup is staged: no-git -> local-git -> GitHub-connected -> protected-branches with required checks. - Shared contracts live in `packages/shared-types` so the UI can evolve without importing Python internals. - Shared contracts should ultimately model section, role, cue, confidence, and export artifacts explicitly enough that desktop UI and analysis outputs do not invent their own parallel schemas. +- The current shared-types baseline includes a rehearsal-domain fixture that exercises section, role, cue, confidence, provenance, and export-summary fields in the desktop shell before the full analysis pipeline lands. - Product and UX decisions should prefer rehearsal-first simplicity while still maintaining high analytical accuracy. - Security decisions should prefer allowlisted narrow capabilities over generic convenience APIs. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c971019b..ceb7824a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,7 @@ "build": "vite build", "lint": "eslint \"src/**/*.{ts,tsx}\" vite.config.ts", "typecheck": "tsc --noEmit", - "test": "vitest run --coverage" + "test": "node -e \"require('node:fs').mkdirSync('coverage/.tmp', { recursive: true })\" && vitest run --coverage" }, "dependencies": { "@bandscope/shared-types": "0.1.0", diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 1e8df898..a1a9dfdd 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -2,11 +2,19 @@ import { render, screen } from "@testing-library/react"; import { App } from "./App"; describe("App", () => { - it("shows the harness status and supported formats", () => { + it("shows the shared rehearsal overview", () => { render(); expect(screen.getByRole("heading", { name: /BandScope Bootstrap/i })).toBeInTheDocument(); expect(screen.getByText(/wav, mp3, flac, m4a/i)).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /Verse 1/i })).toBeInTheDocument(); + expect(screen.getByText(/Bass Guitar/i)).toBeInTheDocument(); + expect(screen.getByText(/Keyboard 1 Right Hand/i)).toBeInTheDocument(); + expect(screen.getByText(/Lead Vocal/i)).toBeInTheDocument(); + expect(screen.getByText(/Section confidence: Needs ear check \(Auto-detected\)/i)).toBeInTheDocument(); + expect(screen.getAllByText(/harmony source: Auto-detected/i)).toHaveLength(3); + expect(screen.getByText(/manual override: C#m11 \(User-confirmed\)/i)).toBeInTheDocument(); expect(screen.getByText(/Home baseline is wired/i)).toBeInTheDocument(); }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 1ab5d85d..e4545b64 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,8 @@ -import { SUPPORTED_AUDIO_FORMATS } from "@bandscope/shared-types"; +import { useMemo } from "react"; +import { + createDemoRehearsalSong, + SUPPORTED_AUDIO_FORMATS +} from "@bandscope/shared-types"; import { ChordsFeature } from "./features/chords"; import { HomeFeature } from "./features/home"; import { PlayerFeature } from "./features/player"; @@ -8,6 +12,16 @@ import { createTranslator, detectPreferredLocale } from "./i18n"; export function App() { const t = createTranslator(detectPreferredLocale()); + const rehearsalSong = useMemo(() => createDemoRehearsalSong(), []); + const confidenceLabels = { + low: t("confidenceLevelLow"), + medium: t("confidenceLevelMedium"), + high: t("confidenceLevelHigh") + } as const; + const provenanceLabels = { + model: t("provenanceSourceModel"), + user: t("provenanceSourceUser") + } as const; return (
@@ -16,6 +30,36 @@ export function App() {

{t("supportedFormats")}: {SUPPORTED_AUDIO_FORMATS.join(", ")}

+
+

{rehearsalSong.title}

+

{rehearsalSong.exportSummary.headline}

+
+ {rehearsalSong.sections.map((section) => ( +
+

{section.label}

+

{section.groove}

+

+ {t("sectionConfidence")}: {confidenceLabels[section.confidence.level]} ({provenanceLabels[section.confidence.source]}) +

+
    + {section.roles.map((role) => ( +
  • + {role.name} + - {role.harmony.chord} + - {role.cue.value} + - {t("roleConfidence")}: {confidenceLabels[role.confidence.level]} + - {t("harmonySource")}: {provenanceLabels[role.harmony.source]} + {role.manualOverrides.map((override, index) => ( + + {" "} + - {t("manualOverride")}: {override.value.chord} ({provenanceLabels[override.source]}) + + ))} +
  • + ))} +
+
+ ))} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 2f031557..0b2f836f 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -6,5 +6,14 @@ "chordsCard": "Chord analysis baseline is wired.", "rangesCard": "Range analysis baseline is wired.", "settingsCard": "Settings baseline is wired.", - "supportedFormats": "Supported input formats" + "supportedFormats": "Supported input formats", + "sectionConfidence": "Section confidence", + "roleConfidence": "confidence", + "harmonySource": "harmony source", + "manualOverride": "manual override", + "confidenceLevelLow": "Low confidence", + "confidenceLevelMedium": "Needs ear check", + "confidenceLevelHigh": "Ready to trust", + "provenanceSourceModel": "Auto-detected", + "provenanceSourceUser": "User-confirmed" } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 4e20d6af..42545ce8 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -6,5 +6,14 @@ "chordsCard": "코드 분석 기준선이 연결되었습니다.", "rangesCard": "음역 분석 기준선이 연결되었습니다.", "settingsCard": "설정 기준선이 연결되었습니다.", - "supportedFormats": "지원 입력 형식" + "supportedFormats": "지원 입력 형식", + "sectionConfidence": "구간 신뢰도", + "roleConfidence": "신뢰도", + "harmonySource": "화성 출처", + "manualOverride": "수동 수정", + "confidenceLevelLow": "확신이 낮음", + "confidenceLevelMedium": "귀로 한 번 더 확인", + "confidenceLevelHigh": "믿고 가져가도 됨", + "provenanceSourceModel": "자동 추정", + "provenanceSourceUser": "사용자 확인" } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 44779367..4410c5ce 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -7,6 +7,214 @@ export type ProjectSummary = { supportedAudioFormats: readonly (typeof SUPPORTED_AUDIO_FORMATS)[number][]; }; +export type ConfidenceLevel = "low" | "medium" | "high"; +export type ProvenanceSource = "model" | "user"; +export type CueAnchorKind = "lyric" | "count" | "transition"; +export type RehearsalPriority = "low" | "medium" | "high"; +export type ExportFormat = "cue-sheet" | "chart-summary"; + +export type ConfidenceMarker = { + level: ConfidenceLevel; + source: ProvenanceSource; + notes: string; +}; + +export type CueAnchor = { + kind: CueAnchorKind; + value: string; +}; + +export type RangeSummary = { + lowestNote: string; + highestNote: string; +}; + +export type RehearsalHarmony = { + chord: string; + functionLabel: string; + source: ProvenanceSource; +}; + +export type ManualOverride = + { + field: "harmony"; + value: RehearsalHarmony & { source: "user" }; + source: "user"; + }; + +export type RehearsalRole = { + id: string; + name: string; + roleType: "instrument" | "vocal" | "hand"; + harmony: RehearsalHarmony; + cue: CueAnchor; + range: RangeSummary; + confidence: ConfidenceMarker; + rehearsalPriority: RehearsalPriority; + simplification: string; + setupNote: string; + manualOverrides: ManualOverride[]; +}; + +export type RehearsalSection = { + id: string; + label: string; + groove: string; + confidence: ConfidenceMarker; + roles: RehearsalRole[]; +}; + +export type ExportSummary = { + format: ExportFormat; + headline: string; + focusSections: string[]; +}; + +export type RehearsalSong = { + id: string; + title: string; + sections: RehearsalSection[]; + exportSummary: ExportSummary; +}; + +const CONFIDENCE_LEVELS = ["low", "medium", "high"] as const; +const REHEARSAL_PRIORITIES = ["low", "medium", "high"] as const; +const PROVENANCE_SOURCES = ["model", "user"] as const; +const CUE_ANCHOR_KINDS = ["lyric", "count", "transition"] as const; +const ROLE_TYPES = ["instrument", "vocal", "hand"] as const; +const EXPORT_FORMATS = ["cue-sheet", "chart-summary"] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isDenseArray(value: unknown): value is unknown[] { + return Array.isArray(value) && Array.from({ length: value.length }, (_, index) => index in value).every(Boolean); +} + +function isOneOf(options: readonly T[], value: unknown): value is T { + return typeof value === "string" && options.includes(value as T); +} + +function invalidField(path: string): string { + return `Invalid rehearsal song contract: invalid field '${path}'`; +} + +const demoRehearsalSongSeed: RehearsalSong = { + id: "demo-song", + title: "Late Night Set", + sections: [ + { + id: "verse-1", + label: "Verse 1", + groove: "Straight eighths with a late snare feel", + confidence: { + level: "medium", + source: "model", + notes: "Double-check the pickup into the chorus." + }, + roles: [ + { + id: "bass-guitar", + name: "Bass Guitar", + roleType: "instrument", + harmony: { + chord: "C#m7", + functionLabel: "vi pedal anchor", + source: "model" + }, + cue: { + kind: "transition", + value: "Hold through the pickup before the downbeat.", + }, + range: { + lowestNote: "C#2", + highestNote: "E3" + }, + confidence: { + level: "medium", + source: "model", + notes: "Watch the slide into the turnaround." + }, + rehearsalPriority: "high", + simplification: "Stay on roots if the chorus entrance gets muddy.", + setupNote: "Keep the attack short so the verse breathes.", + manualOverrides: [] + }, + { + id: "keys-right", + name: "Keyboard 1 Right Hand", + roleType: "hand", + harmony: { + chord: "Emaj7", + functionLabel: "Imaj7 color", + source: "model" + }, + cue: { + kind: "count", + value: "Enter on beat 2 after the pickup." + }, + range: { + lowestNote: "B3", + highestNote: "G#5" + }, + confidence: { + level: "medium", + source: "model", + notes: "Top note voicing may need a quick ear check." + }, + rehearsalPriority: "high", + simplification: "Drop the top extension if the chorus turnaround still feels busy.", + setupNote: "Keep the patch bright enough to stay over the guitars.", + manualOverrides: [] + }, + { + id: "lead-vocal", + name: "Lead Vocal", + roleType: "vocal", + harmony: { + chord: "C#m7", + functionLabel: "vi melodic pull", + source: "model" + }, + cue: { + kind: "lyric", + value: "city lights" + }, + range: { + lowestNote: "G#3", + highestNote: "C#5" + }, + confidence: { + level: "high", + source: "user", + notes: "Singer confirmed the pickup phrasing in rehearsal notes." + }, + rehearsalPriority: "medium", + simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.", + setupNote: "Watch the breath before the last line of the verse.", + manualOverrides: [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ] + } + ] + } + ], + exportSummary: { + format: "cue-sheet", + headline: "Start with Verse 1 entrances before the chorus lift.", + focusSections: ["Verse 1"] + } +}; + export function createDefaultProjectSummary(input: { id: string; title: string; @@ -18,3 +226,238 @@ export function createDefaultProjectSummary(input: { supportedAudioFormats: SUPPORTED_AUDIO_FORMATS }; } + +export function createDemoRehearsalSong(): RehearsalSong { + return structuredClone(demoRehearsalSongSeed); +} + +function validateConfidenceMarker(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + if (!isOneOf(CONFIDENCE_LEVELS, value.level)) { + return invalidField(`${path}.level`); + } + if (!isOneOf(PROVENANCE_SOURCES, value.source)) { + return invalidField(`${path}.source`); + } + if (typeof value.notes !== "string") { + return invalidField(`${path}.notes`); + } + + return null; +} + +function validateCueAnchor(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + if (!isOneOf(CUE_ANCHOR_KINDS, value.kind)) { + return invalidField(`${path}.kind`); + } + if (typeof value.value !== "string") { + return invalidField(`${path}.value`); + } + + return null; +} + +function validateRangeSummary(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + if (typeof value.lowestNote !== "string") { + return invalidField(`${path}.lowestNote`); + } + if (typeof value.highestNote !== "string") { + return invalidField(`${path}.highestNote`); + } + + return null; +} + +function validateRehearsalHarmony(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + if (typeof value.chord !== "string") { + return invalidField(`${path}.chord`); + } + if (typeof value.functionLabel !== "string") { + return invalidField(`${path}.functionLabel`); + } + if (!isOneOf(PROVENANCE_SOURCES, value.source)) { + return invalidField(`${path}.source`); + } + + return null; +} + +function validateManualOverride(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + if (value.field !== "harmony") { + return invalidField(`${path}.field`); + } + if (value.source !== "user") { + return invalidField(`${path}.source`); + } + + const harmonyError = validateRehearsalHarmony(value.value, `${path}.value`); + if (harmonyError) { + return harmonyError; + } + const harmonyValue = value.value as RehearsalHarmony; + if (harmonyValue.source !== "user") { + return invalidField(`${path}.value.source`); + } + + return null; +} + +function validateRehearsalRole(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + if (typeof value.id !== "string") { + return invalidField(`${path}.id`); + } + if (typeof value.name !== "string") { + return invalidField(`${path}.name`); + } + if (!isOneOf(ROLE_TYPES, value.roleType)) { + return invalidField(`${path}.roleType`); + } + + const harmonyError = validateRehearsalHarmony(value.harmony, `${path}.harmony`); + if (harmonyError) { + return harmonyError; + } + + const cueError = validateCueAnchor(value.cue, `${path}.cue`); + if (cueError) { + return cueError; + } + + const rangeError = validateRangeSummary(value.range, `${path}.range`); + if (rangeError) { + return rangeError; + } + + const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`); + if (confidenceError) { + return confidenceError; + } + + if (!isOneOf(REHEARSAL_PRIORITIES, value.rehearsalPriority)) { + return invalidField(`${path}.rehearsalPriority`); + } + if (typeof value.simplification !== "string") { + return invalidField(`${path}.simplification`); + } + if (typeof value.setupNote !== "string") { + return invalidField(`${path}.setupNote`); + } + if (!isDenseArray(value.manualOverrides)) { + return invalidField(`${path}.manualOverrides`); + } + for (const [index, override] of value.manualOverrides.entries()) { + const overrideError = validateManualOverride(override, `${path}.manualOverrides[${index}]`); + if (overrideError) { + return overrideError; + } + } + + return null; +} + +function validateRehearsalSection(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + if (typeof value.id !== "string") { + return invalidField(`${path}.id`); + } + if (typeof value.label !== "string") { + return invalidField(`${path}.label`); + } + if (typeof value.groove !== "string") { + return invalidField(`${path}.groove`); + } + + const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`); + if (confidenceError) { + return confidenceError; + } + + if (!isDenseArray(value.roles)) { + return invalidField(`${path}.roles`); + } + for (const [index, role] of value.roles.entries()) { + const roleError = validateRehearsalRole(role, `${path}.roles[${index}]`); + if (roleError) { + return roleError; + } + } + + return null; +} + +function validateExportSummary(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + if (!isOneOf(EXPORT_FORMATS, value.format)) { + return invalidField(`${path}.format`); + } + if (typeof value.headline !== "string") { + return invalidField(`${path}.headline`); + } + if (!isDenseArray(value.focusSections)) { + return invalidField(`${path}.focusSections`); + } + for (const [index, section] of value.focusSections.entries()) { + if (typeof section !== "string") { + return invalidField(`${path}.focusSections[${index}]`); + } + } + + return null; +} + +function validateRehearsalSong(value: unknown): string | null { + if (!isRecord(value)) { + return invalidField("root"); + } + if (typeof value.id !== "string") { + return invalidField("id"); + } + if (typeof value.title !== "string") { + return invalidField("title"); + } + if (!isDenseArray(value.sections)) { + return invalidField("sections"); + } + for (const [index, section] of value.sections.entries()) { + const sectionError = validateRehearsalSection(section, `sections[${index}]`); + if (sectionError) { + return sectionError; + } + } + + return validateExportSummary(value.exportSummary, "exportSummary"); +} + +export function isRehearsalSong(value: unknown): value is RehearsalSong { + return validateRehearsalSong(value) === null; +} + +export function parseRehearsalSong(value: unknown): RehearsalSong { + const validationError = validateRehearsalSong(value); + if (validationError) { + throw new Error(validationError); + } + + return structuredClone(value as RehearsalSong); +} diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 2bfc40db..f7a4b5cf 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -1,4 +1,11 @@ -import { createDefaultProjectSummary, SUPPORTED_AUDIO_FORMATS } from "../src/index"; +import { + createDefaultProjectSummary, + createDemoRehearsalSong, + isRehearsalSong, + parseRehearsalSong, + type RehearsalSong, + SUPPORTED_AUDIO_FORMATS +} from "../src/index"; describe("shared type helpers", () => { it("creates a project summary for a fresh analysis job", () => { @@ -14,4 +21,375 @@ describe("shared type helpers", () => { supportedAudioFormats: SUPPORTED_AUDIO_FORMATS }); }); + + it("creates a rehearsal song with section and role level guidance", () => { + const song = createDemoRehearsalSong(); + + expect(song).toMatchObject({ + id: "demo-song", + title: "Late Night Set", + sections: [ + { + id: "verse-1", + label: "Verse 1", + confidence: { + level: "medium", + source: "model" + }, + roles: [ + { + id: "bass-guitar", + name: "Bass Guitar", + roleType: "instrument" + }, + { + id: "keys-right", + name: "Keyboard 1 Right Hand", + roleType: "hand", + harmony: { + chord: "Emaj7", + source: "model" + } + }, + { + id: "lead-vocal", + name: "Lead Vocal", + roleType: "vocal", + cue: { + kind: "lyric", + value: "city lights" + } + } + ] + } + ], + exportSummary: { + format: "cue-sheet" + } + }); + + expect(song.sections[0]?.roles[2]?.harmony?.source).toBe("model"); + expect(song.sections[0]?.roles[2]?.manualOverrides?.[0]).toMatchObject({ + field: "harmony", + source: "user", + value: { + chord: "C#m11" + } + }); + }); + + it("returns a fresh copy of the rehearsal song fixture", () => { + const first = createDemoRehearsalSong(); + const second = createDemoRehearsalSong(); + + first.sections[0]?.roles[2]?.manualOverrides?.splice(0, 1); + + expect(second).not.toBe(first); + expect(second.sections).not.toBe(first.sections); + expect(second.sections[0]?.roles).not.toBe(first.sections[0]?.roles); + expect(second.sections[0]?.roles[2]?.manualOverrides).toHaveLength(1); + }); + + it("validates and parses rehearsal song payloads", () => { + const song = createDemoRehearsalSong(); + const malformedSong = createDemoRehearsalSong() as unknown as { + sections: Array<{ roles: unknown[] }>; + }; + const sparseSong = createDemoRehearsalSong() as unknown as { + exportSummary: { focusSections: string[] }; + sections: Array<{ roles: unknown[] }>; + }; + const sparseSongWithProperty = createDemoRehearsalSong() as unknown as { + exportSummary: { focusSections: string[] & { label?: string } }; + }; + const arrayPayload = Object.assign([], { + id: "array-song", + title: "Array Song", + sections: [], + exportSummary: { + format: "cue-sheet", + headline: "Array payload", + focusSections: [] + } + }); + malformedSong.sections[0]!.roles = [{ id: "broken-role" }]; + sparseSong.exportSummary.focusSections = new Array(1); + sparseSongWithProperty.exportSummary.focusSections = new Array(1) as string[] & { + label?: string; + }; + sparseSongWithProperty.exportSummary.focusSections.label = "ghost"; + + expect(isRehearsalSong(song)).toBe(true); + expect(isRehearsalSong({ id: "bad" })).toBe(false); + expect(isRehearsalSong({ + id: "bad", + title: "Bad", + sections: [], + exportSummary: { + format: 42, + headline: "oops" + } + })).toBe(false); + expect(isRehearsalSong(malformedSong)).toBe(false); + expect(isRehearsalSong(sparseSong)).toBe(false); + expect(isRehearsalSong(sparseSongWithProperty)).toBe(false); + expect(isRehearsalSong(arrayPayload)).toBe(false); + + const parsed = parseRehearsalSong(song); + parsed.sections[0]?.roles.splice(0, 1); + + expect(parsed.sections[0]?.roles).toHaveLength(2); + expect(song.sections[0]?.roles).toHaveLength(3); + expect(() => parseRehearsalSong(null)).toThrow("Invalid rehearsal song contract"); + expect(() => parseRehearsalSong({ + id: "bad", + title: "Bad", + sections: [], + exportSummary: { + format: 42, + headline: "oops" + } + })).toThrow("exportSummary.format"); + }); + + it("reports the first invalid field path for nested contract failures", () => { + const roleSparse = createDemoRehearsalSong() as unknown as { + sections: Array<{ roles: unknown[] }>; + }; + const badOverride = createDemoRehearsalSong() as unknown as { + sections: Array<{ roles: Array<{ manualOverrides: Array<{ value: { source: string } }> }> }>; + }; + const badHeadline = createDemoRehearsalSong() as unknown as { + exportSummary: { headline: unknown }; + }; + const badFocusSection = createDemoRehearsalSong() as unknown as { + exportSummary: { focusSections: unknown[] }; + }; + const badExportSummary = createDemoRehearsalSong() as unknown as { + exportSummary: unknown; + }; + const missingId = { ...createDemoRehearsalSong(), id: 42 }; + const sparseSections = createDemoRehearsalSong() as unknown as { sections: RehearsalSong["sections"] }; + + roleSparse.sections[0]!.roles = new Array(1); + badOverride.sections[0]!.roles[2]!.manualOverrides[0]!.value.source = "model"; + badHeadline.exportSummary.headline = 99; + badFocusSection.exportSummary.focusSections = ["Verse 1", 7]; + badExportSummary.exportSummary = []; + sparseSections.sections = new Array(1) as RehearsalSong["sections"]; + + expect(() => parseRehearsalSong(roleSparse)).toThrow("sections[0].roles"); + expect(() => parseRehearsalSong(badOverride)).toThrow("manualOverrides[0].value.source"); + expect(() => parseRehearsalSong(badHeadline)).toThrow("exportSummary.headline"); + expect(() => parseRehearsalSong(badFocusSection)).toThrow("exportSummary.focusSections[1]"); + expect(() => parseRehearsalSong(badExportSummary)).toThrow("exportSummary"); + expect(() => parseRehearsalSong(missingId)).toThrow("id"); + expect(() => parseRehearsalSong(sparseSections)).toThrow("sections"); + }); + + it("covers detailed validation branches", () => { + const createInvalidSong = (mutate: (song: RehearsalSong) => unknown) => { + const song = createDemoRehearsalSong(); + mutate(song); + return song; + }; + + const cases: Array<{ message: string; payload: unknown }> = [ + { message: "title", payload: { id: "song" } }, + { + message: "sections[0]", + payload: { ...createDemoRehearsalSong(), sections: [null] } + }, + { + message: "sections[0].id", + payload: createInvalidSong((song) => { + (song.sections[0] as RehearsalSong["sections"][number]).id = 4 as never; + }) + }, + { + message: "sections[0].label", + payload: createInvalidSong((song) => { + (song.sections[0] as RehearsalSong["sections"][number]).label = 4 as never; + }) + }, + { + message: "sections[0].groove", + payload: createInvalidSong((song) => { + (song.sections[0] as RehearsalSong["sections"][number]).groove = 4 as never; + }) + }, + { + message: "sections[0].confidence.level", + payload: createInvalidSong((song) => { + song.sections[0]!.confidence.level = "certain" as never; + }) + }, + { + message: "sections[0].confidence.source", + payload: createInvalidSong((song) => { + song.sections[0]!.confidence.source = "other" as never; + }) + }, + { + message: "sections[0].confidence.notes", + payload: createInvalidSong((song) => { + song.sections[0]!.confidence.notes = 1 as never; + }) + }, + { + message: "sections[0].confidence", + payload: createInvalidSong((song) => { + song.sections[0]!.confidence = null as never; + }) + }, + { + message: "sections[0].roles[0].id", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.id = 7 as never; + }) + }, + { + message: "sections[0].roles[0]", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0] = null as never; + }) + }, + { + message: "sections[0].roles[0].name", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.name = 7 as never; + }) + }, + { + message: "sections[0].roles[0].roleType", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.roleType = "drums" as never; + }) + }, + { + message: "sections[0].roles[0].harmony", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.harmony = null as never; + }) + }, + { + message: "sections[0].roles[0].harmony.chord", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.harmony.chord = 3 as never; + }) + }, + { + message: "sections[0].roles[0].harmony.functionLabel", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.harmony.functionLabel = 3 as never; + }) + }, + { + message: "sections[0].roles[0].harmony.source", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.harmony.source = "other" as never; + }) + }, + { + message: "sections[0].roles[0].cue", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.cue = null as never; + }) + }, + { + message: "sections[0].roles[0].cue.kind", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.cue.kind = "bar" as never; + }) + }, + { + message: "sections[0].roles[0].cue.value", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.cue.value = 2 as never; + }) + }, + { + message: "sections[0].roles[0].range", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.range = null as never; + }) + }, + { + message: "sections[0].roles[0].range.lowestNote", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.range.lowestNote = 2 as never; + }) + }, + { + message: "sections[0].roles[0].range.highestNote", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.range.highestNote = 2 as never; + }) + }, + { + message: "sections[0].roles[0].confidence", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.confidence = null as never; + }) + }, + { + message: "sections[0].roles[0].rehearsalPriority", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.rehearsalPriority = "urgent" as never; + }) + }, + { + message: "sections[0].roles[0].simplification", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.simplification = 2 as never; + }) + }, + { + message: "sections[0].roles[0].setupNote", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.setupNote = 2 as never; + }) + }, + { + message: "sections[0].roles[2].manualOverrides[0]", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[2]!.manualOverrides[0] = null as never; + }) + }, + { + message: "sections[0].roles[2].manualOverrides[0].field", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[2]!.manualOverrides[0]!.field = "cue" as never; + }) + }, + { + message: "sections[0].roles[2].manualOverrides[0].source", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[2]!.manualOverrides[0]!.source = "model" as never; + }) + }, + { + message: "sections[0].roles[2].manualOverrides[0].value.chord", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[2]!.manualOverrides[0]!.value.chord = 5 as never; + }) + }, + { + message: "sections[0].roles[0].manualOverrides", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.manualOverrides = new Array(1) as never; + }) + }, + { + message: "exportSummary.focusSections", + payload: createInvalidSong((song) => { + song.exportSummary.focusSections = new Array(1) as never; + }) + } + ]; + + for (const testCase of cases) { + expect(() => parseRehearsalSong(testCase.payload)).toThrow(testCase.message); + } + }); });