From 9be96cd9570e424c76f363075edb61dd3efa4ad6 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 1 May 2026 14:29:27 -0700 Subject: [PATCH] feat: add `R` as shortcut of recording button; fixed linter errors. --- public/config.json | 1 + src/components/KGOnePanel.tsx | 10 +- src/core/KGDebugger.ts | 174 +++--- .../audio-interface/KGAudioInterface.test.ts | 122 ++-- src/core/audio-interface/KGMetronome.test.ts | 72 +-- .../commands/note/CreateNoteCommand.test.ts | 166 ++--- src/core/midi/KGMidiNote.test.ts | 178 +++--- src/core/region/KGMidiRegion.test.ts | 420 ++++++------- src/core/track/KGMidiTrack.test.ts | 400 ++++++------ src/hooks/useGlobalKeyboardHandler.ts | 44 +- src/stores/projectStore.ts | 2 +- .../command-execution.integration.test.ts | 362 +++++------ .../project-store-sync.integration.test.ts | 512 +++++++-------- .../utils/abc-notation.integration.test.ts | 246 ++++---- src/test/mocks/audio-interface.ts | 6 +- src/test/mocks/indexed-db.ts | 34 +- src/test/mocks/tone-js.ts | 8 +- src/test/mocks/tone.ts | 26 +- src/test/setup.ts | 36 +- src/test/utils/mock-data.ts | 54 +- src/test/utils/setup-integration-tests.ts | 62 +- src/test/utils/test-utils.tsx | 12 +- src/util/midiUtil.test.ts | 226 +++---- src/util/scaleUtil.test.ts | 584 +++++++++--------- src/util/timeUtil.test.ts | 324 +++++----- src/util/xmlUtil.test.ts | 408 ++++++------ src/vite-env.d.ts | 2 +- 27 files changed, 2266 insertions(+), 2225 deletions(-) diff --git a/public/config.json b/public/config.json index 9b0619f..71c018f 100644 --- a/public/config.json +++ b/public/config.json @@ -39,6 +39,7 @@ "hold_to_create_region": "ctrl", "play": "space", "loop": "c", + "record": "r", "undo": "ctrl+z", "redo": "ctrl+shift+z", "select_all": "ctrl+a", diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx index 78b6102..ffecc44 100644 --- a/src/components/KGOnePanel.tsx +++ b/src/components/KGOnePanel.tsx @@ -300,7 +300,7 @@ const ClipTab: React.FC = ({ bpm, keySignature }) => { setGenStatus('polling'); setGenHint('Generating clip...'); - // eslint-disable-next-line no-constant-condition + while (true) { if (signal.aborted) return; @@ -626,7 +626,7 @@ const FullSongTab: React.FC = () => { type ResultItem = { progress: number; stage: string; status: number }; type PollResponse = { data: Array<{ status: number; result: string }>; code: number }; - // eslint-disable-next-line no-constant-condition + while (true) { if (signal.aborted) return; @@ -997,7 +997,7 @@ const SeparatorTab: React.FC = () => { let files: string[] = []; - // eslint-disable-next-line no-constant-condition + while (true) { if (signal.aborted) return; @@ -1414,7 +1414,7 @@ const RemixTab: React.FC = () => { type ResultItem = { progress: number; stage: string; status: number }; type PollResponse = { data: Array<{ status: number; result: string }>; code: number }; - // eslint-disable-next-line no-constant-condition + while (true) { if (signal.aborted) return; @@ -1932,7 +1932,7 @@ const RepaintTab: React.FC = () => { type ResultItem = { progress: number; stage: string; status: number }; type PollResponse = { data: Array<{ status: number; result: string }>; code: number }; - // eslint-disable-next-line no-constant-condition + while (true) { if (signal.aborted) return; diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts index 55cf488..a80ee13 100644 --- a/src/core/KGDebugger.ts +++ b/src/core/KGDebugger.ts @@ -673,212 +673,212 @@ export class KGDebugger { * await KGDebugger.opfs('cat project.json') */ public async opfs(command: string): Promise { - const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] - const cmd = parts[0] + const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []; + const cmd = parts[0]; // Strip surrounding quotes from arguments - const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ') + const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' '); try { switch (cmd) { case 'pwd': - console.log('/' + this.opfsCwd.join('/')) - break + console.log('/' + this.opfsCwd.join('/')); + break; case 'ls': - await this.opfsLs() - break + await this.opfsLs(); + break; case 'cd': - await this.opfsCd(arg) - break + await this.opfsCd(arg); + break; case 'cat': - await this.opfsCat(arg) - break + await this.opfsCat(arg); + break; case 'dl': - await this.opfsDl(arg) - break + await this.opfsDl(arg); + break; case 'rm': - await this.opfsRm(arg) - break + await this.opfsRm(arg); + break; default: - console.log(`opfs: command not found: ${cmd}`) - console.log('Available commands: pwd, ls, cd , cat , dl , rm ') + console.log(`opfs: command not found: ${cmd}`); + console.log('Available commands: pwd, ls, cd , cat , dl , rm '); } } catch (error) { - console.error(`opfs: ${error}`) + console.error(`opfs: ${error}`); } } private async opfsResolveCwd(): Promise { - let dir = await navigator.storage.getDirectory() + let dir = await navigator.storage.getDirectory(); for (const segment of this.opfsCwd) { - dir = await dir.getDirectoryHandle(segment) + dir = await dir.getDirectoryHandle(segment); } - return dir + return dir; } private async opfsLs(): Promise { - const dir = await this.opfsResolveCwd() - const entries: Array<{ kind: string; name: string; size: number; modified: string }> = [] + const dir = await this.opfsResolveCwd(); + const entries: Array<{ kind: string; name: string; size: number; modified: string }> = []; for await (const entry of dir.values()) { if (entry.kind === 'file') { - const fileHandle = entry as FileSystemFileHandle - const file = await fileHandle.getFile() + const fileHandle = entry as FileSystemFileHandle; + const file = await fileHandle.getFile(); entries.push({ kind: 'file', name: entry.name, size: file.size, modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19), - }) + }); } else { entries.push({ kind: 'dir', name: entry.name + '/', size: 0, modified: '-', - }) + }); } } // Sort: directories first, then files, alphabetically within each group entries.sort((a, b) => { - if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 - return a.name.localeCompare(b.name) - }) + if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1; + return a.name.localeCompare(b.name); + }); if (entries.length === 0) { - console.log('(empty directory)') - return + console.log('(empty directory)'); + return; } // Print header - const cwdPath = '/' + this.opfsCwd.join('/') - console.log(`total ${entries.length} (${cwdPath})`) + const cwdPath = '/' + this.opfsCwd.join('/'); + console.log(`total ${entries.length} (${cwdPath})`); // Format like ls -lla - const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4) + const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4); for (const e of entries) { - const typeChar = e.kind === 'dir' ? 'd' : '-' - const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--' - const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen) - console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`) + const typeChar = e.kind === 'dir' ? 'd' : '-'; + const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'; + const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen); + console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`); } } private async opfsCd(path: string): Promise { if (!path || path === '') { // cd with no args goes to root - this.opfsCwd = [] - return + this.opfsCwd = []; + return; } - let segments: string[] + let segments: string[]; if (path === '/') { - this.opfsCwd = [] - return + this.opfsCwd = []; + return; } else if (path.startsWith('/')) { // Absolute path - segments = path.split('/').filter(Boolean) + segments = path.split('/').filter(Boolean); } else { // Relative path - segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)] + segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]; } // Resolve . and .. - const resolved: string[] = [] + const resolved: string[] = []; for (const seg of segments) { - if (seg === '.') continue + if (seg === '.') continue; if (seg === '..') { - resolved.pop() + resolved.pop(); } else { - resolved.push(seg) + resolved.push(seg); } } // Verify the path exists - let dir = await navigator.storage.getDirectory() + let dir = await navigator.storage.getDirectory(); for (const seg of resolved) { try { - dir = await dir.getDirectoryHandle(seg) + dir = await dir.getDirectoryHandle(seg); } catch { - console.error(`opfs: cd: no such directory: ${path}`) - return + console.error(`opfs: cd: no such directory: ${path}`); + return; } } - this.opfsCwd = resolved + this.opfsCwd = resolved; } private async opfsCat(fileName: string): Promise { if (!fileName) { - console.error('opfs: cat: missing file name') - return + console.error('opfs: cat: missing file name'); + return; } - const dir = await this.opfsResolveCwd() + const dir = await this.opfsResolveCwd(); try { - const fileHandle = await dir.getFileHandle(fileName) - const file = await fileHandle.getFile() - const text = await file.text() + const fileHandle = await dir.getFileHandle(fileName); + const file = await fileHandle.getFile(); + const text = await file.text(); // Pretty-print JSON files if (fileName.endsWith('.json')) { try { - const parsed = JSON.parse(text) - console.log(JSON.stringify(parsed, null, 2)) + const parsed = JSON.parse(text); + console.log(JSON.stringify(parsed, null, 2)); } catch { - console.log(text) + console.log(text); } } else { - console.log(text) + console.log(text); } } catch { - console.error(`opfs: cat: ${fileName}: No such file`) + console.error(`opfs: cat: ${fileName}: No such file`); } } private async opfsDl(fileName: string): Promise { if (!fileName) { - console.error('opfs: dl: missing file name') - return + console.error('opfs: dl: missing file name'); + return; } - const dir = await this.opfsResolveCwd() + const dir = await this.opfsResolveCwd(); try { - const fileHandle = await dir.getFileHandle(fileName) - const file = await fileHandle.getFile() - const url = URL.createObjectURL(file) - const a = document.createElement('a') - a.href = url - a.download = fileName - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - console.log(`downloaded: ${fileName} (${file.size} bytes)`) + const fileHandle = await dir.getFileHandle(fileName); + const file = await fileHandle.getFile(); + const url = URL.createObjectURL(file); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + console.log(`downloaded: ${fileName} (${file.size} bytes)`); } catch { - console.error(`opfs: dl: ${fileName}: No such file`) + console.error(`opfs: dl: ${fileName}: No such file`); } } private async opfsRm(name: string): Promise { if (!name) { - console.error('opfs: rm: missing file or directory name') - return + console.error('opfs: rm: missing file or directory name'); + return; } - const dir = await this.opfsResolveCwd() + const dir = await this.opfsResolveCwd(); try { - await dir.removeEntry(name, { recursive: true }) - console.log(`removed: ${name}`) + await dir.removeEntry(name, { recursive: true }); + console.log(`removed: ${name}`); } catch { - console.error(`opfs: rm: ${name}: No such file or directory`) + console.error(`opfs: rm: ${name}: No such file or directory`); } } } \ No newline at end of file diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts index 068f923..d087fcb 100644 --- a/src/core/audio-interface/KGAudioInterface.test.ts +++ b/src/core/audio-interface/KGAudioInterface.test.ts @@ -1,115 +1,115 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockProject } from '../../test/utils/mock-data' -import { MockTransport } from '../../test/mocks/tone' +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockProject } from '../../test/utils/mock-data'; +import { MockTransport } from '../../test/mocks/tone'; vi.mock('tone', async () => { - const { ToneMock } = await import('../../test/mocks/tone') - return ToneMock -}) + const { ToneMock } = await import('../../test/mocks/tone'); + return ToneMock; +}); vi.mock('../KGCore', () => ({ KGCore: { instance: vi.fn() } -})) +})); vi.mock('../config/ConfigManager', () => ({ ConfigManager: { instance: vi.fn() } -})) +})); -import { KGCore } from '../KGCore' -import { ConfigManager } from '../config/ConfigManager' -import { KGAudioInterface } from './KGAudioInterface' +import { KGCore } from '../KGCore'; +import { ConfigManager } from '../config/ConfigManager'; +import { KGAudioInterface } from './KGAudioInterface'; describe('KGAudioInterface preroll playback', () => { beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(0) - vi.clearAllMocks() + vi.useFakeTimers(); + vi.setSystemTime(0); + vi.clearAllMocks(); - MockTransport.position = 0 - MockTransport.start.mockClear() - MockTransport.stop.mockClear() - MockTransport.clear.mockClear() - MockTransport.schedule.mockClear() - MockTransport.bpm.value = 120 + MockTransport.position = 0; + MockTransport.start.mockClear(); + MockTransport.stop.mockClear(); + MockTransport.clear.mockClear(); + MockTransport.schedule.mockClear(); + MockTransport.bpm.value = 120; const project = createMockProject({ bpm: 120, timeSignature: { numerator: 4, denominator: 4 }, tracks: [], - }) + }); vi.mocked(KGCore.instance).mockReturnValue({ getCurrentProject: () => project, - } as unknown as KGCore) + } as unknown as KGCore); vi.mocked(ConfigManager.instance).mockReturnValue({ get: (key: string) => { - if (key === 'audio.playback_delay') return 0.2 - if (key === 'audio.lookahead_time') return 0.05 - return null + if (key === 'audio.playback_delay') return 0.2; + if (key === 'audio.lookahead_time') return 0.05; + return null; }, } as unknown as ConfigManager) - ;(KGAudioInterface as unknown as { _instance: KGAudioInterface | null })._instance = null - }) + ;(KGAudioInterface as unknown as { _instance: KGAudioInterface | null })._instance = null; + }); it('uses virtual negative beats until the delayed transport start reaches beat 0', () => { - const project = KGCore.instance().getCurrentProject() + const project = KGCore.instance().getCurrentProject(); const audio = KGAudioInterface.instance() ;(audio as unknown as { isInitialized: boolean }).isInitialized = true - ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true + ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true; - const metronomeStart = vi.spyOn((audio as unknown as { metronome: { start: (...args: unknown[]) => void } }).metronome, 'start') - audio.setMetronomeEnabled(true) + const metronomeStart = vi.spyOn((audio as unknown as { metronome: { start: (...args: unknown[]) => void } }).metronome, 'start'); + audio.setMetronomeEnabled(true); - audio.preparePlayback(project, -2) - audio.startPlayback() + audio.preparePlayback(project, -2); + audio.startPlayback(); - expect(MockTransport.position).toBe(0) - expect(MockTransport.start).not.toHaveBeenCalled() - expect(metronomeStart).toHaveBeenCalledWith(-2, 4, 0.2) - expect(audio.getTransportPosition()).toBeCloseTo(-2, 2) + expect(MockTransport.position).toBe(0); + expect(MockTransport.start).not.toHaveBeenCalled(); + expect(metronomeStart).toHaveBeenCalledWith(-2, 4, 0.2); + expect(audio.getTransportPosition()).toBeCloseTo(-2, 2); - vi.advanceTimersByTime(500) - expect(audio.getTransportPosition()).toBeCloseTo(-1, 1) + vi.advanceTimersByTime(500); + expect(audio.getTransportPosition()).toBeCloseTo(-1, 1); - vi.advanceTimersByTime(500) - expect(MockTransport.start).toHaveBeenCalledTimes(1) - expect(audio.getTransportPosition()).toBe(0) - }) + vi.advanceTimersByTime(500); + expect(MockTransport.start).toHaveBeenCalledTimes(1); + expect(audio.getTransportPosition()).toBe(0); + }); it('cancels the delayed transport start when playback stops during preroll', () => { - const project = KGCore.instance().getCurrentProject() + const project = KGCore.instance().getCurrentProject(); const audio = KGAudioInterface.instance() ;(audio as unknown as { isInitialized: boolean }).isInitialized = true - ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true + ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true; - audio.preparePlayback(project, -2) - audio.startPlayback() - audio.stopPlayback() - vi.runAllTimers() + audio.preparePlayback(project, -2); + audio.startPlayback(); + audio.stopPlayback(); + vi.runAllTimers(); - expect(MockTransport.start).not.toHaveBeenCalled() - expect(MockTransport.stop).toHaveBeenCalledTimes(1) - expect(audio.getTransportPosition()).toBe(0) - }) + expect(MockTransport.start).not.toHaveBeenCalled(); + expect(MockTransport.stop).toHaveBeenCalledTimes(1); + expect(audio.getTransportPosition()).toBe(0); + }); it('allows a first-pass start before the loop start when explicitly requested', () => { const project = createMockProject({ bpm: 120, timeSignature: { numerator: 4, denominator: 4 }, tracks: [], - }) - project.setIsLooping(true) - project.setLoopingRange([4, 7]) + }); + project.setIsLooping(true); + project.setLoopingRange([4, 7]); - const audio = KGAudioInterface.instance() - audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true }) + const audio = KGAudioInterface.instance(); + audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true }); - expect(MockTransport.position).toBe(6) - }) -}) + expect(MockTransport.position).toBe(6); + }); +}); diff --git a/src/core/audio-interface/KGMetronome.test.ts b/src/core/audio-interface/KGMetronome.test.ts index ebf1f43..a92ceff 100644 --- a/src/core/audio-interface/KGMetronome.test.ts +++ b/src/core/audio-interface/KGMetronome.test.ts @@ -1,61 +1,61 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MockLoop, MockTransport } from '../../test/mocks/tone' +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MockLoop, MockTransport } from '../../test/mocks/tone'; vi.mock('tone', async () => { - const { ToneMock: toneMock } = await import('../../test/mocks/tone') - return toneMock -}) + const { ToneMock: toneMock } = await import('../../test/mocks/tone'); + return toneMock; +}); -import { KGMetronome } from './KGMetronome' +import { KGMetronome } from './KGMetronome'; describe('KGMetronome', () => { beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(0) - vi.clearAllMocks() - MockTransport.bpm.value = 120 - MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ) - }) + vi.useFakeTimers(); + vi.setSystemTime(0); + vi.clearAllMocks(); + MockTransport.bpm.value = 120; + MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ); + }); it('schedules audible preroll clicks before beat 0 and keeps the beat 0 accent', () => { - const metronome = new KGMetronome() + const metronome = new KGMetronome(); const triggerAttackRelease = vi.fn() ;(metronome as unknown as { sampler: unknown }).sampler = { loaded: true, triggerAttackRelease, - } + }; - metronome.start(-4, 4, 0.2) + metronome.start(-4, 4, 0.2); - vi.advanceTimersByTime(200) - expect(triggerAttackRelease.mock.calls[0]?.[0]).toBe('C5') - expect(triggerAttackRelease.mock.calls[0]?.[1]).toBe('16n') - expect(triggerAttackRelease.mock.calls[0]?.[2]).toBeCloseTo(0.2, 5) + vi.advanceTimersByTime(200); + expect(triggerAttackRelease.mock.calls[0]?.[0]).toBe('C5'); + expect(triggerAttackRelease.mock.calls[0]?.[1]).toBe('16n'); + expect(triggerAttackRelease.mock.calls[0]?.[2]).toBeCloseTo(0.2, 5); - vi.advanceTimersByTime(1000) - expect(triggerAttackRelease.mock.calls[1]?.[0]).toBe('C4') - expect(triggerAttackRelease.mock.calls[1]?.[1]).toBe('16n') - expect(triggerAttackRelease.mock.calls[1]?.[2]).toBeCloseTo(0.7, 5) + vi.advanceTimersByTime(1000); + expect(triggerAttackRelease.mock.calls[1]?.[0]).toBe('C4'); + expect(triggerAttackRelease.mock.calls[1]?.[1]).toBe('16n'); + expect(triggerAttackRelease.mock.calls[1]?.[2]).toBeCloseTo(0.7, 5); - const transportLoop = MockLoop.mock.results[0]?.value - expect(transportLoop).toBeDefined() + const transportLoop = MockLoop.mock.results[0]?.value; + expect(transportLoop).toBeDefined(); - transportLoop.callback(0) - expect(triggerAttackRelease).toHaveBeenLastCalledWith('C5', '16n', 0.2) - }) + transportLoop.callback(0); + expect(triggerAttackRelease).toHaveBeenLastCalledWith('C5', '16n', 0.2); + }); it('cancels pending preroll clicks when stopped', () => { - const metronome = new KGMetronome() + const metronome = new KGMetronome(); const triggerAttackRelease = vi.fn() ;(metronome as unknown as { sampler: unknown }).sampler = { loaded: true, triggerAttackRelease, - } + }; - metronome.start(-2, 4, 0.2) - metronome.stop() - vi.runAllTimers() + metronome.start(-2, 4, 0.2); + metronome.stop(); + vi.runAllTimers(); - expect(triggerAttackRelease).not.toHaveBeenCalled() - }) -}) + expect(triggerAttackRelease).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/commands/note/CreateNoteCommand.test.ts b/src/core/commands/note/CreateNoteCommand.test.ts index 6ce2f0c..e7ab386 100644 --- a/src/core/commands/note/CreateNoteCommand.test.ts +++ b/src/core/commands/note/CreateNoteCommand.test.ts @@ -1,34 +1,34 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { CreateNoteCommand } from './CreateNoteCommand' -import { KGCore } from '../../KGCore' -import { KGMidiNote } from '../../midi/KGMidiNote' -import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data' +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CreateNoteCommand } from './CreateNoteCommand'; +import { KGCore } from '../../KGCore'; +import { KGMidiNote } from '../../midi/KGMidiNote'; +import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data'; // Mock the KGCore singleton vi.mock('../../KGCore', () => ({ KGCore: { instance: vi.fn() } -})) +})); // Mock generateUniqueId utility vi.mock('../../../util/miscUtil', () => ({ generateUniqueId: vi.fn().mockReturnValue('mock-note-id') -})) +})); // Import the mocked function properly -const { generateUniqueId } = await import('../../../util/miscUtil') +const { generateUniqueId } = await import('../../../util/miscUtil'); interface MockCore { getCurrentProject: ReturnType } describe('CreateNoteCommand', () => { - let mockCore: MockCore - let mockProject: ReturnType - let mockTrack: ReturnType - let mockRegion: ReturnType - let command: CreateNoteCommand + let mockCore: MockCore; + let mockProject: ReturnType; + let mockTrack: ReturnType; + let mockRegion: ReturnType; + let command: CreateNoteCommand; beforeEach(() => { // Create test data @@ -36,25 +36,25 @@ describe('CreateNoteCommand', () => { id: 'test-region', trackId: 'test-track', name: 'Test Region' - }) + }); mockTrack = createMockMidiTrack({ id: 1, name: 'Test Track', regions: [mockRegion] - }) + }); mockProject = createMockProject({ name: 'Test Project', tracks: [mockTrack] - }) + }); // Mock KGCore methods mockCore = { getCurrentProject: vi.fn().mockReturnValue(mockProject) - } + }; - vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore) + vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore); // Create command command = new CreateNoteCommand( @@ -63,135 +63,135 @@ describe('CreateNoteCommand', () => { 1, // endBeat 60, // pitch (middle C) 80 // velocity - ) - }) + ); + }); describe('constructor', () => { it('should create command with correct parameters', () => { - const cmd = new CreateNoteCommand('region-1', 2, 4, 72, 100, 'custom-id') + const cmd = new CreateNoteCommand('region-1', 2, 4, 72, 100, 'custom-id'); - expect(cmd).toBeInstanceOf(CreateNoteCommand) + expect(cmd).toBeInstanceOf(CreateNoteCommand); // We can't directly test private properties, but we can test execution - }) + }); it('should generate unique ID when not provided', () => { - new CreateNoteCommand('region-1', 0, 1, 60, 80) + new CreateNoteCommand('region-1', 0, 1, 60, 80); // The generateUniqueId mock should have been called - expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote') - }) + expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote'); + }); it('should use provided ID when given', () => { // Clear previous calls - vi.clearAllMocks() + vi.clearAllMocks(); - new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id') + new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id'); // Should not call generateUniqueId when ID is provided - expect(generateUniqueId).not.toHaveBeenCalled() - }) - }) + expect(generateUniqueId).not.toHaveBeenCalled(); + }); + }); describe('execute', () => { it('should create a note in the target region', () => { // Mock the addNote method - const addNoteSpy = vi.spyOn(mockRegion, 'addNote') + const addNoteSpy = vi.spyOn(mockRegion, 'addNote'); // Execute the command - command.execute() + command.execute(); // Verify note was added - expect(addNoteSpy).toHaveBeenCalledTimes(1) + expect(addNoteSpy).toHaveBeenCalledTimes(1); // Verify the note has correct properties - const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote - expect(addedNote).toBeInstanceOf(KGMidiNote) - expect(addedNote.getStartBeat()).toBe(0) - expect(addedNote.getEndBeat()).toBe(1) - expect(addedNote.getPitch()).toBe(60) - expect(addedNote.getVelocity()).toBe(80) - }) + const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote; + expect(addedNote).toBeInstanceOf(KGMidiNote); + expect(addedNote.getStartBeat()).toBe(0); + expect(addedNote.getEndBeat()).toBe(1); + expect(addedNote.getPitch()).toBe(60); + expect(addedNote.getVelocity()).toBe(80); + }); it('should throw error for non-existent region', () => { // Create command for non-existent region - const badCommand = new CreateNoteCommand('non-existent-region', 0, 1, 60, 80) + const badCommand = new CreateNoteCommand('non-existent-region', 0, 1, 60, 80); // Should throw error for non-existent region - expect(() => badCommand.execute()).toThrow('MIDI region with ID non-existent-region not found') - }) + expect(() => badCommand.execute()).toThrow('MIDI region with ID non-existent-region not found'); + }); it('should store created note for undo operation', () => { - const addNoteSpy = vi.spyOn(mockRegion, 'addNote') + const addNoteSpy = vi.spyOn(mockRegion, 'addNote'); - command.execute() + command.execute(); // Note should be created and stored internally for undo - expect(addNoteSpy).toHaveBeenCalledTimes(1) - }) - }) + expect(addNoteSpy).toHaveBeenCalledTimes(1); + }); + }); describe('undo', () => { it('should remove the created note', () => { // Execute first to create the note - command.execute() + command.execute(); // Mock removeNote method - const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote') + const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote'); // Undo the command - command.undo() + command.undo(); // Verify note was removed - expect(removeNoteSpy).toHaveBeenCalledTimes(1) - }) + expect(removeNoteSpy).toHaveBeenCalledTimes(1); + }); it('should throw error when undoing without execute', () => { // Try to undo without executing first - expect(() => command.undo()).toThrow('Cannot undo: no note was created') - }) - }) + expect(() => command.undo()).toThrow('Cannot undo: no note was created'); + }); + }); describe('re-execute (redo pattern)', () => { it('should re-add the note after undo using execute', () => { // Execute, undo, then execute again (redo pattern) - command.execute() - command.undo() + command.execute(); + command.undo(); - const addNoteSpy = vi.spyOn(mockRegion, 'addNote') - command.execute() // Commands are re-executed for redo + const addNoteSpy = vi.spyOn(mockRegion, 'addNote'); + command.execute(); // Commands are re-executed for redo // Note should be added again - expect(addNoteSpy).toHaveBeenCalledTimes(1) - }) - }) + expect(addNoteSpy).toHaveBeenCalledTimes(1); + }); + }); describe('getDescription', () => { it('should return descriptive text', () => { - const description = command.getDescription() + const description = command.getDescription(); - expect(description).toBeDefined() - expect(typeof description).toBe('string') - expect(description.length).toBeGreaterThan(0) - }) - }) + expect(description).toBeDefined(); + expect(typeof description).toBe('string'); + expect(description.length).toBeGreaterThan(0); + }); + }); describe('command lifecycle', () => { it('should support multiple execute/undo cycles', () => { - const addNoteSpy = vi.spyOn(mockRegion, 'addNote') - const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote') + const addNoteSpy = vi.spyOn(mockRegion, 'addNote'); + const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote'); // Execute -> Undo -> Execute -> Undo - command.execute() - expect(addNoteSpy).toHaveBeenCalledTimes(1) + command.execute(); + expect(addNoteSpy).toHaveBeenCalledTimes(1); - command.undo() - expect(removeNoteSpy).toHaveBeenCalledTimes(1) + command.undo(); + expect(removeNoteSpy).toHaveBeenCalledTimes(1); - command.execute() // Re-execute for redo - expect(addNoteSpy).toHaveBeenCalledTimes(2) + command.execute(); // Re-execute for redo + expect(addNoteSpy).toHaveBeenCalledTimes(2); - command.undo() - expect(removeNoteSpy).toHaveBeenCalledTimes(2) - }) - }) -}) \ No newline at end of file + command.undo(); + expect(removeNoteSpy).toHaveBeenCalledTimes(2); + }); + }); +}); \ No newline at end of file diff --git a/src/core/midi/KGMidiNote.test.ts b/src/core/midi/KGMidiNote.test.ts index 4b86fdf..ea3d72f 100644 --- a/src/core/midi/KGMidiNote.test.ts +++ b/src/core/midi/KGMidiNote.test.ts @@ -1,151 +1,151 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { KGMidiNote } from './KGMidiNote' +import { describe, it, expect, beforeEach } from 'vitest'; +import { KGMidiNote } from './KGMidiNote'; describe('KGMidiNote', () => { - let note: KGMidiNote + let note: KGMidiNote; beforeEach(() => { - note = new KGMidiNote('test-note', 0, 1, 60, 80) - }) + note = new KGMidiNote('test-note', 0, 1, 60, 80); + }); describe('constructor', () => { it('should create a note with correct properties', () => { - const testNote = new KGMidiNote('note-1', 2, 4, 72, 100) + const testNote = new KGMidiNote('note-1', 2, 4, 72, 100); - expect(testNote.getId()).toBe('note-1') - expect(testNote.getStartBeat()).toBe(2) - expect(testNote.getEndBeat()).toBe(4) - expect(testNote.getPitch()).toBe(72) - expect(testNote.getVelocity()).toBe(100) - }) + expect(testNote.getId()).toBe('note-1'); + expect(testNote.getStartBeat()).toBe(2); + expect(testNote.getEndBeat()).toBe(4); + expect(testNote.getPitch()).toBe(72); + expect(testNote.getVelocity()).toBe(100); + }); it('should use default values when not provided', () => { - const defaultNote = new KGMidiNote('default-note') + const defaultNote = new KGMidiNote('default-note'); - expect(defaultNote.getId()).toBe('default-note') - expect(defaultNote.getStartBeat()).toBe(0) - expect(defaultNote.getEndBeat()).toBe(0) - expect(defaultNote.getPitch()).toBe(0) - expect(defaultNote.getVelocity()).toBe(127) - }) - }) + expect(defaultNote.getId()).toBe('default-note'); + expect(defaultNote.getStartBeat()).toBe(0); + expect(defaultNote.getEndBeat()).toBe(0); + expect(defaultNote.getPitch()).toBe(0); + expect(defaultNote.getVelocity()).toBe(127); + }); + }); describe('getters and setters', () => { it('should get and set start beat', () => { - expect(note.getStartBeat()).toBe(0) + expect(note.getStartBeat()).toBe(0); - note.setStartBeat(1.5) - expect(note.getStartBeat()).toBe(1.5) - }) + note.setStartBeat(1.5); + expect(note.getStartBeat()).toBe(1.5); + }); it('should get and set end beat', () => { - expect(note.getEndBeat()).toBe(1) + expect(note.getEndBeat()).toBe(1); - note.setEndBeat(3.5) - expect(note.getEndBeat()).toBe(3.5) - }) + note.setEndBeat(3.5); + expect(note.getEndBeat()).toBe(3.5); + }); it('should get and set pitch', () => { - expect(note.getPitch()).toBe(60) + expect(note.getPitch()).toBe(60); - note.setPitch(72) - expect(note.getPitch()).toBe(72) - }) + note.setPitch(72); + expect(note.getPitch()).toBe(72); + }); it('should get and set velocity', () => { - expect(note.getVelocity()).toBe(80) + expect(note.getVelocity()).toBe(80); - note.setVelocity(100) - expect(note.getVelocity()).toBe(100) - }) + note.setVelocity(100); + expect(note.getVelocity()).toBe(100); + }); it('should get and set ID', () => { - expect(note.getId()).toBe('test-note') + expect(note.getId()).toBe('test-note'); - note.setId('new-id') - expect(note.getId()).toBe('new-id') - }) - }) + note.setId('new-id'); + expect(note.getId()).toBe('new-id'); + }); + }); describe('selection', () => { it('should start unselected', () => { - expect(note.isSelected()).toBe(false) - }) + expect(note.isSelected()).toBe(false); + }); it('should select and deselect', () => { - note.select() - expect(note.isSelected()).toBe(true) + note.select(); + expect(note.isSelected()).toBe(true); - note.deselect() - expect(note.isSelected()).toBe(false) - }) - }) + note.deselect(); + expect(note.isSelected()).toBe(false); + }); + }); describe('note duration', () => { it('should calculate duration correctly', () => { - const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80) - expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2) - }) + const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80); + expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2); + }); it('should handle zero duration', () => { - const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80) - expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0) - }) - }) + const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80); + expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0); + }); + }); describe('pitch validation', () => { it('should accept valid MIDI pitch range', () => { // MIDI pitch range is typically 0-127 - note.setPitch(0) - expect(note.getPitch()).toBe(0) + note.setPitch(0); + expect(note.getPitch()).toBe(0); - note.setPitch(127) - expect(note.getPitch()).toBe(127) + note.setPitch(127); + expect(note.getPitch()).toBe(127); - note.setPitch(60) // Middle C - expect(note.getPitch()).toBe(60) - }) - }) + note.setPitch(60); // Middle C + expect(note.getPitch()).toBe(60); + }); + }); describe('velocity validation', () => { it('should accept valid MIDI velocity range', () => { // MIDI velocity range is typically 0-127 - note.setVelocity(0) - expect(note.getVelocity()).toBe(0) + note.setVelocity(0); + expect(note.getVelocity()).toBe(0); - note.setVelocity(127) - expect(note.getVelocity()).toBe(127) + note.setVelocity(127); + expect(note.getVelocity()).toBe(127); - note.setVelocity(64) // Mid velocity - expect(note.getVelocity()).toBe(64) - }) - }) + note.setVelocity(64); // Mid velocity + expect(note.getVelocity()).toBe(64); + }); + }); describe('clone and comparison', () => { it('should create independent instances', () => { - const note1 = new KGMidiNote('note-1', 0, 1, 60, 80) - const note2 = new KGMidiNote('note-2', 0, 1, 60, 80) + const note1 = new KGMidiNote('note-1', 0, 1, 60, 80); + const note2 = new KGMidiNote('note-2', 0, 1, 60, 80); - expect(note1.getId()).not.toBe(note2.getId()) + expect(note1.getId()).not.toBe(note2.getId()); - note1.setPitch(72) - expect(note2.getPitch()).toBe(60) // Should remain unchanged - }) - }) + note1.setPitch(72); + expect(note2.getPitch()).toBe(60); // Should remain unchanged + }); + }); describe('edge cases', () => { it('should handle negative start beat', () => { - note.setStartBeat(-1) - expect(note.getStartBeat()).toBe(-1) - }) + note.setStartBeat(-1); + expect(note.getStartBeat()).toBe(-1); + }); it('should handle start beat after end beat', () => { - note.setStartBeat(5) - note.setEndBeat(2) + note.setStartBeat(5); + note.setEndBeat(2); - expect(note.getStartBeat()).toBe(5) - expect(note.getEndBeat()).toBe(2) + expect(note.getStartBeat()).toBe(5); + expect(note.getEndBeat()).toBe(2); // Note: The class might need validation logic to prevent this - }) - }) -}) \ No newline at end of file + }); + }); +}); \ No newline at end of file diff --git a/src/core/region/KGMidiRegion.test.ts b/src/core/region/KGMidiRegion.test.ts index d47c6b6..4131c4e 100644 --- a/src/core/region/KGMidiRegion.test.ts +++ b/src/core/region/KGMidiRegion.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { KGMidiRegion } from './KGMidiRegion' -import { KGRegion } from './KGRegion' -import { KGMidiNote } from '../midi/KGMidiNote' -import { createMockMidiNote } from '../../test/utils/mock-data' +import { describe, it, expect, beforeEach } from 'vitest'; +import { KGMidiRegion } from './KGMidiRegion'; +import { KGRegion } from './KGRegion'; +import { KGMidiNote } from '../midi/KGMidiNote'; +import { createMockMidiNote } from '../../test/utils/mock-data'; describe('KGMidiRegion', () => { - let region: KGMidiRegion + let region: KGMidiRegion; beforeEach(() => { region = new KGMidiRegion( @@ -15,8 +15,8 @@ describe('KGMidiRegion', () => { 'Test Region', 0, 4 - ) - }) + ); + }); describe('constructor', () => { it('should create a MIDI region with correct properties', () => { @@ -27,327 +27,327 @@ describe('KGMidiRegion', () => { 'My Region', 4, 8 - ) + ); - expect(testRegion.getId()).toBe('region-1') - expect(testRegion.getTrackId()).toBe('track-1') - expect(testRegion.getTrackIndex()).toBe(2) - expect(testRegion.getName()).toBe('My Region') - expect(testRegion.getStartFromBeat()).toBe(4) - expect(testRegion.getLength()).toBe(8) - expect(testRegion.getNotes()).toEqual([]) - }) + expect(testRegion.getId()).toBe('region-1'); + expect(testRegion.getTrackId()).toBe('track-1'); + expect(testRegion.getTrackIndex()).toBe(2); + expect(testRegion.getName()).toBe('My Region'); + expect(testRegion.getStartFromBeat()).toBe(4); + expect(testRegion.getLength()).toBe(8); + expect(testRegion.getNotes()).toEqual([]); + }); it('should use default values for optional parameters', () => { - const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region') + const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region'); - expect(defaultRegion.getStartFromBeat()).toBe(0) - expect(defaultRegion.getLength()).toBe(0) - expect(defaultRegion.getNotes()).toEqual([]) - }) + expect(defaultRegion.getStartFromBeat()).toBe(0); + expect(defaultRegion.getLength()).toBe(0); + expect(defaultRegion.getNotes()).toEqual([]); + }); it('should set the correct type identifier', () => { - expect(region.getCurrentType()).toBe('KGMidiRegion') - }) - }) + expect(region.getCurrentType()).toBe('KGMidiRegion'); + }); + }); describe('note management', () => { - let note1: KGMidiNote - let note2: KGMidiNote - let note3: KGMidiNote + let note1: KGMidiNote; + let note2: KGMidiNote; + let note3: KGMidiNote; beforeEach(() => { - note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 }) - note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 }) - note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 }) - }) + note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 }); + note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 }); + note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 }); + }); describe('addNote', () => { it('should add a single note to empty region', () => { - region.addNote(note1) + region.addNote(note1); - const notes = region.getNotes() - expect(notes).toHaveLength(1) - expect(notes[0]).toBe(note1) - }) + const notes = region.getNotes(); + expect(notes).toHaveLength(1); + expect(notes[0]).toBe(note1); + }); it('should add multiple notes to region', () => { - region.addNote(note1) - region.addNote(note2) - region.addNote(note3) + region.addNote(note1); + region.addNote(note2); + region.addNote(note3); - const notes = region.getNotes() - expect(notes).toHaveLength(3) - expect(notes).toContain(note1) - expect(notes).toContain(note2) - expect(notes).toContain(note3) - }) + const notes = region.getNotes(); + expect(notes).toHaveLength(3); + expect(notes).toContain(note1); + expect(notes).toContain(note2); + expect(notes).toContain(note3); + }); it('should maintain note order when adding', () => { - region.addNote(note1) - region.addNote(note2) - region.addNote(note3) + region.addNote(note1); + region.addNote(note2); + region.addNote(note3); - const notes = region.getNotes() - expect(notes[0]).toBe(note1) - expect(notes[1]).toBe(note2) - expect(notes[2]).toBe(note3) - }) + const notes = region.getNotes(); + expect(notes[0]).toBe(note1); + expect(notes[1]).toBe(note2); + expect(notes[2]).toBe(note3); + }); it('should allow adding the same note multiple times', () => { - region.addNote(note1) - region.addNote(note1) + region.addNote(note1); + region.addNote(note1); - const notes = region.getNotes() - expect(notes).toHaveLength(2) - expect(notes[0]).toBe(note1) - expect(notes[1]).toBe(note1) - }) - }) + const notes = region.getNotes(); + expect(notes).toHaveLength(2); + expect(notes[0]).toBe(note1); + expect(notes[1]).toBe(note1); + }); + }); describe('removeNote', () => { beforeEach(() => { - region.addNote(note1) - region.addNote(note2) - region.addNote(note3) - }) + region.addNote(note1); + region.addNote(note2); + region.addNote(note3); + }); it('should remove note by ID', () => { - region.removeNote('note-2') + region.removeNote('note-2'); - const notes = region.getNotes() - expect(notes).toHaveLength(2) - expect(notes).toContain(note1) - expect(notes).toContain(note3) - expect(notes).not.toContain(note2) - }) + const notes = region.getNotes(); + expect(notes).toHaveLength(2); + expect(notes).toContain(note1); + expect(notes).toContain(note3); + expect(notes).not.toContain(note2); + }); it('should handle removing non-existent note gracefully', () => { - const initialLength = region.getNotes().length + const initialLength = region.getNotes().length; - region.removeNote('non-existent-note') + region.removeNote('non-existent-note'); - expect(region.getNotes()).toHaveLength(initialLength) - }) + expect(region.getNotes()).toHaveLength(initialLength); + }); it('should remove all instances when note ID appears multiple times', () => { // Add another note with same ID as note1 - const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 }) - region.addNote(duplicateNote) + const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 }); + region.addNote(duplicateNote); - expect(region.getNotes()).toHaveLength(4) + expect(region.getNotes()).toHaveLength(4); - region.removeNote('note-1') + region.removeNote('note-1'); - const notes = region.getNotes() - expect(notes).toHaveLength(2) - expect(notes).toContain(note2) - expect(notes).toContain(note3) - expect(notes).not.toContain(note1) - expect(notes).not.toContain(duplicateNote) - }) + const notes = region.getNotes(); + expect(notes).toHaveLength(2); + expect(notes).toContain(note2); + expect(notes).toContain(note3); + expect(notes).not.toContain(note1); + expect(notes).not.toContain(duplicateNote); + }); it('should handle removing from empty region', () => { - const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region') + const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region'); - expect(() => emptyRegion.removeNote('note-1')).not.toThrow() - expect(emptyRegion.getNotes()).toHaveLength(0) - }) - }) + expect(() => emptyRegion.removeNote('note-1')).not.toThrow(); + expect(emptyRegion.getNotes()).toHaveLength(0); + }); + }); describe('getNotes', () => { it('should return empty array for new region', () => { - const notes = region.getNotes() - expect(notes).toEqual([]) - expect(notes).toHaveLength(0) - }) + const notes = region.getNotes(); + expect(notes).toEqual([]); + expect(notes).toHaveLength(0); + }); it('should return all notes in region', () => { - region.addNote(note1) - region.addNote(note2) + region.addNote(note1); + region.addNote(note2); - const notes = region.getNotes() - expect(notes).toHaveLength(2) - expect(notes).toEqual([note1, note2]) - }) + const notes = region.getNotes(); + expect(notes).toHaveLength(2); + expect(notes).toEqual([note1, note2]); + }); it('should return a reference to the internal notes array', () => { - region.addNote(note1) - const notes1 = region.getNotes() - const notes2 = region.getNotes() + region.addNote(note1); + const notes1 = region.getNotes(); + const notes2 = region.getNotes(); - expect(notes1).toBe(notes2) // Same reference - }) - }) + expect(notes1).toBe(notes2); // Same reference + }); + }); describe('setNotes', () => { it('should replace all notes with new array', () => { - region.addNote(note1) - region.addNote(note2) + region.addNote(note1); + region.addNote(note2); - expect(region.getNotes()).toHaveLength(2) + expect(region.getNotes()).toHaveLength(2); - region.setNotes([note3]) + region.setNotes([note3]); - const notes = region.getNotes() - expect(notes).toHaveLength(1) - expect(notes[0]).toBe(note3) - }) + const notes = region.getNotes(); + expect(notes).toHaveLength(1); + expect(notes[0]).toBe(note3); + }); it('should allow setting empty notes array', () => { - region.addNote(note1) - region.addNote(note2) + region.addNote(note1); + region.addNote(note2); - region.setNotes([]) + region.setNotes([]); - expect(region.getNotes()).toHaveLength(0) - }) + expect(region.getNotes()).toHaveLength(0); + }); it('should accept notes array with multiple notes', () => { - const newNotes = [note1, note2, note3] - region.setNotes(newNotes) + const newNotes = [note1, note2, note3]; + region.setNotes(newNotes); - const retrievedNotes = region.getNotes() - expect(retrievedNotes).toHaveLength(3) - expect(retrievedNotes).toEqual(newNotes) - }) - }) - }) + const retrievedNotes = region.getNotes(); + expect(retrievedNotes).toHaveLength(3); + expect(retrievedNotes).toEqual(newNotes); + }); + }); + }); describe('inheritance from KGRegion', () => { it('should inherit all base region properties', () => { - expect(region.getId()).toBe('test-region-1') - expect(region.getTrackId()).toBe('test-track-1') - expect(region.getTrackIndex()).toBe(0) - expect(region.getName()).toBe('Test Region') - expect(region.getStartFromBeat()).toBe(0) - expect(region.getLength()).toBe(4) - }) + expect(region.getId()).toBe('test-region-1'); + expect(region.getTrackId()).toBe('test-track-1'); + expect(region.getTrackIndex()).toBe(0); + expect(region.getName()).toBe('Test Region'); + expect(region.getStartFromBeat()).toBe(0); + expect(region.getLength()).toBe(4); + }); it('should inherit selection functionality', () => { - expect(region.isSelected()).toBe(false) + expect(region.isSelected()).toBe(false); - region.select() - expect(region.isSelected()).toBe(true) + region.select(); + expect(region.isSelected()).toBe(true); - region.deselect() - expect(region.isSelected()).toBe(false) - }) + region.deselect(); + expect(region.isSelected()).toBe(false); + }); it('should inherit setters from base class', () => { - region.setName('Updated Region') - expect(region.getName()).toBe('Updated Region') + region.setName('Updated Region'); + expect(region.getName()).toBe('Updated Region'); - region.setStartFromBeat(8) - expect(region.getStartFromBeat()).toBe(8) + region.setStartFromBeat(8); + expect(region.getStartFromBeat()).toBe(8); - region.setLength(12) - expect(region.getLength()).toBe(12) - }) - }) + region.setLength(12); + expect(region.getLength()).toBe(12); + }); + }); describe('type identification', () => { it('should return correct current type', () => { - expect(region.getCurrentType()).toBe('KGMidiRegion') - }) + expect(region.getCurrentType()).toBe('KGMidiRegion'); + }); it('should return correct root type', () => { - expect(region.getRootType()).toBe('KGRegion') - }) + expect(region.getRootType()).toBe('KGRegion'); + }); it('should be instanceof both KGMidiRegion and KGRegion', () => { - expect(region).toBeInstanceOf(KGMidiRegion) - expect(region).toBeInstanceOf(KGRegion) - }) - }) + expect(region).toBeInstanceOf(KGMidiRegion); + expect(region).toBeInstanceOf(KGRegion); + }); + }); describe('edge cases and error handling', () => { it('should handle notes with overlapping time ranges', () => { - const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 }) - const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 }) + const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 }); + const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 }); - region.addNote(overlappingNote1) - region.addNote(overlappingNote2) + region.addNote(overlappingNote1); + region.addNote(overlappingNote2); - const notes = region.getNotes() - expect(notes).toHaveLength(2) - expect(notes).toContain(overlappingNote1) - expect(notes).toContain(overlappingNote2) - }) + const notes = region.getNotes(); + expect(notes).toHaveLength(2); + expect(notes).toContain(overlappingNote1); + expect(notes).toContain(overlappingNote2); + }); it('should handle notes with same pitch but different timing', () => { - const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 }) - const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 }) + const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 }); + const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 }); - region.addNote(sameNote1) - region.addNote(sameNote2) + region.addNote(sameNote1); + region.addNote(sameNote2); - expect(region.getNotes()).toHaveLength(2) - }) + expect(region.getNotes()).toHaveLength(2); + }); it('should handle notes outside region boundaries', () => { // Region is from beat 0 to 4, but note extends beyond - const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 }) + const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 }); - region.addNote(outsideNote) + region.addNote(outsideNote); - const notes = region.getNotes() - expect(notes).toHaveLength(1) - expect(notes[0]).toBe(outsideNote) + const notes = region.getNotes(); + expect(notes).toHaveLength(1); + expect(notes[0]).toBe(outsideNote); // Note: The region doesn't enforce boundary constraints - that's application logic - }) + }); it('should handle zero-length region', () => { - const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0) - const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 }) + const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0); + const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 }); - zeroRegion.addNote(note) + zeroRegion.addNote(note); - expect(zeroRegion.getNotes()).toHaveLength(1) - expect(zeroRegion.getLength()).toBe(0) - }) - }) + expect(zeroRegion.getNotes()).toHaveLength(1); + expect(zeroRegion.getLength()).toBe(0); + }); + }); describe('data consistency', () => { it('should maintain note references correctly', () => { - const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 }) + const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 }); - region.addNote(originalNote) - const retrievedNote = region.getNotes()[0] + region.addNote(originalNote); + const retrievedNote = region.getNotes()[0]; - expect(retrievedNote).toBe(originalNote) // Same reference + expect(retrievedNote).toBe(originalNote); // Same reference // Modify original note - originalNote.setPitch(64) - expect(retrievedNote.getPitch()).toBe(64) // Should reflect change - }) + originalNote.setPitch(64); + expect(retrievedNote.getPitch()).toBe(64); // Should reflect change + }); it('should handle concurrent modifications correctly', () => { const notes = [ createMockMidiNote({ id: 'concurrent-1', pitch: 60 }), createMockMidiNote({ id: 'concurrent-2', pitch: 64 }), createMockMidiNote({ id: 'concurrent-3', pitch: 67 }) - ] + ]; // Add notes - notes.forEach(note => region.addNote(note)) - expect(region.getNotes()).toHaveLength(3) + notes.forEach(note => region.addNote(note)); + expect(region.getNotes()).toHaveLength(3); // Remove middle note - region.removeNote('concurrent-2') - expect(region.getNotes()).toHaveLength(2) + region.removeNote('concurrent-2'); + expect(region.getNotes()).toHaveLength(2); // Add new note - const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 }) - region.addNote(newNote) - expect(region.getNotes()).toHaveLength(3) + const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 }); + region.addNote(newNote); + expect(region.getNotes()).toHaveLength(3); // Verify final state - const finalNotes = region.getNotes() - expect(finalNotes).toContain(notes[0]) // concurrent-1 - expect(finalNotes).not.toContain(notes[1]) // concurrent-2 (removed) - expect(finalNotes).toContain(notes[2]) // concurrent-3 - expect(finalNotes).toContain(newNote) // concurrent-4 - }) - }) -}) \ No newline at end of file + const finalNotes = region.getNotes(); + expect(finalNotes).toContain(notes[0]); // concurrent-1 + expect(finalNotes).not.toContain(notes[1]); // concurrent-2 (removed) + expect(finalNotes).toContain(notes[2]); // concurrent-3 + expect(finalNotes).toContain(newNote); // concurrent-4 + }); + }); +}); \ No newline at end of file diff --git a/src/core/track/KGMidiTrack.test.ts b/src/core/track/KGMidiTrack.test.ts index e7066bd..73092a3 100644 --- a/src/core/track/KGMidiTrack.test.ts +++ b/src/core/track/KGMidiTrack.test.ts @@ -1,70 +1,70 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { KGMidiTrack, type InstrumentType } from './KGMidiTrack' -import { KGTrack, TrackType } from './KGTrack' -import { KGMidiRegion } from '../region/KGMidiRegion' -import { createMockMidiRegion } from '../../test/utils/mock-data' +import { describe, it, expect, beforeEach } from 'vitest'; +import { KGMidiTrack, type InstrumentType } from './KGMidiTrack'; +import { KGTrack, TrackType } from './KGTrack'; +import { KGMidiRegion } from '../region/KGMidiRegion'; +import { createMockMidiRegion } from '../../test/utils/mock-data'; describe('KGMidiTrack', () => { - let track: KGMidiTrack + let track: KGMidiTrack; beforeEach(() => { - track = new KGMidiTrack() - }) + track = new KGMidiTrack(); + }); describe('constructor', () => { it('should create track with default values', () => { - const defaultTrack = new KGMidiTrack() + const defaultTrack = new KGMidiTrack(); - expect(defaultTrack.getName()).toBe('Untitled MIDI Track') - expect(defaultTrack.getId()).toBe(0) - expect(defaultTrack.getType()).toBe(TrackType.MIDI) - expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano') - expect(defaultTrack.getVolume()).toBe(0) // DEFAULT_TRACK_VOLUME (0 dB) - expect(defaultTrack.getRegions()).toEqual([]) - }) + expect(defaultTrack.getName()).toBe('Untitled MIDI Track'); + expect(defaultTrack.getId()).toBe(0); + expect(defaultTrack.getType()).toBe(TrackType.MIDI); + expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano'); + expect(defaultTrack.getVolume()).toBe(0); // DEFAULT_TRACK_VOLUME (0 dB) + expect(defaultTrack.getRegions()).toEqual([]); + }); it('should create track with custom parameters', () => { - const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4) + const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4); - expect(customTrack.getName()).toBe('My Piano Track') - expect(customTrack.getId()).toBe(5) - expect(customTrack.getType()).toBe(TrackType.MIDI) - expect(customTrack.getInstrument()).toBe('electric_piano_1') - expect(customTrack.getVolume()).toBe(-4) - }) + expect(customTrack.getName()).toBe('My Piano Track'); + expect(customTrack.getId()).toBe(5); + expect(customTrack.getType()).toBe(TrackType.MIDI); + expect(customTrack.getInstrument()).toBe('electric_piano_1'); + expect(customTrack.getVolume()).toBe(-4); + }); it('should set correct type identifier', () => { - expect(track.getCurrentType()).toBe('KGMidiTrack') - }) + expect(track.getCurrentType()).toBe('KGMidiTrack'); + }); it('should inherit from KGTrack', () => { - expect(track).toBeInstanceOf(KGMidiTrack) - expect(track).toBeInstanceOf(KGTrack) - }) - }) + expect(track).toBeInstanceOf(KGMidiTrack); + expect(track).toBeInstanceOf(KGTrack); + }); + }); describe('instrument management', () => { describe('getInstrument', () => { it('should return default instrument when not set', () => { - expect(track.getInstrument()).toBe('acoustic_grand_piano') - }) + expect(track.getInstrument()).toBe('acoustic_grand_piano'); + }); it('should return current instrument', () => { - const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean') - expect(customTrack.getInstrument()).toBe('electric_guitar_clean') - }) + const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean'); + expect(customTrack.getInstrument()).toBe('electric_guitar_clean'); + }); it('should provide backward compatibility for undefined instrument', () => { // This tests the backward compatibility mentioned in the code - expect(track.getInstrument()).toBe('acoustic_grand_piano') - }) - }) + expect(track.getInstrument()).toBe('acoustic_grand_piano'); + }); + }); describe('setInstrument', () => { it('should update instrument', () => { - track.setInstrument('violin') - expect(track.getInstrument()).toBe('violin') - }) + track.setInstrument('violin'); + expect(track.getInstrument()).toBe('violin'); + }); it('should handle different instrument types', () => { const instruments: InstrumentType[] = [ @@ -75,28 +75,28 @@ describe('KGMidiTrack', () => { 'violin', 'trumpet', 'flute' - ] + ]; instruments.forEach(instrument => { - track.setInstrument(instrument) - expect(track.getInstrument()).toBe(instrument) - }) - }) + track.setInstrument(instrument); + expect(track.getInstrument()).toBe(instrument); + }); + }); it('should handle rapid instrument changes', () => { - track.setInstrument('piano') - track.setInstrument('guitar') - track.setInstrument('violin') + track.setInstrument('piano'); + track.setInstrument('guitar'); + track.setInstrument('violin'); - expect(track.getInstrument()).toBe('violin') - }) - }) - }) + expect(track.getInstrument()).toBe('violin'); + }); + }); + }); describe('region management', () => { - let region1: KGMidiRegion - let region2: KGMidiRegion - let region3: KGMidiRegion + let region1: KGMidiRegion; + let region2: KGMidiRegion; + let region3: KGMidiRegion; beforeEach(() => { region1 = createMockMidiRegion({ @@ -105,137 +105,137 @@ describe('KGMidiTrack', () => { name: 'Region 1', startFromBeat: 0, length: 4 - }) + }); region2 = createMockMidiRegion({ id: 'region-2', trackId: track.getId().toString(), name: 'Region 2', startFromBeat: 4, length: 4 - }) + }); region3 = createMockMidiRegion({ id: 'region-3', trackId: track.getId().toString(), name: 'Region 3', startFromBeat: 8, length: 4 - }) - }) + }); + }); describe('setRegions', () => { it('should set regions array', () => { - const regions = [region1, region2] - track.setRegions(regions) + const regions = [region1, region2]; + track.setRegions(regions); - expect(track.getRegions()).toHaveLength(2) - expect(track.getRegions()).toEqual(regions) - }) + expect(track.getRegions()).toHaveLength(2); + expect(track.getRegions()).toEqual(regions); + }); it('should replace existing regions', () => { - track.setRegions([region1]) - expect(track.getRegions()).toHaveLength(1) + track.setRegions([region1]); + expect(track.getRegions()).toHaveLength(1); - track.setRegions([region2, region3]) - expect(track.getRegions()).toHaveLength(2) - expect(track.getRegions()).toContain(region2) - expect(track.getRegions()).toContain(region3) - expect(track.getRegions()).not.toContain(region1) - }) + track.setRegions([region2, region3]); + expect(track.getRegions()).toHaveLength(2); + expect(track.getRegions()).toContain(region2); + expect(track.getRegions()).toContain(region3); + expect(track.getRegions()).not.toContain(region1); + }); it('should accept empty array', () => { - track.setRegions([region1, region2]) - track.setRegions([]) + track.setRegions([region1, region2]); + track.setRegions([]); - expect(track.getRegions()).toHaveLength(0) - }) + expect(track.getRegions()).toHaveLength(0); + }); it('should enforce KGMidiRegion type', () => { - const regions: KGMidiRegion[] = [region1, region2] - track.setRegions(regions) + const regions: KGMidiRegion[] = [region1, region2]; + track.setRegions(regions); - const retrievedRegions = track.getRegions() + const retrievedRegions = track.getRegions(); retrievedRegions.forEach(region => { - expect(region).toBeInstanceOf(KGMidiRegion) - }) - }) - }) + expect(region).toBeInstanceOf(KGMidiRegion); + }); + }); + }); describe('inherited region methods', () => { beforeEach(() => { - track.setRegions([region1, region2]) - }) + track.setRegions([region1, region2]); + }); it('should inherit addRegion method', () => { - track.addRegion(region3) + track.addRegion(region3); - const regions = track.getRegions() - expect(regions).toHaveLength(3) - expect(regions).toContain(region3) - }) + const regions = track.getRegions(); + expect(regions).toHaveLength(3); + expect(regions).toContain(region3); + }); it('should inherit removeRegion method', () => { - track.removeRegion('region-1') + track.removeRegion('region-1'); - const regions = track.getRegions() - expect(regions).toHaveLength(1) - expect(regions).not.toContain(region1) - expect(regions).toContain(region2) - }) + const regions = track.getRegions(); + expect(regions).toHaveLength(1); + expect(regions).not.toContain(region1); + expect(regions).toContain(region2); + }); it('should inherit getRegions method', () => { - const regions = track.getRegions() - expect(regions).toHaveLength(2) - expect(regions).toContain(region1) - expect(regions).toContain(region2) - }) - }) - }) + const regions = track.getRegions(); + expect(regions).toHaveLength(2); + expect(regions).toContain(region1); + expect(regions).toContain(region2); + }); + }); + }); describe('inheritance from KGTrack', () => { it('should inherit all base track properties', () => { - const customTrack = new KGMidiTrack('Test Track', 42, 'violin', -1) + const customTrack = new KGMidiTrack('Test Track', 42, 'violin', -1); - expect(customTrack.getName()).toBe('Test Track') - expect(customTrack.getId()).toBe(42) - expect(customTrack.getType()).toBe(TrackType.MIDI) - expect(customTrack.getVolume()).toBe(-1) - }) + expect(customTrack.getName()).toBe('Test Track'); + expect(customTrack.getId()).toBe(42); + expect(customTrack.getType()).toBe(TrackType.MIDI); + expect(customTrack.getVolume()).toBe(-1); + }); it('should inherit base track setters', () => { - track.setName('Updated Track') - expect(track.getName()).toBe('Updated Track') + track.setName('Updated Track'); + expect(track.getName()).toBe('Updated Track'); - track.setVolume(-6) - expect(track.getVolume()).toBe(-6) + track.setVolume(-6); + expect(track.getVolume()).toBe(-6); - track.setTrackIndex(3) - expect(track.getTrackIndex()).toBe(3) - }) + track.setTrackIndex(3); + expect(track.getTrackIndex()).toBe(3); + }); it('should inherit volume controls', () => { - expect(track.getVolume()).toBe(0) // Default volume (0 dB) + expect(track.getVolume()).toBe(0); // Default volume (0 dB) - track.setVolume(-6) - expect(track.getVolume()).toBe(-6) + track.setVolume(-6); + expect(track.getVolume()).toBe(-6); - track.setVolume(0) - expect(track.getVolume()).toBe(0) - }) - }) + track.setVolume(0); + expect(track.getVolume()).toBe(0); + }); + }); describe('type identification', () => { it('should return correct current type', () => { - expect(track.getCurrentType()).toBe('KGMidiTrack') - }) + expect(track.getCurrentType()).toBe('KGMidiTrack'); + }); it('should return correct root type', () => { - expect(track.getRootType()).toBe('KGTrack') - }) + expect(track.getRootType()).toBe('KGTrack'); + }); it('should have MIDI track type', () => { - expect(track.getType()).toBe(TrackType.MIDI) - }) - }) + expect(track.getType()).toBe(TrackType.MIDI); + }); + }); describe('instrument type validation', () => { it('should handle all valid General MIDI instruments', () => { @@ -259,100 +259,100 @@ describe('KGMidiTrack', () => { 'clarinet', 'soprano_sax', 'alto_sax' - ] + ]; validInstruments.forEach(instrument => { expect(() => { - track.setInstrument(instrument) - expect(track.getInstrument()).toBe(instrument) - }).not.toThrow() - }) - }) - }) + track.setInstrument(instrument); + expect(track.getInstrument()).toBe(instrument); + }).not.toThrow(); + }); + }); + }); describe('track state consistency', () => { it('should maintain consistent state after multiple operations', () => { // Setup initial state - track.setName('Piano Track') - track.setInstrument('acoustic_grand_piano') - track.setVolume(-3) + track.setName('Piano Track'); + track.setInstrument('acoustic_grand_piano'); + track.setVolume(-3); const regions = [ createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }), createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' }) - ] - track.setRegions(regions) + ]; + track.setRegions(regions); // Verify initial state - expect(track.getName()).toBe('Piano Track') - expect(track.getInstrument()).toBe('acoustic_grand_piano') - expect(track.getVolume()).toBe(-3) - expect(track.getRegions()).toHaveLength(2) + expect(track.getName()).toBe('Piano Track'); + expect(track.getInstrument()).toBe('acoustic_grand_piano'); + expect(track.getVolume()).toBe(-3); + expect(track.getRegions()).toHaveLength(2); // Modify state - track.setInstrument('electric_piano_1') + track.setInstrument('electric_piano_1'); track.addRegion(createMockMidiRegion({ id: 'r3', trackId: '0', name: 'Chorus' - })) + })); // Verify modified state - expect(track.getName()).toBe('Piano Track') - expect(track.getInstrument()).toBe('electric_piano_1') - expect(track.getVolume()).toBe(-3) - expect(track.getRegions()).toHaveLength(3) - }) + expect(track.getName()).toBe('Piano Track'); + expect(track.getInstrument()).toBe('electric_piano_1'); + expect(track.getVolume()).toBe(-3); + expect(track.getRegions()).toHaveLength(3); + }); it('should handle region-track relationship correctly', () => { const region = createMockMidiRegion({ id: 'test-region', trackId: track.getId().toString(), name: 'Test Region' - }) + }); - track.addRegion(region) + track.addRegion(region); // Verify region is in track - expect(track.getRegions()).toContain(region) + expect(track.getRegions()).toContain(region); // Find region manually since getRegionById doesn't exist - const foundRegion = track.getRegions().find(r => r.getId() === 'test-region') - expect(foundRegion).toBe(region) + const foundRegion = track.getRegions().find(r => r.getId() === 'test-region'); + expect(foundRegion).toBe(region); // Verify region properties - expect(region.getTrackId()).toBe(track.getId().toString()) - }) - }) + expect(region.getTrackId()).toBe(track.getId().toString()); + }); + }); describe('edge cases and error handling', () => { it('should handle empty track name', () => { - const emptyNameTrack = new KGMidiTrack('', 0, 'piano') - expect(emptyNameTrack.getName()).toBe('') - }) + const emptyNameTrack = new KGMidiTrack('', 0, 'piano'); + expect(emptyNameTrack.getName()).toBe(''); + }); it('should handle negative track ID', () => { - const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano') - expect(negativeIdTrack.getId()).toBe(-1) - }) + const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano'); + expect(negativeIdTrack.getId()).toBe(-1); + }); it('should handle volume boundaries', () => { - track.setVolume(-60) - expect(track.getVolume()).toBe(-60) + track.setVolume(-60); + expect(track.getVolume()).toBe(-60); - track.setVolume(0) - expect(track.getVolume()).toBe(0) + track.setVolume(0); + expect(track.getVolume()).toBe(0); - track.setVolume(6) - expect(track.getVolume()).toBe(6) + track.setVolume(6); + expect(track.getVolume()).toBe(6); // Volume outside valid dB range is clamped - track.setVolume(10) - expect(track.getVolume()).toBe(6) + track.setVolume(10); + expect(track.getVolume()).toBe(6); - track.setVolume(-100) - expect(track.getVolume()).toBe(-60) - }) + track.setVolume(-100); + expect(track.getVolume()).toBe(-60); + }); it('should handle large number of regions', () => { const manyRegions = Array.from({ length: 100 }, (_, i) => @@ -361,44 +361,44 @@ describe('KGMidiTrack', () => { trackId: track.getId().toString(), name: `Region ${i}` }) - ) + ); - track.setRegions(manyRegions) - expect(track.getRegions()).toHaveLength(100) + track.setRegions(manyRegions); + expect(track.getRegions()).toHaveLength(100); // Should be able to find any region - const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50') - const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99') + const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50'); + const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99'); - expect(foundRegion50).toBeDefined() - expect(foundRegion99).toBeDefined() - }) - }) + expect(foundRegion50).toBeDefined(); + expect(foundRegion99).toBeDefined(); + }); + }); describe('class-transformer compatibility', () => { it('should have proper type annotations for serialization', () => { // Verify the class has the necessary decorators for serialization - expect(track.getCurrentType()).toBe('KGMidiTrack') + expect(track.getCurrentType()).toBe('KGMidiTrack'); // Test that default instrument fallback works - const instrument = track.getInstrument() - expect(instrument).toBe('acoustic_grand_piano') - }) + const instrument = track.getInstrument(); + expect(instrument).toBe('acoustic_grand_piano'); + }); it('should maintain regions type after serialization simulation', () => { const regions = [ createMockMidiRegion({ id: 'r1', trackId: '0' }), createMockMidiRegion({ id: 'r2', trackId: '0' }) - ] + ]; - track.setRegions(regions) + track.setRegions(regions); // Simulate what happens during serialization/deserialization - const retrievedRegions = track.getRegions() - expect(retrievedRegions).toHaveLength(2) + const retrievedRegions = track.getRegions(); + expect(retrievedRegions).toHaveLength(2); retrievedRegions.forEach(region => { - expect(region).toBeInstanceOf(KGMidiRegion) - }) - }) - }) -}) \ No newline at end of file + expect(region).toBeInstanceOf(KGMidiRegion); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts index 3d0d916..916f397 100644 --- a/src/hooks/useGlobalKeyboardHandler.ts +++ b/src/hooks/useGlobalKeyboardHandler.ts @@ -5,13 +5,16 @@ import { saveProject } from '../util/saveUtil'; import { ConfigManager } from '../core/config/ConfigManager'; import { useProjectStore } from '../stores/projectStore'; import { selectAllNotesInActiveRegion } from '../util/selectionUtil'; +import { KGCore } from '../core/KGCore'; +import { KGMidiInput } from '../core/midi-input/KGMidiInput'; +import { KGMidiRegion } from '../core/region/KGMidiRegion'; /** * Global keyboard handler for copy/paste, undo/redo, play/pause, and save operations * Handles keyboard shortcuts defined in the configuration */ export const useGlobalKeyboardHandler = () => { - const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName } = useProjectStore(); + const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll } = useProjectStore(); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -64,6 +67,7 @@ export const useGlobalKeyboardHandler = () => { const playShortcut = configManager.get('hotkeys.main.play') as string; const loopShortcut = configManager.get('hotkeys.main.loop') as string; const saveShortcut = configManager.get('hotkeys.main.save') as string; + const recordShortcut = configManager.get('hotkeys.main.record') as string; // Check for undo shortcut if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) { @@ -153,6 +157,42 @@ export const useGlobalKeyboardHandler = () => { return; } + // Check for record toggle shortcut + if (recordShortcut && matchesKeyboardShortcut(event, recordShortcut)) { + event.preventDefault(); + if (isRecording) { + stopRecording(); + setStatus('Recording stopped — notes committed'); + return; + } + const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null); + if (!candidateId) { + setStatus('Select a MIDI region before recording'); + return; + } + const tracks = KGCore.instance().getCurrentProject().getTracks(); + let isMidi = false; + for (const track of tracks) { + const region = track.getRegions().find(r => r.getId() === candidateId); + if (region) { isMidi = region instanceof KGMidiRegion; break; } + } + if (!isMidi) { + setStatus('Select a MIDI region before recording'); + return; + } + if (KGMidiInput.instance().getConnectedInputCount() === 0) { + setStatus('No MIDI device detected'); + return; + } + if (!activeRegionId) { + setActiveRegionId(candidateId); + setShowPianoRoll(true); + } + startRecording(); + setStatus('Recording started...'); + return; + } + // Check for save shortcut if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) { event.preventDefault(); @@ -176,5 +216,5 @@ export const useGlobalKeyboardHandler = () => { return () => { document.removeEventListener('keydown', handleKeyDown, { capture: true }); }; - }, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName]); // Include dependencies for store actions + }, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll]); // Include dependencies for store actions }; \ No newline at end of file diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index b97461d..9d1f1a1 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -1197,7 +1197,7 @@ export const useProjectStore = create((set, get) => { set({ showChatBox: defaultChatBoxOpen }); } } - } + }; }); diff --git a/src/test/integration/store/command-execution.integration.test.ts b/src/test/integration/store/command-execution.integration.test.ts index 122adf6..f3d224b 100644 --- a/src/test/integration/store/command-execution.integration.test.ts +++ b/src/test/integration/store/command-execution.integration.test.ts @@ -2,334 +2,334 @@ * Integration tests for command execution and undo/redo functionality * Tests the complete flow: Command execution → Core model updates → UI state sync */ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { createRoot } from 'react-dom/client' +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { createRoot } from 'react-dom/client'; // Import core classes -import { KGCore } from '../../../core/KGCore' -import { KGProject } from '../../../core/KGProject' -import { KGMidiTrack } from '../../../core/track/KGMidiTrack' -import { KGMidiRegion } from '../../../core/region/KGMidiRegion' -import { KGMidiNote } from '../../../core/midi/KGMidiNote' -import { KGCommandHistory } from '../../../core/commands/KGCommandHistory' +import { KGCore } from '../../../core/KGCore'; +import { KGProject } from '../../../core/KGProject'; +import { KGMidiTrack } from '../../../core/track/KGMidiTrack'; +import { KGMidiRegion } from '../../../core/region/KGMidiRegion'; +import { KGMidiNote } from '../../../core/midi/KGMidiNote'; +import { KGCommandHistory } from '../../../core/commands/KGCommandHistory'; // Import commands -import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand' -import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand' -import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand' +import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand'; +import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand'; +import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand'; // Import store -import { useProjectStore } from '../../../stores/projectStore' +import { useProjectStore } from '../../../stores/projectStore'; // Import test utilities -import '../../utils/setup-integration-tests' +import '../../utils/setup-integration-tests'; describe('Command Execution Integration Tests', () => { - let testProject: KGProject - let testTrack: KGMidiTrack - let testRegion: KGMidiRegion + let testProject: KGProject; + let testTrack: KGMidiTrack; + let testRegion: KGMidiRegion; beforeEach(async () => { // Create a real project with track and region for testing - testProject = new KGProject('Test Project') - testProject.setBpm(120) - testProject.setTimeSignature({ numerator: 4, denominator: 4 }) - testProject.setKeySignature('C major') - testProject.setMaxBars(32) + testProject = new KGProject('Test Project'); + testProject.setBpm(120); + testProject.setTimeSignature({ numerator: 4, denominator: 4 }); + testProject.setKeySignature('C major'); + testProject.setMaxBars(32); // Create test track and region - testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano') - testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16) + testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano'); + testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16); // Set up the project hierarchy - testTrack.addRegion(testRegion) - testProject.setTracks([testTrack]) + testTrack.addRegion(testRegion); + testProject.setTracks([testTrack]); // Initialize KGCore with test project - const core = KGCore.instance() - await core.initialize() - core.setCurrentProject(testProject) + const core = KGCore.instance(); + await core.initialize(); + core.setCurrentProject(testProject); // Clear command history - KGCommandHistory.instance().clear() + KGCommandHistory.instance().clear(); // Initialize store with project - const { loadProject } = useProjectStore.getState() - await loadProject(testProject) - }) + const { loadProject } = useProjectStore.getState(); + await loadProject(testProject); + }); describe('Note Command Integration', () => { it('should execute CreateNoteCommand and update both core model and store', async () => { - const regionId = testRegion.getId() - const initialNoteCount = testRegion.getNotes().length + const regionId = testRegion.getId(); + const initialNoteCount = testRegion.getNotes().length; // Create and execute command - const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) - const commandHistory = KGCommandHistory.instance() + const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100); + const commandHistory = KGCommandHistory.instance(); // Execute command through command history (simulates real usage) act(() => { - commandHistory.executeCommand(createCommand) - }) + commandHistory.executeCommand(createCommand); + }); // Verify core model was updated - const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion - expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1) + const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion; + expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1); - const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId()) - expect(createdNote).toBeDefined() - expect(createdNote!.getPitch()).toBe(60) - expect(createdNote!.getStartBeat()).toBe(0) - expect(createdNote!.getEndBeat()).toBe(1) + const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId()); + expect(createdNote).toBeDefined(); + expect(createdNote!.getPitch()).toBe(60); + expect(createdNote!.getStartBeat()).toBe(0); + expect(createdNote!.getEndBeat()).toBe(1); // Verify command history state - expect(commandHistory.canUndo()).toBe(true) - expect(commandHistory.canRedo()).toBe(false) - expect(commandHistory.getUndoDescription()).toBe('Create note C4') + expect(commandHistory.canUndo()).toBe(true); + expect(commandHistory.canRedo()).toBe(false); + expect(commandHistory.getUndoDescription()).toBe('Create note C4'); // Verify store undo/redo state is updated - const storeState = useProjectStore.getState() - expect(storeState.canUndo).toBe(true) - expect(storeState.canRedo).toBe(false) - }) + const storeState = useProjectStore.getState(); + expect(storeState.canUndo).toBe(true); + expect(storeState.canRedo).toBe(false); + }); it('should execute undo and restore previous state', async () => { - const regionId = testRegion.getId() - const initialNoteCount = testRegion.getNotes().length + const regionId = testRegion.getId(); + const initialNoteCount = testRegion.getNotes().length; // Create and execute command - const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120) // E4 - const commandHistory = KGCommandHistory.instance() + const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120); // E4 + const commandHistory = KGCommandHistory.instance(); act(() => { - commandHistory.executeCommand(createCommand) - }) + commandHistory.executeCommand(createCommand); + }); // Verify note was created - const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion - expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1) + const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion; + expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1); // Execute undo act(() => { - const undoSuccess = commandHistory.undo() - expect(undoSuccess).toBe(true) - }) + const undoSuccess = commandHistory.undo(); + expect(undoSuccess).toBe(true); + }); // Verify core model was restored - const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion - expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount) + const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion; + expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount); // Verify the specific note was removed - const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId()) - expect(noteExists).toBe(false) + const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId()); + expect(noteExists).toBe(false); // Verify command history state - expect(commandHistory.canUndo()).toBe(false) - expect(commandHistory.canRedo()).toBe(true) - expect(commandHistory.getRedoDescription()).toBe('Create note E4') - }) + expect(commandHistory.canUndo()).toBe(false); + expect(commandHistory.canRedo()).toBe(true); + expect(commandHistory.getRedoDescription()).toBe('Create note E4'); + }); it('should execute redo and restore forward state', async () => { - const regionId = testRegion.getId() - const initialNoteCount = testRegion.getNotes().length + const regionId = testRegion.getId(); + const initialNoteCount = testRegion.getNotes().length; // Create, execute, and undo a command - const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110) // G4 - const commandHistory = KGCommandHistory.instance() + const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110); // G4 + const commandHistory = KGCommandHistory.instance(); act(() => { - commandHistory.executeCommand(createCommand) - commandHistory.undo() - }) + commandHistory.executeCommand(createCommand); + commandHistory.undo(); + }); // Verify we're back to initial state - expect(testRegion.getNotes()).toHaveLength(initialNoteCount) + expect(testRegion.getNotes()).toHaveLength(initialNoteCount); // Execute redo act(() => { - const redoSuccess = commandHistory.redo() - expect(redoSuccess).toBe(true) - }) + const redoSuccess = commandHistory.redo(); + expect(redoSuccess).toBe(true); + }); // Verify core model was restored to post-create state - const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion - expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1) + const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion; + expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1); // Verify the specific note was recreated - const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId()) - expect(recreatedNote).toBeDefined() - expect(recreatedNote!.getPitch()).toBe(67) + const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId()); + expect(recreatedNote).toBeDefined(); + expect(recreatedNote!.getPitch()).toBe(67); // Verify command history state - expect(commandHistory.canUndo()).toBe(true) - expect(commandHistory.canRedo()).toBe(false) - }) - }) + expect(commandHistory.canUndo()).toBe(true); + expect(commandHistory.canRedo()).toBe(false); + }); + }); describe('Multiple Command Integration', () => { it('should execute multiple commands and maintain history integrity', async () => { - const regionId = testRegion.getId() - const commandHistory = KGCommandHistory.instance() + const regionId = testRegion.getId(); + const commandHistory = KGCommandHistory.instance(); // Execute multiple note creation commands - const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100) // C4 - const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100) // E4 - const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100) // G4 + const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100); // C4 + const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100); // E4 + const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100); // G4 act(() => { - commandHistory.executeCommand(command1) - commandHistory.executeCommand(command2) - commandHistory.executeCommand(command3) - }) + commandHistory.executeCommand(command1); + commandHistory.executeCommand(command2); + commandHistory.executeCommand(command3); + }); // Verify all notes were created - const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion - expect(region.getNotes()).toHaveLength(3) + const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion; + expect(region.getNotes()).toHaveLength(3); // Verify command history - expect(commandHistory.canUndo()).toBe(true) - expect(commandHistory.getUndoDescription()).toBe('Create note G4') + expect(commandHistory.canUndo()).toBe(true); + expect(commandHistory.getUndoDescription()).toBe('Create note G4'); // Undo middle command by undoing twice act(() => { - commandHistory.undo() // Remove G4 - commandHistory.undo() // Remove E4 - }) + commandHistory.undo(); // Remove G4 + commandHistory.undo(); // Remove E4 + }); // Verify only first note remains - expect(region.getNotes()).toHaveLength(1) - expect(region.getNotes()[0].getPitch()).toBe(60) // C4 + expect(region.getNotes()).toHaveLength(1); + expect(region.getNotes()[0].getPitch()).toBe(60); // C4 // Verify redo state - expect(commandHistory.canRedo()).toBe(true) - expect(commandHistory.getRedoDescription()).toBe('Create note E4') - }) + expect(commandHistory.canRedo()).toBe(true); + expect(commandHistory.getRedoDescription()).toBe('Create note E4'); + }); it('should handle command execution with different command types', async () => { - const commandHistory = KGCommandHistory.instance() - const initialTrackCount = testProject.getTracks().length + const commandHistory = KGCommandHistory.instance(); + const initialTrackCount = testProject.getTracks().length; // Execute track addition command - const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass') + const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass'); act(() => { - commandHistory.executeCommand(addTrackCommand) - }) + commandHistory.executeCommand(addTrackCommand); + }); // Verify track was added to core model - expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1) - const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack - expect(newTrack.getName()).toBe('Bass Track') - expect(newTrack.getInstrument()).toBe('acoustic_bass') + expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1); + const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack; + expect(newTrack.getName()).toBe('Bass Track'); + expect(newTrack.getInstrument()).toBe('acoustic_bass'); // Add a note to the original region - const createNoteCommand = new CreateNoteCommand(testRegion.getId(), 0, 1, 48, 100) // C3 + const createNoteCommand = new CreateNoteCommand(testRegion.getId(), 0, 1, 48, 100); // C3 act(() => { - commandHistory.executeCommand(createNoteCommand) - }) + commandHistory.executeCommand(createNoteCommand); + }); // Verify both commands are in history - expect(commandHistory.canUndo()).toBe(true) - expect(commandHistory.getUndoDescription()).toBe('Create note C3') + expect(commandHistory.canUndo()).toBe(true); + expect(commandHistory.getUndoDescription()).toBe('Create note C3'); // Undo both commands act(() => { - commandHistory.undo() // Undo note creation - commandHistory.undo() // Undo track addition - }) + commandHistory.undo(); // Undo note creation + commandHistory.undo(); // Undo track addition + }); // Verify both operations were undone - expect(testProject.getTracks()).toHaveLength(initialTrackCount) - const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion - expect(originalRegion.getNotes()).toHaveLength(0) - }) - }) + expect(testProject.getTracks()).toHaveLength(initialTrackCount); + const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion; + expect(originalRegion.getNotes()).toHaveLength(0); + }); + }); describe('Error Handling Integration', () => { it('should handle command execution errors gracefully', async () => { - const commandHistory = KGCommandHistory.instance() + const commandHistory = KGCommandHistory.instance(); // Try to create note in non-existent region - const invalidCommand = new CreateNoteCommand('invalid-region-id', 0, 1, 60, 100) + const invalidCommand = new CreateNoteCommand('invalid-region-id', 0, 1, 60, 100); // Execute command - should not throw but should not add to history act(() => { - commandHistory.executeCommand(invalidCommand) - }) + commandHistory.executeCommand(invalidCommand); + }); // Verify command was not added to history due to execution failure - expect(commandHistory.canUndo()).toBe(false) - expect(commandHistory.getUndoDescription()).toBeNull() + expect(commandHistory.canUndo()).toBe(false); + expect(commandHistory.getUndoDescription()).toBeNull(); // Verify project state unchanged - const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion - expect(region.getNotes()).toHaveLength(0) - }) + const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion; + expect(region.getNotes()).toHaveLength(0); + }); it('should handle undo failures gracefully', async () => { - const regionId = testRegion.getId() - const commandHistory = KGCommandHistory.instance() + const regionId = testRegion.getId(); + const commandHistory = KGCommandHistory.instance(); // Create a command that will succeed initially - const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) + const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100); act(() => { - commandHistory.executeCommand(createCommand) - }) + commandHistory.executeCommand(createCommand); + }); // Manually remove the region to cause undo to fail - testTrack.removeRegion(testRegion.getId()) + testTrack.removeRegion(testRegion.getId()); // Try to undo - should fail gracefully act(() => { - const undoSuccess = commandHistory.undo() - expect(undoSuccess).toBe(false) - }) + const undoSuccess = commandHistory.undo(); + expect(undoSuccess).toBe(false); + }); // Verify command is still in undo stack after failed undo - expect(commandHistory.canUndo()).toBe(true) - }) - }) + expect(commandHistory.canUndo()).toBe(true); + }); + }); describe('Store Integration', () => { it('should keep store undo/redo state synchronized with command history', async () => { - const regionId = testRegion.getId() - const commandHistory = KGCommandHistory.instance() - const { syncUndoRedoState } = useProjectStore.getState() + const regionId = testRegion.getId(); + const commandHistory = KGCommandHistory.instance(); + const { syncUndoRedoState } = useProjectStore.getState(); // Initial state - expect(useProjectStore.getState().canUndo).toBe(false) - expect(useProjectStore.getState().canRedo).toBe(false) + expect(useProjectStore.getState().canUndo).toBe(false); + expect(useProjectStore.getState().canRedo).toBe(false); // Execute command - const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) + const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100); act(() => { - commandHistory.executeCommand(createCommand) - syncUndoRedoState() // Simulate store sync - }) + commandHistory.executeCommand(createCommand); + syncUndoRedoState(); // Simulate store sync + }); // Verify store state updated - let storeState = useProjectStore.getState() - expect(storeState.canUndo).toBe(true) - expect(storeState.canRedo).toBe(false) - expect(storeState.undoDescription).toBe('Create note C4') - expect(storeState.redoDescription).toBeNull() + let storeState = useProjectStore.getState(); + expect(storeState.canUndo).toBe(true); + expect(storeState.canRedo).toBe(false); + expect(storeState.undoDescription).toBe('Create note C4'); + expect(storeState.redoDescription).toBeNull(); // Execute undo act(() => { - commandHistory.undo() - syncUndoRedoState() // Simulate store sync - }) + commandHistory.undo(); + syncUndoRedoState(); // Simulate store sync + }); // Verify store state updated after undo - storeState = useProjectStore.getState() - expect(storeState.canUndo).toBe(false) - expect(storeState.canRedo).toBe(true) - expect(storeState.undoDescription).toBeNull() - expect(storeState.redoDescription).toBe('Create note C4') - }) - }) -}) \ No newline at end of file + storeState = useProjectStore.getState(); + expect(storeState.canUndo).toBe(false); + expect(storeState.canRedo).toBe(true); + expect(storeState.undoDescription).toBeNull(); + expect(storeState.redoDescription).toBe('Create note C4'); + }); + }); +}); \ No newline at end of file diff --git a/src/test/integration/store/project-store-sync.integration.test.ts b/src/test/integration/store/project-store-sync.integration.test.ts index ac3113c..bec552c 100644 --- a/src/test/integration/store/project-store-sync.integration.test.ts +++ b/src/test/integration/store/project-store-sync.integration.test.ts @@ -2,481 +2,481 @@ * Integration tests for project store synchronization with core models * Tests the critical data flow: Store Actions → Core Models → UI State Updates */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' -import { act, renderHook } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; // Import core classes -import { KGCore } from '../../../core/KGCore' -import { KGProject } from '../../../core/KGProject' -import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack' -import { KGMidiRegion } from '../../../core/region/KGMidiRegion' -import { KGMidiNote } from '../../../core/midi/KGMidiNote' -import { KGMidiInput } from '../../../core/midi-input/KGMidiInput' +import { KGCore } from '../../../core/KGCore'; +import { KGProject } from '../../../core/KGProject'; +import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack'; +import { KGMidiRegion } from '../../../core/region/KGMidiRegion'; +import { KGMidiNote } from '../../../core/midi/KGMidiNote'; +import { KGMidiInput } from '../../../core/midi-input/KGMidiInput'; // Import store -import { useProjectStore } from '../../../stores/projectStore' +import { useProjectStore } from '../../../stores/projectStore'; // Import test utilities and mocks -import '../../utils/setup-integration-tests' -import { mockAudioInterface } from '../../mocks/audio-interface' +import '../../utils/setup-integration-tests'; +import { mockAudioInterface } from '../../mocks/audio-interface'; describe('Project Store Synchronization Integration Tests', () => { - let testProject: KGProject + let testProject: KGProject; beforeEach(async () => { // Create a real project for testing - testProject = new KGProject('Sync Test Project') - testProject.setBpm(120) - testProject.setTimeSignature({ numerator: 4, denominator: 4 }) - testProject.setKeySignature('C major') - testProject.setMaxBars(32) + testProject = new KGProject('Sync Test Project'); + testProject.setBpm(120); + testProject.setTimeSignature({ numerator: 4, denominator: 4 }); + testProject.setKeySignature('C major'); + testProject.setMaxBars(32); // Initialize KGCore - const core = KGCore.instance() - await core.initialize() - core.setCurrentProject(testProject) + const core = KGCore.instance(); + await core.initialize(); + core.setCurrentProject(testProject); // Reset store state - const store = useProjectStore.getState() - await store.loadProject(testProject) - }) + const store = useProjectStore.getState(); + await store.loadProject(testProject); + }); afterEach(() => { - vi.clearAllMocks() - }) + vi.clearAllMocks(); + }); describe('Project Properties Synchronization', () => { it('should sync BPM changes between store and core model', async () => { - const { setBpm } = useProjectStore.getState() - const newBpm = 140 + const { setBpm } = useProjectStore.getState(); + const newBpm = 140; // Execute store action act(() => { - setBpm(newBpm) - }) + setBpm(newBpm); + }); // Verify core model was updated - expect(testProject.getBpm()).toBe(newBpm) + expect(testProject.getBpm()).toBe(newBpm); // Verify store state reflects change - const storeState = useProjectStore.getState() - expect(storeState.bpm).toBe(newBpm) + const storeState = useProjectStore.getState(); + expect(storeState.bpm).toBe(newBpm); // Verify CSS custom property was updated - const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') - expect(cssValue).toBeTruthy() // CSS should be updated by store action - }) + const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator'); + expect(cssValue).toBeTruthy(); // CSS should be updated by store action + }); it('should sync time signature changes and update CSS properties', async () => { - const { setTimeSignature } = useProjectStore.getState() - const newTimeSignature = { numerator: 3, denominator: 4 } + const { setTimeSignature } = useProjectStore.getState(); + const newTimeSignature = { numerator: 3, denominator: 4 }; act(() => { - setTimeSignature(newTimeSignature) - }) + setTimeSignature(newTimeSignature); + }); // Verify core model was updated - expect(testProject.getTimeSignature()).toEqual(newTimeSignature) + expect(testProject.getTimeSignature()).toEqual(newTimeSignature); // Verify store state reflects change - const storeState = useProjectStore.getState() - expect(storeState.timeSignature).toEqual(newTimeSignature) + const storeState = useProjectStore.getState(); + expect(storeState.timeSignature).toEqual(newTimeSignature); // Verify CSS custom property was updated for UI calculations - const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') - expect(cssValue.trim()).toBe('3') - }) + const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator'); + expect(cssValue.trim()).toBe('3'); + }); it('should sync max bars changes and update layout CSS', async () => { - const { setMaxBars } = useProjectStore.getState() - const newMaxBars = 64 + const { setMaxBars } = useProjectStore.getState(); + const newMaxBars = 64; act(() => { - setMaxBars(newMaxBars) - }) + setMaxBars(newMaxBars); + }); // Verify core model was updated - expect(testProject.getMaxBars()).toBe(newMaxBars) + expect(testProject.getMaxBars()).toBe(newMaxBars); // Verify store state reflects change - const storeState = useProjectStore.getState() - expect(storeState.maxBars).toBe(newMaxBars) + const storeState = useProjectStore.getState(); + expect(storeState.maxBars).toBe(newMaxBars); // Verify CSS custom property was updated for layout - const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars') - expect(cssValue.trim()).toBe('64') - }) + const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars'); + expect(cssValue.trim()).toBe('64'); + }); it('should sync key signature changes', async () => { - const { setKeySignature } = useProjectStore.getState() - const newKeySignature = 'G major' + const { setKeySignature } = useProjectStore.getState(); + const newKeySignature = 'G major'; act(() => { - setKeySignature(newKeySignature) - }) + setKeySignature(newKeySignature); + }); // Verify core model was updated - expect(testProject.getKeySignature()).toBe(newKeySignature) + expect(testProject.getKeySignature()).toBe(newKeySignature); // Verify store state reflects change - const storeState = useProjectStore.getState() - expect(storeState.keySignature).toBe(newKeySignature) - }) - }) + const storeState = useProjectStore.getState(); + expect(storeState.keySignature).toBe(newKeySignature); + }); + }); describe('Track Management Synchronization', () => { it('should sync track addition with core model and audio interface', async () => { - const { addTrack } = useProjectStore.getState() - const initialTrackCount = testProject.getTracks().length + const { addTrack } = useProjectStore.getState(); + const initialTrackCount = testProject.getTracks().length; // Execute store action await act(async () => { - await addTrack() - }) + await addTrack(); + }); // Verify core model was updated - expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1) - const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack - expect(newTrack).toBeInstanceOf(KGMidiTrack) - expect(newTrack.getName()).toContain('Track') + expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1); + const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack; + expect(newTrack).toBeInstanceOf(KGMidiTrack); + expect(newTrack.getName()).toContain('Track'); // Verify store state reflects change - const storeState = useProjectStore.getState() - expect(storeState.tracks).toHaveLength(initialTrackCount + 1) + const storeState = useProjectStore.getState(); + expect(storeState.tracks).toHaveLength(initialTrackCount + 1); // Verify audio interface was notified - expect(mockAudioInterface.createTrackBus).toHaveBeenCalled() - }) + expect(mockAudioInterface.createTrackBus).toHaveBeenCalled(); + }); it('should sync track removal with core model and audio interface', async () => { // First add a track - const { addTrack, removeTrack } = useProjectStore.getState() + const { addTrack, removeTrack } = useProjectStore.getState(); await act(async () => { - await addTrack() - }) + await addTrack(); + }); - const trackCountAfterAdd = testProject.getTracks().length - const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1] + const trackCountAfterAdd = testProject.getTracks().length; + const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1]; // Remove the track await act(async () => { - await removeTrack(trackCountAfterAdd - 1) // Remove last track - }) + await removeTrack(trackCountAfterAdd - 1); // Remove last track + }); // Verify core model was updated - expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1) + expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1); // Verify store state reflects change - const storeState = useProjectStore.getState() - expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1) + const storeState = useProjectStore.getState(); + expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1); // Verify audio interface was notified - expect(mockAudioInterface.removeTrackBus).toHaveBeenCalledWith(trackToRemove.getId()) - }) + expect(mockAudioInterface.removeTrackBus).toHaveBeenCalledWith(trackToRemove.getId()); + }); it('should sync track instrument changes with audio interface', async () => { // Add a track first - const { addTrack, setTrackInstrument } = useProjectStore.getState() + const { addTrack, setTrackInstrument } = useProjectStore.getState(); await act(async () => { - await addTrack() - }) + await addTrack(); + }); - const trackIndex = testProject.getTracks().length - 1 - const track = testProject.getTracks()[trackIndex] as KGMidiTrack - const newInstrument: InstrumentType = 'electric_bass' + const trackIndex = testProject.getTracks().length - 1; + const track = testProject.getTracks()[trackIndex] as KGMidiTrack; + const newInstrument: InstrumentType = 'electric_bass'; // Change track instrument await act(async () => { - await setTrackInstrument(trackIndex, newInstrument) - }) + await setTrackInstrument(trackIndex, newInstrument); + }); // Verify core model was updated - expect(track.getInstrument()).toBe(newInstrument) + expect(track.getInstrument()).toBe(newInstrument); // Verify store state reflects change - const storeState = useProjectStore.getState() - const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack - expect(storeTrack.getInstrument()).toBe(newInstrument) + const storeState = useProjectStore.getState(); + const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack; + expect(storeTrack.getInstrument()).toBe(newInstrument); // Verify audio interface was notified - expect(mockAudioInterface.setTrackInstrument).toHaveBeenCalledWith(track.getId(), newInstrument) - }) + expect(mockAudioInterface.setTrackInstrument).toHaveBeenCalledWith(track.getId(), newInstrument); + }); it('should sync track reordering with core model', async () => { - const { addTrack, reorderTracks } = useProjectStore.getState() + const { addTrack, reorderTracks } = useProjectStore.getState(); // Add two tracks await act(async () => { - await addTrack() // Track at index 0 - await addTrack() // Track at index 1 - }) + await addTrack(); // Track at index 0 + await addTrack(); // Track at index 1 + }); - const track0Before = testProject.getTracks()[0] - const track1Before = testProject.getTracks()[1] + const track0Before = testProject.getTracks()[0]; + const track1Before = testProject.getTracks()[1]; // Reorder tracks (move track 0 to position 1) act(() => { - reorderTracks(0, 1) - }) + reorderTracks(0, 1); + }); // Verify core model track order changed - const track0After = testProject.getTracks()[0] - const track1After = testProject.getTracks()[1] + const track0After = testProject.getTracks()[0]; + const track1After = testProject.getTracks()[1]; - expect(track0After.getId()).toBe(track1Before.getId()) - expect(track1After.getId()).toBe(track0Before.getId()) + expect(track0After.getId()).toBe(track1Before.getId()); + expect(track1After.getId()).toBe(track0Before.getId()); // Verify store state reflects change - const storeState = useProjectStore.getState() - expect(storeState.tracks[0].getId()).toBe(track1Before.getId()) - expect(storeState.tracks[1].getId()).toBe(track0Before.getId()) - }) - }) + const storeState = useProjectStore.getState(); + expect(storeState.tracks[0].getId()).toBe(track1Before.getId()); + expect(storeState.tracks[1].getId()).toBe(track0Before.getId()); + }); + }); describe('Playback State Synchronization', () => { it('should sync playhead position with formatted time string', async () => { - const { setPlayheadPosition } = useProjectStore.getState() - const newPosition = 8.5 // beats + const { setPlayheadPosition } = useProjectStore.getState(); + const newPosition = 8.5; // beats act(() => { - setPlayheadPosition(newPosition) - }) + setPlayheadPosition(newPosition); + }); // Verify store state updated - const storeState = useProjectStore.getState() - expect(storeState.playheadPosition).toBe(newPosition) + const storeState = useProjectStore.getState(); + expect(storeState.playheadPosition).toBe(newPosition); // Verify formatted time string was updated - expect(storeState.currentTime).toBeDefined() - expect(storeState.currentTime).toContain('|') // Should contain BBB:B | mm:ss:mmm format - }) + expect(storeState.currentTime).toBeDefined(); + expect(storeState.currentTime).toContain('|'); // Should contain BBB:B | mm:ss:mmm format + }); it('should sync playback state changes', async () => { - const { startPlaying, stopPlaying } = useProjectStore.getState() + const { startPlaying, stopPlaying } = useProjectStore.getState(); // Start playing await act(async () => { - await startPlaying() - }) + await startPlaying(); + }); // Verify store state updated - let storeState = useProjectStore.getState() - expect(storeState.isPlaying).toBe(true) + let storeState = useProjectStore.getState(); + expect(storeState.isPlaying).toBe(true); // Verify audio interface was called - expect(mockAudioInterface.startPlayback).toHaveBeenCalled() + expect(mockAudioInterface.startPlayback).toHaveBeenCalled(); // Stop playing await act(async () => { - await stopPlaying() - }) + await stopPlaying(); + }); // Verify store state updated - storeState = useProjectStore.getState() - expect(storeState.isPlaying).toBe(false) + storeState = useProjectStore.getState(); + expect(storeState.isPlaying).toBe(false); // Verify audio interface was called - expect(mockAudioInterface.stopPlayback).toHaveBeenCalled() - }) + expect(mockAudioInterface.stopPlayback).toHaveBeenCalled(); + }); it('should start looped recording one bar before the loop start on the first pass', async () => { - const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano') - const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16) - testTrack.addRegion(testRegion) - testProject.setTracks([testTrack]) - testProject.setIsLooping(true) - testProject.setLoopingRange([4, 7]) + const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano'); + const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16); + testTrack.addRegion(testRegion); + testProject.setTracks([testTrack]); + testProject.setIsLooping(true); + testProject.setLoopingRange([4, 7]); await act(async () => { - await useProjectStore.getState().loadProject(testProject) - }) + await useProjectStore.getState().loadProject(testProject); + }); - const core = KGCore.instance() - const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined) - const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks') - const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState() + const core = KGCore.instance(); + const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined); + const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks'); + const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState(); act(() => { - setActiveRegionId(testRegion.getId()) - setPlayheadPosition(18) - }) + setActiveRegionId(testRegion.getId()); + setPlayheadPosition(18); + }); await act(async () => { - await startRecording() - }) + await startRecording(); + }); - const storeState = useProjectStore.getState() - expect(storeState.playheadPosition).toBe(12) - expect(storeState.recordingOriginalPlayhead).toBe(18) - expect(storeState.isRecording).toBe(true) - expect(storeState.isPlaying).toBe(true) - expect(recordingCallbacksSpy).toHaveBeenCalled() - expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true }) - }) - }) + const storeState = useProjectStore.getState(); + expect(storeState.playheadPosition).toBe(12); + expect(storeState.recordingOriginalPlayhead).toBe(18); + expect(storeState.isRecording).toBe(true); + expect(storeState.isPlaying).toBe(true); + expect(recordingCallbacksSpy).toHaveBeenCalled(); + expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true }); + }); + }); describe('Selection State Synchronization', () => { it('should sync selection state with core piano roll state', async () => { - const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState() + const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState(); // Add a track and region for testing - const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano') - const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16) - testTrack.addRegion(testRegion) - testProject.setTracks([...testProject.getTracks(), testTrack]) + const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano'); + const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16); + testTrack.addRegion(testRegion); + testProject.setTracks([...testProject.getTracks(), testTrack]); // Set active region act(() => { - setActiveRegionId(testRegion.getId()) - }) + setActiveRegionId(testRegion.getId()); + }); // Verify store state updated - let storeState = useProjectStore.getState() - expect(storeState.activeRegionId).toBe(testRegion.getId()) + let storeState = useProjectStore.getState(); + expect(storeState.activeRegionId).toBe(testRegion.getId()); // Simulate core selection changes and sync act(() => { - syncSelectionFromCore() - }) + syncSelectionFromCore(); + }); // Verify store selection state is synchronized - storeState = useProjectStore.getState() - expect(storeState.selectedNoteIds).toBeDefined() - expect(storeState.selectedRegionIds).toBeDefined() - }) + storeState = useProjectStore.getState(); + expect(storeState.selectedNoteIds).toBeDefined(); + expect(storeState.selectedRegionIds).toBeDefined(); + }); it('should clear all selections and sync state', async () => { - const { clearAllSelections, setSelectedTrack } = useProjectStore.getState() + const { clearAllSelections, setSelectedTrack } = useProjectStore.getState(); // Set some initial selection state act(() => { - setSelectedTrack('test-track-id') - }) + setSelectedTrack('test-track-id'); + }); // Verify selection was set - let storeState = useProjectStore.getState() - expect(storeState.selectedTrackId).toBe('test-track-id') + let storeState = useProjectStore.getState(); + expect(storeState.selectedTrackId).toBe('test-track-id'); // Clear all selections act(() => { - clearAllSelections() - }) + clearAllSelections(); + }); // Verify all selections were cleared - storeState = useProjectStore.getState() - expect(storeState.selectedTrackId).toBeNull() - expect(storeState.selectedNoteIds).toEqual([]) - expect(storeState.selectedRegionIds).toEqual([]) - }) - }) + storeState = useProjectStore.getState(); + expect(storeState.selectedTrackId).toBeNull(); + expect(storeState.selectedNoteIds).toEqual([]); + expect(storeState.selectedRegionIds).toEqual([]); + }); + }); describe('Piano Roll State Integration', () => { it('should sync piano roll visibility and active region', async () => { - const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState() + const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState(); // Add test region - const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano') - const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16) - testTrack.addRegion(testRegion) - testProject.setTracks([...testProject.getTracks(), testTrack]) + const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano'); + const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16); + testTrack.addRegion(testRegion); + testProject.setTracks([...testProject.getTracks(), testTrack]); // Show piano roll with active region act(() => { - setActiveRegionId(testRegion.getId()) - setShowPianoRoll(true) - }) + setActiveRegionId(testRegion.getId()); + setShowPianoRoll(true); + }); // Verify store state updated - const storeState = useProjectStore.getState() - expect(storeState.showPianoRoll).toBe(true) - expect(storeState.activeRegionId).toBe(testRegion.getId()) - }) - }) + const storeState = useProjectStore.getState(); + expect(storeState.showPianoRoll).toBe(true); + expect(storeState.activeRegionId).toBe(testRegion.getId()); + }); + }); describe('Error Handling and Edge Cases', () => { it('should handle invalid track operations gracefully', async () => { - const { removeTrack } = useProjectStore.getState() - const initialTrackCount = testProject.getTracks().length + const { removeTrack } = useProjectStore.getState(); + const initialTrackCount = testProject.getTracks().length; // Try to remove non-existent track await act(async () => { - await removeTrack(999) // Invalid index - }) + await removeTrack(999); // Invalid index + }); // Verify project state unchanged - expect(testProject.getTracks()).toHaveLength(initialTrackCount) + expect(testProject.getTracks()).toHaveLength(initialTrackCount); // Verify store state unchanged - const storeState = useProjectStore.getState() - expect(storeState.tracks).toHaveLength(initialTrackCount) - }) + const storeState = useProjectStore.getState(); + expect(storeState.tracks).toHaveLength(initialTrackCount); + }); it('should handle concurrent state updates correctly', async () => { - const { setBpm, setMaxBars } = useProjectStore.getState() + const { setBpm, setMaxBars } = useProjectStore.getState(); // Execute multiple state updates concurrently await act(async () => { - setBpm(140) - setMaxBars(64) - }) + setBpm(140); + setMaxBars(64); + }); // Verify both updates were applied to core model - expect(testProject.getBpm()).toBe(140) - expect(testProject.getMaxBars()).toBe(64) + expect(testProject.getBpm()).toBe(140); + expect(testProject.getMaxBars()).toBe(64); // Verify store state is consistent - const storeState = useProjectStore.getState() - expect(storeState.bpm).toBe(140) - expect(storeState.maxBars).toBe(64) - }) - }) + const storeState = useProjectStore.getState(); + expect(storeState.bpm).toBe(140); + expect(storeState.maxBars).toBe(64); + }); + }); describe('Project Loading Integration', () => { it('should completely sync store state when loading new project', async () => { - const { loadProject } = useProjectStore.getState() + const { loadProject } = useProjectStore.getState(); // Create a new project with specific properties - const newProject = new KGProject('New Loaded Project') - newProject.setBpm(160) - newProject.setTimeSignature({ numerator: 6, denominator: 8 }) - newProject.setKeySignature('D major') - newProject.setMaxBars(48) + const newProject = new KGProject('New Loaded Project'); + newProject.setBpm(160); + newProject.setTimeSignature({ numerator: 6, denominator: 8 }); + newProject.setKeySignature('D major'); + newProject.setMaxBars(48); // Add a track with region and notes - const track = new KGMidiTrack('Loaded Track', 0, 'violin') - const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8) - const note = new KGMidiNote('test-note', 0, 1, 64, 100) + const track = new KGMidiTrack('Loaded Track', 0, 'violin'); + const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8); + const note = new KGMidiNote('test-note', 0, 1, 64, 100); - region.addNote(note) - track.addRegion(region) - newProject.setTracks([track]) + region.addNote(note); + track.addRegion(region); + newProject.setTracks([track]); // Load the new project await act(async () => { - await loadProject(newProject) - }) + await loadProject(newProject); + }); // Verify store state completely matches new project - const storeState = useProjectStore.getState() - expect(storeState.projectName).toBe('New Loaded Project') - expect(storeState.bpm).toBe(160) - expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 }) - expect(storeState.keySignature).toBe('D major') - expect(storeState.maxBars).toBe(48) - expect(storeState.tracks).toHaveLength(1) + const storeState = useProjectStore.getState(); + expect(storeState.projectName).toBe('New Loaded Project'); + expect(storeState.bpm).toBe(160); + expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 }); + expect(storeState.keySignature).toBe('D major'); + expect(storeState.maxBars).toBe(48); + expect(storeState.tracks).toHaveLength(1); // Verify core model is updated - const core = KGCore.instance() - expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project') + const core = KGCore.instance(); + expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project'); // Verify CSS properties were updated - const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') - expect(timeSignatureCSS.trim()).toBe('6') + const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator'); + expect(timeSignatureCSS.trim()).toBe('6'); - const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars') - expect(maxBarsCSS.trim()).toBe('48') - }) - }) -}) + const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars'); + expect(maxBarsCSS.trim()).toBe('48'); + }); + }); +}); diff --git a/src/test/integration/utils/abc-notation.integration.test.ts b/src/test/integration/utils/abc-notation.integration.test.ts index 4525500..c20ec54 100644 --- a/src/test/integration/utils/abc-notation.integration.test.ts +++ b/src/test/integration/utils/abc-notation.integration.test.ts @@ -1,62 +1,62 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { plainToInstance } from 'class-transformer' -import { convertRegionToABCNotation } from '../../../util/abcNotationUtil' -import { KGMidiRegion } from '../../../core/region/KGMidiRegion' -import { KGMidiTrack } from '../../../core/track/KGMidiTrack' -import { KGCore } from '../../../core/KGCore' -import { KGProject } from '../../../core/KGProject' +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import { convertRegionToABCNotation } from '../../../util/abcNotationUtil'; +import { KGMidiRegion } from '../../../core/region/KGMidiRegion'; +import { KGMidiTrack } from '../../../core/track/KGMidiTrack'; +import { KGCore } from '../../../core/KGCore'; +import { KGProject } from '../../../core/KGProject'; // Import the test fixture -import joyProjectData from '../../fixtures/joy-project.json' +import joyProjectData from '../../fixtures/joy-project.json'; // Helper function to load project using real class-transformer deserialization (same as UI) function loadProjectFromJSON(projectData: Record): KGProject { // Use the exact same deserialization process as the UI (Toolbar.tsx handleKGStudioJSONImport) - const deserializedResult = plainToInstance(KGProject, projectData) + const deserializedResult = plainToInstance(KGProject, projectData); // Handle case where plainToInstance might return an array (same as UI) const deserializedProject = Array.isArray(deserializedResult) ? deserializedResult[0] || null - : deserializedResult + : deserializedResult; if (!deserializedProject) { - throw new Error("Failed to deserialize project data") + throw new Error("Failed to deserialize project data"); } - return deserializedProject + return deserializedProject; } describe('abcNotationUtil - Integration Tests with Real Project Data', () => { - let joyProject: KGProject + let joyProject: KGProject; beforeEach(() => { // Simulate the exact same process as Toolbar.tsx handleKGStudioJSONImport // First parse as JSON (simulating file.text() -> JSON.parse()) - const fileContent = JSON.stringify(joyProjectData) - const projectData = JSON.parse(fileContent) + const fileContent = JSON.stringify(joyProjectData); + const projectData = JSON.parse(fileContent); // Then deserialize using class-transformer (same as UI) - joyProject = loadProjectFromJSON(projectData) + joyProject = loadProjectFromJSON(projectData); // Mock KGCore to return the loaded project (same as production flow) vi.spyOn(KGCore, 'instance').mockReturnValue({ getCurrentProject: () => joyProject - } as unknown as KGCore) - }) + } as unknown as KGCore); + }); describe('convertRegionToABCNotation with real project data', () => { it('should convert joy project melody region to exact ABC notation', () => { // Get the melody track and region using real project structure - const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack - expect(melodyTrack.getName()).toBe('Melody') - expect(melodyTrack.getRegions()).toHaveLength(1) + const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack; + expect(melodyTrack.getName()).toBe('Melody'); + expect(melodyTrack.getRegions()).toHaveLength(1); - const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion - expect(melodyRegion.getName()).toBe('Melody Region 1') - expect(melodyRegion.getNotes()).toHaveLength(30) // Verify we have all 30 notes + const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion; + expect(melodyRegion.getName()).toBe('Melody Region 1'); + expect(melodyRegion.getNotes()).toHaveLength(30); // Verify we have all 30 notes // Convert to ABC notation using real region data - const result = convertRegionToABCNotation(melodyRegion, 0, 32) + const result = convertRegionToABCNotation(melodyRegion, 0, 32); // Expected ABC notation output (exactly as provided) const expectedABCNotation = `X:1 @@ -65,161 +65,161 @@ M:4/4 L:1/4 Q:1/4=125 K:C -E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2 C1/2 C2 |` +E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2 C1/2 C2 |`; // Verify exact match - expect(result).toBe(expectedABCNotation) - }) + expect(result).toBe(expectedABCNotation); + }); it('should handle empty pad chord region from real project', () => { // Get the pad chord track and region - const padTrack = joyProject.getTracks()[1] as KGMidiTrack - expect(padTrack.getName()).toBe('Pad Chord') - expect(padTrack.getInstrument()).toBe('pad_1_new_age') + const padTrack = joyProject.getTracks()[1] as KGMidiTrack; + expect(padTrack.getName()).toBe('Pad Chord'); + expect(padTrack.getInstrument()).toBe('pad_1_new_age'); - const padRegion = padTrack.getRegions()[0] as KGMidiRegion - expect(padRegion.getName()).toBe('Pad Chord Region 1') - expect(padRegion.getNotes()).toHaveLength(0) // Empty region + const padRegion = padTrack.getRegions()[0] as KGMidiRegion; + expect(padRegion.getName()).toBe('Pad Chord Region 1'); + expect(padRegion.getNotes()).toHaveLength(0); // Empty region // Convert empty region to ABC notation - const result = convertRegionToABCNotation(padRegion, 0, 32) + const result = convertRegionToABCNotation(padRegion, 0, 32); // Verify it contains rest notation and proper headers - expect(result).toBeDefined() - expect(result).toContain('T:Pad Chord Region 1') - expect(result).toContain('M:4/4') - expect(result).toContain('Q:1/4=125') - expect(result).toContain('K:C') - expect(result).toContain('z') // Should contain rest notation - }) + expect(result).toBeDefined(); + expect(result).toContain('T:Pad Chord Region 1'); + expect(result).toContain('M:4/4'); + expect(result).toContain('Q:1/4=125'); + expect(result).toContain('K:C'); + expect(result).toContain('z'); // Should contain rest notation + }); it('should use correct project settings from real project data', () => { // Verify project settings are loaded correctly - expect(joyProject.getName()).toBe('joy') - expect(joyProject.getBpm()).toBe(125) - expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 }) - expect(joyProject.getKeySignature()).toBe('C major') - expect(joyProject.getMaxBars()).toBe(32) + expect(joyProject.getName()).toBe('joy'); + expect(joyProject.getBpm()).toBe(125); + expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 }); + expect(joyProject.getKeySignature()).toBe('C major'); + expect(joyProject.getMaxBars()).toBe(32); // These settings should be reflected in ABC notation headers - const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack - const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion - const result = convertRegionToABCNotation(melodyRegion, 0, 32) + const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack; + const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion; + const result = convertRegionToABCNotation(melodyRegion, 0, 32); - expect(result).toContain('Q:1/4=125') // BPM - expect(result).toContain('M:4/4') // Time signature - expect(result).toContain('K:C') // Key signature - }) + expect(result).toContain('Q:1/4=125'); // BPM + expect(result).toContain('M:4/4'); // Time signature + expect(result).toContain('K:C'); // Key signature + }); it('should handle real note timing and pitches correctly', () => { - const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack - const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion - const notes = melodyRegion.getNotes() + const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack; + const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion; + const notes = melodyRegion.getNotes(); // Verify some key notes from the real data - expect(notes[0].getPitch()).toBe(64) // First note is E (64) - expect(notes[0].getStartBeat()).toBe(0) - expect(notes[0].getEndBeat()).toBe(1) + expect(notes[0].getPitch()).toBe(64); // First note is E (64) + expect(notes[0].getStartBeat()).toBe(0); + expect(notes[0].getEndBeat()).toBe(1); - expect(notes[3].getPitch()).toBe(67) // Fourth note is G (67) - expect(notes[3].getStartBeat()).toBe(3) - expect(notes[3].getEndBeat()).toBe(4) + expect(notes[3].getPitch()).toBe(67); // Fourth note is G (67) + expect(notes[3].getStartBeat()).toBe(3); + expect(notes[3].getEndBeat()).toBe(4); // Verify fractional timing note (beat 12-13.5) - const fractionalNote = notes.find(note => note.getEndBeat() === 13.5) - expect(fractionalNote).toBeDefined() - expect(fractionalNote!.getPitch()).toBe(64) // E - expect(fractionalNote!.getStartBeat()).toBe(12) + const fractionalNote = notes.find(note => note.getEndBeat() === 13.5); + expect(fractionalNote).toBeDefined(); + expect(fractionalNote!.getPitch()).toBe(64); // E + expect(fractionalNote!.getStartBeat()).toBe(12); // Convert and verify these real timings are reflected in ABC notation - const result = convertRegionToABCNotation(melodyRegion, 0, 32) - expect(result).toContain('E3/2 D1/2') // Fractional timing should appear in ABC - }) + const result = convertRegionToABCNotation(melodyRegion, 0, 32); + expect(result).toContain('E3/2 D1/2'); // Fractional timing should appear in ABC + }); it('should handle multiple tracks with different instruments', () => { - const tracks = joyProject.getTracks() - expect(tracks).toHaveLength(2) + const tracks = joyProject.getTracks(); + expect(tracks).toHaveLength(2); // Verify track properties from real project - const melodyTrack = tracks[0] as KGMidiTrack - expect(melodyTrack.getName()).toBe('Melody') - expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano') - expect(melodyTrack.getVolume()).toBe(0.8) + const melodyTrack = tracks[0] as KGMidiTrack; + expect(melodyTrack.getName()).toBe('Melody'); + expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano'); + expect(melodyTrack.getVolume()).toBe(0.8); - const padTrack = tracks[1] as KGMidiTrack - expect(padTrack.getName()).toBe('Pad Chord') - expect(padTrack.getInstrument()).toBe('pad_1_new_age') - expect(padTrack.getVolume()).toBe(1) + const padTrack = tracks[1] as KGMidiTrack; + expect(padTrack.getName()).toBe('Pad Chord'); + expect(padTrack.getInstrument()).toBe('pad_1_new_age'); + expect(padTrack.getVolume()).toBe(1); // Both tracks should be processable for ABC notation - const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion - const padRegion = padTrack.getRegions()[0] as KGMidiRegion + const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion; + const padRegion = padTrack.getRegions()[0] as KGMidiRegion; - const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32) - const padABC = convertRegionToABCNotation(padRegion, 0, 32) + const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32); + const padABC = convertRegionToABCNotation(padRegion, 0, 32); - expect(melodyABC).toContain('T:Melody Region 1') - expect(padABC).toContain('T:Pad Chord Region 1') - }) + expect(melodyABC).toContain('T:Melody Region 1'); + expect(padABC).toContain('T:Pad Chord Region 1'); + }); it('should verify complete deserialization hierarchy', () => { // Test that class-transformer properly restored the entire object hierarchy - expect(joyProject).toBeInstanceOf(KGProject) + expect(joyProject).toBeInstanceOf(KGProject); - const tracks = joyProject.getTracks() + const tracks = joyProject.getTracks(); tracks.forEach(track => { - expect(track).toBeInstanceOf(KGMidiTrack) + expect(track).toBeInstanceOf(KGMidiTrack); - const regions = track.getRegions() + const regions = track.getRegions(); regions.forEach(region => { - expect(region).toBeInstanceOf(KGMidiRegion) + expect(region).toBeInstanceOf(KGMidiRegion); - const notes = (region as KGMidiRegion).getNotes() + const notes = (region as KGMidiRegion).getNotes(); notes.forEach(note => { // Notes should have proper methods and properties - expect(typeof note.getId()).toBe('string') - expect(typeof note.getPitch()).toBe('number') - expect(typeof note.getStartBeat()).toBe('number') - expect(typeof note.getEndBeat()).toBe('number') - expect(typeof note.getVelocity()).toBe('number') - }) - }) - }) + expect(typeof note.getId()).toBe('string'); + expect(typeof note.getPitch()).toBe('number'); + expect(typeof note.getStartBeat()).toBe('number'); + expect(typeof note.getEndBeat()).toBe('number'); + expect(typeof note.getVelocity()).toBe('number'); + }); + }); + }); // Verify type identifiers are preserved tracks.forEach(track => { - expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack') - }) - }) - }) + expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack'); + }); + }); + }); describe('edge cases with real project data', () => { it('should handle partial region conversion', () => { - const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack - const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion + const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack; + const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion; // Test converting only first 8 beats (2 bars) - const result = convertRegionToABCNotation(melodyRegion, 0, 8) + const result = convertRegionToABCNotation(melodyRegion, 0, 8); - expect(result).toBeDefined() - expect(result).toContain('T:Melody Region 1') + expect(result).toBeDefined(); + expect(result).toContain('T:Melody Region 1'); // Should only contain the first 2 bars of music - expect(result).not.toContain('D3/2 C1/2 C2') // This appears later in the song - }) + expect(result).not.toContain('D3/2 C1/2 C2'); // This appears later in the song + }); it('should handle mid-region start point', () => { - const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack - const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion + const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack; + const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion; // Test converting from beat 16 to 24 (second half of melody) - const result = convertRegionToABCNotation(melodyRegion, 16, 24) + const result = convertRegionToABCNotation(melodyRegion, 16, 24); - expect(result).toBeDefined() - expect(result).toContain('T:Melody Region 1') + expect(result).toBeDefined(); + expect(result).toContain('T:Melody Region 1'); // Should start from the second repetition - const lines = result.split('\n') - const musicLine = lines[lines.length - 1] // Last line contains the music - expect(musicLine).toBeTruthy() - }) - }) -}) \ No newline at end of file + const lines = result.split('\n'); + const musicLine = lines[lines.length - 1]; // Last line contains the music + expect(musicLine).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/test/mocks/audio-interface.ts b/src/test/mocks/audio-interface.ts index e780d81..29f822d 100644 --- a/src/test/mocks/audio-interface.ts +++ b/src/test/mocks/audio-interface.ts @@ -2,7 +2,7 @@ * Mock implementation of KGAudioInterface for integration tests * Provides interface compatibility while avoiding actual audio operations */ -import { vi } from 'vitest' +import { vi } from 'vitest'; export const mockAudioInterface = { // Audio context management @@ -34,7 +34,7 @@ export const mockAudioInterface = { // Singleton pattern getInstance: vi.fn().mockReturnThis(), -} +}; // Mock the class constructor -export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface) \ No newline at end of file +export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface); \ No newline at end of file diff --git a/src/test/mocks/indexed-db.ts b/src/test/mocks/indexed-db.ts index dafdec1..606a102 100644 --- a/src/test/mocks/indexed-db.ts +++ b/src/test/mocks/indexed-db.ts @@ -2,54 +2,54 @@ * Mock implementation of IndexedDB for integration tests * Provides in-memory storage that mimics IndexedDB interface */ -import { vi } from 'vitest' +import { vi } from 'vitest'; // In-memory storage for tests -const mockStorage = new Map() +const mockStorage = new Map(); export const mockIndexedDB = { openDB: vi.fn().mockImplementation(() => { return Promise.resolve({ put: vi.fn().mockImplementation((storeName: string, data: any, key?: string) => { - const actualKey = key || data.id || 'default' - mockStorage.set(`${storeName}:${actualKey}`, data) - return Promise.resolve(actualKey) + const actualKey = key || data.id || 'default'; + mockStorage.set(`${storeName}:${actualKey}`, data); + return Promise.resolve(actualKey); }), get: vi.fn().mockImplementation((storeName: string, key: string) => { - return Promise.resolve(mockStorage.get(`${storeName}:${key}`)) + return Promise.resolve(mockStorage.get(`${storeName}:${key}`)); }), getAll: vi.fn().mockImplementation((storeName: string) => { - const results: any[] = [] + const results: any[] = []; for (const [key, value] of mockStorage.entries()) { if (key.startsWith(`${storeName}:`)) { - results.push(value) + results.push(value); } } - return Promise.resolve(results) + return Promise.resolve(results); }), delete: vi.fn().mockImplementation((storeName: string, key: string) => { - mockStorage.delete(`${storeName}:${key}`) - return Promise.resolve() + mockStorage.delete(`${storeName}:${key}`); + return Promise.resolve(); }), clear: vi.fn().mockImplementation((storeName: string) => { for (const key of mockStorage.keys()) { if (key.startsWith(`${storeName}:`)) { - mockStorage.delete(key) + mockStorage.delete(key); } } - return Promise.resolve() + return Promise.resolve(); }), close: vi.fn().mockResolvedValue(undefined), - }) + }); }), -} +}; // Helper function to clear mock storage between tests export const clearMockStorage = () => { - mockStorage.clear() -} \ No newline at end of file + mockStorage.clear(); +}; \ No newline at end of file diff --git a/src/test/mocks/tone-js.ts b/src/test/mocks/tone-js.ts index 4ca06a6..13f50b5 100644 --- a/src/test/mocks/tone-js.ts +++ b/src/test/mocks/tone-js.ts @@ -2,7 +2,7 @@ * Mock implementation of Tone.js for integration tests * Provides interface compatibility while avoiding actual audio operations */ -import { vi } from 'vitest' +import { vi } from 'vitest'; // Mock Sampler class export const mockSampler = { @@ -16,7 +16,7 @@ export const mockSampler = { disconnect: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(), get: vi.fn().mockReturnValue({}), -} +}; // Mock Transport export const mockTransport = { @@ -30,7 +30,7 @@ export const mockTransport = { schedule: vi.fn(), clear: vi.fn(), cancel: vi.fn(), -} +}; // Mock Tone namespace export const mockTone = { @@ -55,4 +55,4 @@ export const mockTone = { state: 'running', resume: vi.fn().mockResolvedValue(undefined), }, -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts index 53a7e27..94fd3b2 100644 --- a/src/test/mocks/tone.ts +++ b/src/test/mocks/tone.ts @@ -1,4 +1,4 @@ -import { vi } from 'vitest' +import { vi } from 'vitest'; /** * Mock implementation of Tone.js for testing @@ -18,7 +18,7 @@ export const MockSampler = vi.fn().mockImplementation(() => ({ connect: vi.fn(), disconnect: vi.fn(), toDestination: vi.fn() -})) +})); // Mock Transport object export const MockTransport = { @@ -42,14 +42,14 @@ export const MockTransport = { loop: false, PPQ: 192, getTicksAtTime: vi.fn().mockImplementation((time: number) => time * 192) -} +}; export const MockLoop = vi.fn().mockImplementation((callback: (time: number) => void, interval: string) => ({ callback, interval, start: vi.fn(), dispose: vi.fn() -})) +})); // Mock Destination export const MockDestination = { @@ -59,7 +59,7 @@ export const MockDestination = { mute: false, connect: vi.fn(), disconnect: vi.fn() -} +}; // Mock ToneAudioBuffer export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({ @@ -68,7 +68,7 @@ export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({ get: vi.fn(), set: vi.fn(), load: vi.fn().mockResolvedValue(undefined) -})) +})); // Mock Gain node export const MockGain = vi.fn().mockImplementation(() => ({ @@ -81,7 +81,7 @@ export const MockGain = vi.fn().mockImplementation(() => ({ connect: vi.fn(), disconnect: vi.fn(), dispose: vi.fn() -})) +})); // Mock Meter export const MockMeter = vi.fn().mockImplementation(() => ({ @@ -89,7 +89,7 @@ export const MockMeter = vi.fn().mockImplementation(() => ({ connect: vi.fn(), disconnect: vi.fn(), dispose: vi.fn() -})) +})); // Complete Tone.js mock export const ToneMock = { @@ -110,10 +110,10 @@ export const ToneMock = { close: vi.fn().mockResolvedValue(undefined), lookAhead: 0.05, setTimeout: vi.fn().mockImplementation((fn: () => void, timeoutSeconds: number) => { - return window.setTimeout(fn, timeoutSeconds * 1000) + return window.setTimeout(fn, timeoutSeconds * 1000); }), clearTimeout: vi.fn().mockImplementation((id: number) => { - window.clearTimeout(id) + window.clearTimeout(id); }) }), @@ -129,9 +129,9 @@ export const ToneMock = { valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440) })), now: vi.fn().mockImplementation(() => Date.now() / 1000) -} +}; // Setup the global mock export const setupToneMocks = () => { - vi.doMock('tone', () => ToneMock) -} + vi.doMock('tone', () => ToneMock); +}; diff --git a/src/test/setup.ts b/src/test/setup.ts index d46bc83..d1aba87 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,13 +1,13 @@ -import '@testing-library/jest-dom' -import 'reflect-metadata' // Required for class-transformer decorators -import { beforeAll, afterEach, vi } from 'vitest' -import { cleanup } from '@testing-library/react' +import '@testing-library/jest-dom'; +import 'reflect-metadata'; // Required for class-transformer decorators +import { beforeAll, afterEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; // Import our custom Tone.js mocks -import { setupToneMocks } from './mocks/tone' +import { setupToneMocks } from './mocks/tone'; // Setup global mocks -setupToneMocks() +setupToneMocks(); // Mock KGCore globally to prevent store initialization issues vi.mock('../core/KGCore', () => ({ @@ -24,22 +24,22 @@ vi.mock('../core/KGCore', () => ({ executeCommand: vi.fn() }) } -})) +})); // Global test setup for all unit tests // Clean up after each test afterEach(() => { - cleanup() - vi.clearAllMocks() -}) + cleanup(); + vi.clearAllMocks(); +}); // Setup before all tests beforeAll(() => { // Mock console methods to reduce noise in tests - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); // Mock window.matchMedia (needed for some UI components) Object.defineProperty(window, 'matchMedia', { @@ -54,16 +54,16 @@ beforeAll(() => { removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), - }) + }); // Mock ResizeObserver (might be needed for some components) global.ResizeObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), - })) + })); // Mock URL.createObjectURL (might be needed for file operations) - global.URL.createObjectURL = vi.fn(() => 'mocked-url') - global.URL.revokeObjectURL = vi.fn() -}) \ No newline at end of file + global.URL.createObjectURL = vi.fn(() => 'mocked-url'); + global.URL.revokeObjectURL = vi.fn(); +}); \ No newline at end of file diff --git a/src/test/utils/mock-data.ts b/src/test/utils/mock-data.ts index 4b0adcc..eedfcf5 100644 --- a/src/test/utils/mock-data.ts +++ b/src/test/utils/mock-data.ts @@ -1,7 +1,7 @@ -import { KGMidiNote } from '../../core/midi/KGMidiNote' -import { KGProject } from '../../core/KGProject' -import { KGMidiTrack } from '../../core/track/KGMidiTrack' -import { KGMidiRegion } from '../../core/region/KGMidiRegion' +import { KGMidiNote } from '../../core/midi/KGMidiNote'; +import { KGProject } from '../../core/KGProject'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGMidiRegion } from '../../core/region/KGMidiRegion'; /** * Test data factories for creating mock objects @@ -22,7 +22,7 @@ export const createMockMidiNote = (overrides: Partial<{ pitch: 60, // Middle C velocity: 80, ...overrides - } + }; return new KGMidiNote( defaults.id, @@ -30,8 +30,8 @@ export const createMockMidiNote = (overrides: Partial<{ defaults.endBeat, defaults.pitch, defaults.velocity - ) -} + ); +}; export const createMockMidiRegion = (overrides: Partial<{ id: string @@ -50,7 +50,7 @@ export const createMockMidiRegion = (overrides: Partial<{ startFromBeat: 0, length: 4, ...overrides - } + }; const region = new KGMidiRegion( defaults.id, @@ -59,15 +59,15 @@ export const createMockMidiRegion = (overrides: Partial<{ defaults.name, defaults.startFromBeat, defaults.length - ) + ); // Add notes if provided if (overrides.notes) { - overrides.notes.forEach(note => region.addNote(note)) + overrides.notes.forEach(note => region.addNote(note)); } - return region -} + return region; +}; export const createMockMidiTrack = (overrides: Partial<{ name: string @@ -82,22 +82,22 @@ export const createMockMidiTrack = (overrides: Partial<{ instrument: 'acoustic_grand_piano' as const, volume: 0.8, ...overrides - } + }; const track = new KGMidiTrack( defaults.name, defaults.id, defaults.instrument as keyof typeof import('../../constants/generalMidiConstants').FLUIDR3_INSTRUMENT_MAP, defaults.volume - ) + ); // Add regions if provided if (overrides.regions) { - track.setRegions(overrides.regions) + track.setRegions(overrides.regions); } - return track -} + return track; +}; export const createMockProject = (overrides: Partial<{ name: string @@ -111,7 +111,7 @@ export const createMockProject = (overrides: Partial<{ timeSignature: { numerator: 4, denominator: 4 }, tracks: [], ...overrides - } + }; const project = new KGProject( defaults.name, @@ -126,34 +126,34 @@ export const createMockProject = (overrides: Partial<{ 1, // barWidthMultiplier defaults.tracks, // tracks 5 // projectStructureVersion - ) + ); - return project -} + return project; +}; // Common test scenarios export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMidiTrack; region: KGMidiRegion } => { const notes = [ createMockMidiNote({ pitch: 60, startBeat: 0, endBeat: 1 }), createMockMidiNote({ pitch: 64, startBeat: 1, endBeat: 2 }), - ] + ]; const region = createMockMidiRegion({ id: 'region-1', trackId: 'track-1', notes - }) + }); const track = createMockMidiTrack({ id: 1, name: 'Track 1', regions: [region] - }) + }); const project = createMockProject({ name: 'Basic Test Project', tracks: [track] - }) + }); - return { project, track, region } -} \ No newline at end of file + return { project, track, region }; +}; \ No newline at end of file diff --git a/src/test/utils/setup-integration-tests.ts b/src/test/utils/setup-integration-tests.ts index dac8018..5a60f65 100644 --- a/src/test/utils/setup-integration-tests.ts +++ b/src/test/utils/setup-integration-tests.ts @@ -2,43 +2,43 @@ * Integration test setup utilities * Common setup and teardown for integration tests */ -import { beforeEach, afterEach, vi } from 'vitest' -import { mockAudioInterface } from '../mocks/audio-interface' -import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db' -import { mockTone } from '../mocks/tone-js' -import { KGCore } from '../../core/KGCore' -import { KGProject } from '../../core/KGProject' +import { beforeEach, afterEach, vi } from 'vitest'; +import { mockAudioInterface } from '../mocks/audio-interface'; +import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db'; +import { mockTone } from '../mocks/tone-js'; +import { KGCore } from '../../core/KGCore'; +import { KGProject } from '../../core/KGProject'; // Initialize KGCore with a default project at module load time // This prevents errors when projectStore module initializes and tries to access currentProject // This runs synchronously when the module is imported, before any tests const initializeKGCore = async () => { - const defaultProject = new KGProject('Test Setup Project') - const core = KGCore.instance() - await core.initialize() - core.setCurrentProject(defaultProject) -} + const defaultProject = new KGProject('Test Setup Project'); + const core = KGCore.instance(); + await core.initialize(); + core.setCurrentProject(defaultProject); +}; // Run initialization immediately at module load -await initializeKGCore() +await initializeKGCore(); // Global setup for integration tests beforeEach(() => { // Clear all mocks - vi.clearAllMocks() + vi.clearAllMocks(); // Clear mock storage - clearMockStorage() + clearMockStorage(); // Mock external dependencies while keeping internal components real vi.doMock('../../audio/KGAudioInterface', () => ({ KGAudioInterface: mockAudioInterface, default: mockAudioInterface, - })) + })); - vi.doMock('idb', () => mockIndexedDB) + vi.doMock('idb', () => mockIndexedDB); - vi.doMock('tone', () => mockTone) + vi.doMock('tone', () => mockTone); // Mock browser APIs that might be used Object.defineProperty(window, 'AudioContext', { @@ -47,19 +47,19 @@ beforeEach(() => { state: 'running', resume: vi.fn().mockResolvedValue(undefined), })), - }) + }); Object.defineProperty(window, 'webkitAudioContext', { writable: true, value: window.AudioContext, - }) -}) + }); +}); afterEach(() => { // Clean up after each test - vi.restoreAllMocks() - clearMockStorage() -}) + vi.restoreAllMocks(); + clearMockStorage(); +}); // Helper functions for integration tests export const createMockProject = () => { @@ -73,8 +73,8 @@ export const createMockProject = () => { keySignature: 'C major', maxBars: 32, tracks: [], - } -} + }; +}; export const createMockTrack = (name = 'Test Track') => { return { @@ -83,8 +83,8 @@ export const createMockTrack = (name = 'Test Track') => { instrument: 'acoustic_grand_piano', volume: 0.8, regions: [], - } -} + }; +}; export const createMockRegion = (name = 'Test Region') => { return { @@ -93,8 +93,8 @@ export const createMockRegion = (name = 'Test Region') => { startBeat: 0, endBeat: 4, notes: [], - } -} + }; +}; export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => { return { @@ -103,5 +103,5 @@ export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => { startBeat, endBeat, velocity: 100, - } -} \ No newline at end of file + }; +}; \ No newline at end of file diff --git a/src/test/utils/test-utils.tsx b/src/test/utils/test-utils.tsx index a1d5880..572d192 100644 --- a/src/test/utils/test-utils.tsx +++ b/src/test/utils/test-utils.tsx @@ -1,5 +1,5 @@ -import type { ReactElement } from 'react' -import { render, type RenderOptions } from '@testing-library/react' +import type { ReactElement } from 'react'; +import { render, type RenderOptions } from '@testing-library/react'; // Custom render function that includes any providers your app needs // This can be extended later with Zustand store providers, etc. @@ -13,10 +13,10 @@ const customRender = ( // If you need to wrap components with providers (like Zustand store), // you can create an AllTheProviders wrapper here - return render(ui, options) -} + return render(ui, options); +}; // Re-export everything from testing-library/react // eslint-disable-next-line react-refresh/only-export-components -export * from '@testing-library/react' -export { customRender as render } \ No newline at end of file +export * from '@testing-library/react'; +export { customRender as render }; \ No newline at end of file diff --git a/src/util/midiUtil.test.ts b/src/util/midiUtil.test.ts index 820b3c5..30139ea 100644 --- a/src/util/midiUtil.test.ts +++ b/src/util/midiUtil.test.ts @@ -1,186 +1,186 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect } from 'vitest'; import { beatsToBar, pitchToNoteNameString, pitchToNoteName, pianoRollIndexToPitch, noteNameToPitch -} from './midiUtil' +} from './midiUtil'; describe('midiUtil', () => { describe('beatsToBar', () => { it('should convert beats to bar position object', () => { - const result1 = beatsToBar(0, { numerator: 4, denominator: 4 }) - expect(result1.bar).toBe(0) - expect(result1.beatInBar).toBe(0) + const result1 = beatsToBar(0, { numerator: 4, denominator: 4 }); + expect(result1.bar).toBe(0); + expect(result1.beatInBar).toBe(0); - const result2 = beatsToBar(4, { numerator: 4, denominator: 4 }) - expect(result2.bar).toBe(1) - expect(result2.beatInBar).toBe(0) + const result2 = beatsToBar(4, { numerator: 4, denominator: 4 }); + expect(result2.bar).toBe(1); + expect(result2.beatInBar).toBe(0); - const result3 = beatsToBar(8, { numerator: 4, denominator: 4 }) - expect(result3.bar).toBe(2) - expect(result3.beatInBar).toBe(0) - }) + const result3 = beatsToBar(8, { numerator: 4, denominator: 4 }); + expect(result3.bar).toBe(2); + expect(result3.beatInBar).toBe(0); + }); it('should handle different time signatures', () => { - const result1 = beatsToBar(0, { numerator: 3, denominator: 4 }) - expect(result1.bar).toBe(0) - expect(result1.beatInBar).toBe(0) + const result1 = beatsToBar(0, { numerator: 3, denominator: 4 }); + expect(result1.bar).toBe(0); + expect(result1.beatInBar).toBe(0); - const result2 = beatsToBar(3, { numerator: 3, denominator: 4 }) - expect(result2.bar).toBe(1) - expect(result2.beatInBar).toBe(0) - }) + const result2 = beatsToBar(3, { numerator: 3, denominator: 4 }); + expect(result2.bar).toBe(1); + expect(result2.beatInBar).toBe(0); + }); it('should handle fractional beats', () => { - const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 }) - expect(result1.bar).toBe(0) - expect(result1.beatInBar).toBe(2.5) + const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 }); + expect(result1.bar).toBe(0); + expect(result1.beatInBar).toBe(2.5); - const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 }) - expect(result2.bar).toBe(1) - expect(result2.beatInBar).toBe(0.5) - }) - }) + const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 }); + expect(result2.bar).toBe(1); + expect(result2.beatInBar).toBe(0.5); + }); + }); describe('pitchToNoteNameString', () => { it('should convert MIDI pitch to note name with octave', () => { - expect(pitchToNoteNameString(60)).toBe('C4') // Middle C - expect(pitchToNoteNameString(61)).toBe('C#4') // C# above middle C - expect(pitchToNoteNameString(59)).toBe('B3') // B below middle C - expect(pitchToNoteNameString(72)).toBe('C5') // C one octave above middle C - expect(pitchToNoteNameString(48)).toBe('C3') // C one octave below middle C - }) + expect(pitchToNoteNameString(60)).toBe('C4'); // Middle C + expect(pitchToNoteNameString(61)).toBe('C#4'); // C# above middle C + expect(pitchToNoteNameString(59)).toBe('B3'); // B below middle C + expect(pitchToNoteNameString(72)).toBe('C5'); // C one octave above middle C + expect(pitchToNoteNameString(48)).toBe('C3'); // C one octave below middle C + }); it('should handle edge cases', () => { - expect(pitchToNoteNameString(0)).toBe('C-1') // Lowest MIDI note - expect(pitchToNoteNameString(127)).toBe('G9') // Highest MIDI note - }) + expect(pitchToNoteNameString(0)).toBe('C-1'); // Lowest MIDI note + expect(pitchToNoteNameString(127)).toBe('G9'); // Highest MIDI note + }); it('should handle all chromatic notes', () => { - const expectedNotes = ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4'] + const expectedNotes = ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4']; for (let i = 0; i < 12; i++) { - expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i]) + expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i]); } - }) - }) + }); + }); describe('pitchToNoteName', () => { it('should convert pitch to note name object', () => { - const result60 = pitchToNoteName(60) // Middle C - expect(result60.note).toBe('C') - expect(result60.octave).toBe(4) + const result60 = pitchToNoteName(60); // Middle C + expect(result60.note).toBe('C'); + expect(result60.octave).toBe(4); - const result61 = pitchToNoteName(61) // C# - expect(result61.note).toBe('C#') - expect(result61.octave).toBe(4) - }) + const result61 = pitchToNoteName(61); // C# + expect(result61.note).toBe('C#'); + expect(result61.octave).toBe(4); + }); it('should wrap around for different octaves', () => { - const result60 = pitchToNoteName(60) - const result72 = pitchToNoteName(72) - const result84 = pitchToNoteName(84) + const result60 = pitchToNoteName(60); + const result72 = pitchToNoteName(72); + const result84 = pitchToNoteName(84); - expect(result60.note).toBe('C') - expect(result72.note).toBe('C') - expect(result84.note).toBe('C') + expect(result60.note).toBe('C'); + expect(result72.note).toBe('C'); + expect(result84.note).toBe('C'); - expect(result60.octave).toBe(4) - expect(result72.octave).toBe(5) - expect(result84.octave).toBe(6) - }) - }) + expect(result60.octave).toBe(4); + expect(result72.octave).toBe(5); + expect(result84.octave).toBe(6); + }); + }); describe('pianoRollIndexToPitch', () => { it('should convert piano roll row index to MIDI pitch', () => { // This function likely maps visual rows to MIDI pitches // The exact mapping depends on your implementation - const result = pianoRollIndexToPitch(10) - expect(typeof result).toBe('number') - expect(result).toBeGreaterThanOrEqual(0) - expect(result).toBeLessThanOrEqual(127) - }) + const result = pianoRollIndexToPitch(10); + expect(typeof result).toBe('number'); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(127); + }); it('should return different pitches for different indices', () => { - const pitch1 = pianoRollIndexToPitch(0) - const pitch2 = pianoRollIndexToPitch(1) - expect(pitch1).not.toBe(pitch2) - }) - }) + const pitch1 = pianoRollIndexToPitch(0); + const pitch2 = pianoRollIndexToPitch(1); + expect(pitch1).not.toBe(pitch2); + }); + }); describe('noteNameToPitch', () => { it('should convert note names to MIDI pitch', () => { - expect(noteNameToPitch('C4')).toBe(60) // Middle C - expect(noteNameToPitch('C#4')).toBe(61) // C# above middle C - expect(noteNameToPitch('D4')).toBe(62) // D above middle C - }) + expect(noteNameToPitch('C4')).toBe(60); // Middle C + expect(noteNameToPitch('C#4')).toBe(61); // C# above middle C + expect(noteNameToPitch('D4')).toBe(62); // D above middle C + }); it('should handle different octaves', () => { - expect(noteNameToPitch('C3')).toBe(48) // C below middle C - expect(noteNameToPitch('C5')).toBe(72) // C above middle C - }) + expect(noteNameToPitch('C3')).toBe(48); // C below middle C + expect(noteNameToPitch('C5')).toBe(72); // C above middle C + }); it('should handle sharps', () => { - expect(noteNameToPitch('C#4')).toBe(61) - expect(noteNameToPitch('F#4')).toBe(66) - expect(noteNameToPitch('G#4')).toBe(68) - }) + expect(noteNameToPitch('C#4')).toBe(61); + expect(noteNameToPitch('F#4')).toBe(66); + expect(noteNameToPitch('G#4')).toBe(68); + }); it('should handle invalid note names', () => { - expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4') // Flats not supported - expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4') // Invalid note - expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C') // Missing octave - }) - }) + expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4'); // Flats not supported + expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4'); // Invalid note + expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C'); // Missing octave + }); + }); describe('edge cases and error handling', () => { it('should handle negative values gracefully', () => { - expect(() => pitchToNoteNameString(-1)).not.toThrow() - expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow() - }) + expect(() => pitchToNoteNameString(-1)).not.toThrow(); + expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow(); + }); it('should handle very large values', () => { - expect(() => pitchToNoteNameString(200)).not.toThrow() - expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow() - }) + expect(() => pitchToNoteNameString(200)).not.toThrow(); + expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow(); + }); it('should handle zero values', () => { - expect(pitchToNoteNameString(0)).toBeDefined() - expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined() - }) - }) + expect(pitchToNoteNameString(0)).toBeDefined(); + expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined(); + }); + }); describe('mathematical consistency', () => { it('should maintain pitch relationships', () => { // One octave = 12 semitones - note names should be the same - const baseNote = pitchToNoteName(60) - const octaveNote = pitchToNoteName(72) - expect(baseNote.note).toBe(octaveNote.note) // Both should be 'C' - expect(octaveNote.octave).toBe(baseNote.octave + 1) // Octave should be one higher - }) + const baseNote = pitchToNoteName(60); + const octaveNote = pitchToNoteName(72); + expect(baseNote.note).toBe(octaveNote.note); // Both should be 'C' + expect(octaveNote.octave).toBe(baseNote.octave + 1); // Octave should be one higher + }); it('should maintain beat-to-bar relationships', () => { - const timeSignature = { numerator: 4, denominator: 4 } + const timeSignature = { numerator: 4, denominator: 4 }; // Should increment bar by 1 for each complete measure for (let beat = 0; beat < 20; beat += 4) { - const expectedBar = Math.floor(beat / 4) - const result = beatsToBar(beat, timeSignature) - expect(result.bar).toBe(expectedBar) - expect(result.beatInBar).toBe(0) // Should be at start of bar + const expectedBar = Math.floor(beat / 4); + const result = beatsToBar(beat, timeSignature); + expect(result.bar).toBe(expectedBar); + expect(result.beatInBar).toBe(0); // Should be at start of bar } - }) + }); it('should maintain note name to pitch conversion consistency', () => { // Converting pitch to note name and back should be consistent - const originalPitch = 60 - const noteObj = pitchToNoteName(originalPitch) - const noteName = `${noteObj.note}${noteObj.octave}` - const convertedPitch = noteNameToPitch(noteName) + const originalPitch = 60; + const noteObj = pitchToNoteName(originalPitch); + const noteName = `${noteObj.note}${noteObj.octave}`; + const convertedPitch = noteNameToPitch(noteName); - expect(convertedPitch).toBe(originalPitch) - }) - }) -}) \ No newline at end of file + expect(convertedPitch).toBe(originalPitch); + }); + }); +}); \ No newline at end of file diff --git a/src/util/scaleUtil.test.ts b/src/util/scaleUtil.test.ts index ce25248..baa6852 100644 --- a/src/util/scaleUtil.test.ts +++ b/src/util/scaleUtil.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { getRootNoteFromKeySignature, noteNameToPitchClass, @@ -9,208 +9,208 @@ import { getMatchingChordsForPitch, generatePianoGridBackground, validateFunctionalChordsJSON -} from './scaleUtil' -import { KGCore } from '../core/KGCore' -import type { KeySignature } from '../core/KGProject' -import functionalChordsData from '../../public/resources/modes/functional_chords.json' +} from './scaleUtil'; +import { KGCore } from '../core/KGCore'; +import type { KeySignature } from '../core/KGProject'; +import functionalChordsData from '../../public/resources/modes/functional_chords.json'; describe('scaleUtil', () => { // Setup: Mock KGCore with real chord data beforeEach(() => { // Use real functional chords data from JSON file (includes name, steps, and chord data) - KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData - }) + KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData; + }); describe('getRootNoteFromKeySignature', () => { it('should extract root note from C major', () => { - expect(getRootNoteFromKeySignature('C major')).toBe('C') - }) + expect(getRootNoteFromKeySignature('C major')).toBe('C'); + }); it('should extract root note from F# minor', () => { - expect(getRootNoteFromKeySignature('F# minor')).toBe('F#') - }) + expect(getRootNoteFromKeySignature('F# minor')).toBe('F#'); + }); it('should extract root note from Bb major', () => { - expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb') - }) + expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb'); + }); it('should handle Db major', () => { - expect(getRootNoteFromKeySignature('Db major')).toBe('Db') - }) + expect(getRootNoteFromKeySignature('Db major')).toBe('Db'); + }); it('should default to C for invalid format', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C') - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format')) - warnSpy.mockRestore() - }) - }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format')); + warnSpy.mockRestore(); + }); + }); describe('noteNameToPitchClass', () => { it('should convert C to 0', () => { - expect(noteNameToPitchClass('C')).toBe(0) - }) + expect(noteNameToPitchClass('C')).toBe(0); + }); it('should convert C# to 1', () => { - expect(noteNameToPitchClass('C#')).toBe(1) - }) + expect(noteNameToPitchClass('C#')).toBe(1); + }); it('should convert Db to 1', () => { - expect(noteNameToPitchClass('Db')).toBe(1) - }) + expect(noteNameToPitchClass('Db')).toBe(1); + }); it('should convert D to 2', () => { - expect(noteNameToPitchClass('D')).toBe(2) - }) + expect(noteNameToPitchClass('D')).toBe(2); + }); it('should convert E to 4', () => { - expect(noteNameToPitchClass('E')).toBe(4) - }) + expect(noteNameToPitchClass('E')).toBe(4); + }); it('should convert F to 5', () => { - expect(noteNameToPitchClass('F')).toBe(5) - }) + expect(noteNameToPitchClass('F')).toBe(5); + }); it('should convert F# to 6', () => { - expect(noteNameToPitchClass('F#')).toBe(6) - }) + expect(noteNameToPitchClass('F#')).toBe(6); + }); it('should convert G to 7', () => { - expect(noteNameToPitchClass('G')).toBe(7) - }) + expect(noteNameToPitchClass('G')).toBe(7); + }); it('should convert A to 9', () => { - expect(noteNameToPitchClass('A')).toBe(9) - }) + expect(noteNameToPitchClass('A')).toBe(9); + }); it('should convert Bb to 10', () => { - expect(noteNameToPitchClass('Bb')).toBe(10) - }) + expect(noteNameToPitchClass('Bb')).toBe(10); + }); it('should convert B to 11', () => { - expect(noteNameToPitchClass('B')).toBe(11) - }) + expect(noteNameToPitchClass('B')).toBe(11); + }); it('should default to 0 for invalid note', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(noteNameToPitchClass('X')).toBe(0) - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name')) - warnSpy.mockRestore() - }) - }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(noteNameToPitchClass('X')).toBe(0); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name')); + warnSpy.mockRestore(); + }); + }); describe('getModeSteps', () => { it('should return ionian steps', () => { - expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1]) - }) + expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1]); + }); it('should return dorian steps', () => { - expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2]) - }) + expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2]); + }); it('should default to ionian for invalid mode', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1]) - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found')) - warnSpy.mockRestore() - }) - }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found')); + warnSpy.mockRestore(); + }); + }); describe('getScalePitchClasses', () => { it('should return C major scale pitch classes', () => { - const steps = [2, 2, 1, 2, 2, 2, 1] - const result = getScalePitchClasses('C', steps) - expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]) // C D E F G A B - }) + const steps = [2, 2, 1, 2, 2, 2, 1]; + const result = getScalePitchClasses('C', steps); + expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]); // C D E F G A B + }); it('should return D major scale pitch classes', () => { - const steps = [2, 2, 1, 2, 2, 2, 1] - const result = getScalePitchClasses('D', steps) - expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]) // D E F# G A B C# - }) + const steps = [2, 2, 1, 2, 2, 2, 1]; + const result = getScalePitchClasses('D', steps); + expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]); // D E F# G A B C# + }); it('should return F# dorian scale pitch classes', () => { - const steps = [2, 1, 2, 2, 2, 1, 2] - const result = getScalePitchClasses('F#', steps) - expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]) // F# G# A B C# D# E - }) - }) + const steps = [2, 1, 2, 2, 2, 1, 2]; + const result = getScalePitchClasses('F#', steps); + expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]); // F# G# A B C# D# E + }); + }); describe('getSuitableChords', () => { it('should return tonic chords for C major ionian', () => { - const result = getSuitableChords('C major', 'ionian', 'T') - expect(result).toHaveProperty('I') - expect(result).toHaveProperty('vi') - expect(result).toHaveProperty('iii') - expect(result['I']).toEqual(['C', 'E', 'G']) - expect(result['vi']).toEqual(['A', 'C', 'E']) - }) + const result = getSuitableChords('C major', 'ionian', 'T'); + expect(result).toHaveProperty('I'); + expect(result).toHaveProperty('vi'); + expect(result).toHaveProperty('iii'); + expect(result['I']).toEqual(['C', 'E', 'G']); + expect(result['vi']).toEqual(['A', 'C', 'E']); + }); it('should return subdominant chords for C major ionian', () => { - const result = getSuitableChords('C major', 'ionian', 'S') - expect(result).toHaveProperty('IV') - expect(result).toHaveProperty('ii') - expect(result['IV']).toEqual(['F', 'A', 'C']) - expect(result['ii']).toEqual(['D', 'F', 'A']) - }) + const result = getSuitableChords('C major', 'ionian', 'S'); + expect(result).toHaveProperty('IV'); + expect(result).toHaveProperty('ii'); + expect(result['IV']).toEqual(['F', 'A', 'C']); + expect(result['ii']).toEqual(['D', 'F', 'A']); + }); it('should return dominant chords for C major ionian', () => { - const result = getSuitableChords('C major', 'ionian', 'D') - expect(result).toHaveProperty('V') - expect(result).toHaveProperty('V7') - expect(result['V']).toEqual(['G', 'B', 'D']) - expect(result['V7']).toEqual(['G', 'B', 'D', 'F']) - }) + const result = getSuitableChords('C major', 'ionian', 'D'); + expect(result).toHaveProperty('V'); + expect(result).toHaveProperty('V7'); + expect(result['V']).toEqual(['G', 'B', 'D']); + expect(result['V7']).toEqual(['G', 'B', 'D', 'F']); + }); it('should transpose chords for D major ionian', () => { - const result = getSuitableChords('D major', 'ionian', 'T') - expect(result['I']).toEqual(['D', 'F#', 'A']) - expect(result['vi']).toEqual(['B', 'D', 'F#']) - }) + const result = getSuitableChords('D major', 'ionian', 'T'); + expect(result['I']).toEqual(['D', 'F#', 'A']); + expect(result['vi']).toEqual(['B', 'D', 'F#']); + }); it('should transpose chords for F# major ionian', () => { - const result = getSuitableChords('F# major', 'ionian', 'T') - expect(result['I']).toEqual(['F#', 'A#', 'C#']) - }) + const result = getSuitableChords('F# major', 'ionian', 'T'); + expect(result['I']).toEqual(['F#', 'A#', 'C#']); + }); it('should return empty object for invalid mode', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = getSuitableChords('C major', 'invalid', 'T') - expect(result).toEqual({}) - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found')) - warnSpy.mockRestore() - }) - }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = getSuitableChords('C major', 'invalid', 'T'); + expect(result).toEqual({}); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found')); + warnSpy.mockRestore(); + }); + }); describe('getChordNotesInKey', () => { it('should return I chord notes in C major ionian', () => { - const result = getChordNotesInKey('I', 'C major', 'ionian') - expect(result).toEqual(['C', 'E', 'G']) - }) + const result = getChordNotesInKey('I', 'C major', 'ionian'); + expect(result).toEqual(['C', 'E', 'G']); + }); it('should return V7 chord notes in C major ionian', () => { - const result = getChordNotesInKey('V7', 'C major', 'ionian') - expect(result).toEqual(['G', 'B', 'D', 'F']) - }) + const result = getChordNotesInKey('V7', 'C major', 'ionian'); + expect(result).toEqual(['G', 'B', 'D', 'F']); + }); it('should transpose to D major', () => { - const result = getChordNotesInKey('I', 'D major', 'ionian') - expect(result).toEqual(['D', 'F#', 'A']) - }) + const result = getChordNotesInKey('I', 'D major', 'ionian'); + expect(result).toEqual(['D', 'F#', 'A']); + }); it('should return empty array for invalid chord symbol', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = getChordNotesInKey('invalid', 'C major', 'ionian') - expect(result).toEqual([]) - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found')) - warnSpy.mockRestore() - }) - }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = getChordNotesInKey('invalid', 'C major', 'ionian'); + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found')); + warnSpy.mockRestore(); + }); + }); describe('getMatchingChordsForPitch', () => { describe('valid inputs', () => { it('should return matching chords for C (pitch 60) in C major ionian tonic', () => { - const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') + const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // C major ionian T chords from JSON: I ["C","E","G"], vi ["A","C","E"], iii ["E","G","B"], I⁶ ["E","G","C"] // Pitch 60 = C (pitch class 0) @@ -222,11 +222,11 @@ describe('scaleUtil', () => { [0, 4, 7], // I chord (C-E-G): C is root [-3, 0, 4], // vi chord (A-C-E): C is 2nd, offset applied [-8, -5, 0] // I⁶ chord (E-G-C): C is 3rd, offset applied - ]) - }) + ]); + }); it('should return matching chords for E (pitch 64) in C major ionian tonic', () => { - const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T') + const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T'); // Pitch 64 = E (pitch class 4) // Expected matches prioritized by position: @@ -239,19 +239,19 @@ describe('scaleUtil', () => { [4, 7, 12], // I⁶ chord (E-G-C): E is root [0, 4, 7], // I chord (C-E-G): E is 2nd [-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied - ]) - }) + ]); + }); it('should prioritize root matches over other positions', () => { - const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') // C + const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // C // First chord should have C as root (position 0) if (result.length > 0) { - expect(result[0][0] % 12).toBe(0) // First note of first chord should be C + expect(result[0][0] % 12).toBe(0); // First note of first chord should be C } - }) + }); it('should return matching chords for F (pitch 65) in C major ionian subdominant', () => { - const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S') // F + const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S'); // F // Pitch 65 = F (pitch class 5) // C major ionian S chords from JSON: IV ["F","A","C"], ii ["D","F","A"], vi ["A","C","E"], IV⁶ ["A","C","F"] @@ -263,11 +263,11 @@ describe('scaleUtil', () => { [5, 9, 12], // IV chord (F-A-C): F is root, offset applied [2, 5, 9], // ii chord (D-F-A): F is 2nd [-3, 0, 5] // IV⁶ chord (A-C-F): F is 3rd, offset applied - ]) - }) + ]); + }); it('should return matching chords for G (pitch 67) in C major ionian dominant', () => { - const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D') // G + const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D'); // G // Pitch 67 = G (pitch class 7) // C major ionian D chords from JSON: V ["G","B","D"], V7 ["G","B","D","F"], vii° ["B","D","F"], ♭II ["Db","F","Ab"] @@ -277,122 +277,122 @@ describe('scaleUtil', () => { expect(result).toEqual([ [7, 11, 14], // V chord (G-B-D): G is root, offset applied [7, 11, 14, 17] // V7 chord (G-B-D-F): G is root, offset applied - ]) - }) + ]); + }); it('should work with different modes (dorian)', () => { - const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T') - expect(result.length).toBeGreaterThan(0) - }) + const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T'); + expect(result.length).toBeGreaterThan(0); + }); it('should transpose correctly for D major', () => { - const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T') // D - expect(result.length).toBeGreaterThan(0) - expect(result.some(chord => chord.includes(2))).toBe(true) // Contains D (pitch class 2) - }) + const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T'); // D + expect(result.length).toBeGreaterThan(0); + expect(result.some(chord => chord.includes(2))).toBe(true); // Contains D (pitch class 2) + }); it('should handle all MIDI pitch ranges (low)', () => { - const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T') // C1 - expect(Array.isArray(result)).toBe(true) - }) + const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T'); // C1 + expect(Array.isArray(result)).toBe(true); + }); it('should handle all MIDI pitch ranges (high)', () => { - const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T') // C8 - expect(Array.isArray(result)).toBe(true) - }) - }) + const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T'); // C8 + expect(Array.isArray(result)).toBe(true); + }); + }); describe('edge cases', () => { it('should return empty array for invalid mode', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T') - expect(result).toEqual([]) - warnSpy.mockRestore() - }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T'); + expect(result).toEqual([]); + warnSpy.mockRestore(); + }); it('should handle pitch class calculations correctly', () => { // Test that pitch 60 (C4) and pitch 72 (C5) both match C chords - const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') - const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T') - expect(result1.length).toBe(result2.length) // Same chords match - }) + const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); + const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T'); + expect(result1.length).toBe(result2.length); // Same chords match + }); it('should return pitch classes in ascending order', () => { - const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') + const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); result.forEach(chord => { for (let i = 1; i < chord.length; i++) { - expect(chord[i]).toBeGreaterThan(chord[i - 1]) + expect(chord[i]).toBeGreaterThan(chord[i - 1]); } - }) - }) + }); + }); it('should apply octave offset correctly for pitch classes >= 12', () => { - const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') + const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // All pitch classes should be < 12 after offset result.forEach(chord => { chord.forEach(pitchClass => { - expect(pitchClass).toBeLessThan(24) // Allowing for extended range - }) - }) - }) - }) + expect(pitchClass).toBeLessThan(24); // Allowing for extended range + }); + }); + }); + }); describe('pitch class calculations', () => { it('should correctly convert hover pitch to pitch class', () => { // C4 (60) should match same chords as C5 (72) - const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') - const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T') - expect(result1).toEqual(result2) - }) + const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); + const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T'); + expect(result1).toEqual(result2); + }); it('should maintain ascending pitch order in results', () => { - const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T') + const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T'); result.forEach(chord => { for (let i = 1; i < chord.length; i++) { - expect(chord[i]).toBeGreaterThan(chord[i - 1]) + expect(chord[i]).toBeGreaterThan(chord[i - 1]); } - }) - }) - }) + }); + }); + }); describe('integration with helper functions', () => { it('should work with getSuitableChords', () => { - const suitableChords = getSuitableChords('C major', 'ionian', 'T') - expect(Object.keys(suitableChords).length).toBeGreaterThan(0) + const suitableChords = getSuitableChords('C major', 'ionian', 'T'); + expect(Object.keys(suitableChords).length).toBeGreaterThan(0); - const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') - expect(matchingChords.length).toBeGreaterThan(0) - }) + const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); + expect(matchingChords.length).toBeGreaterThan(0); + }); it('should work with noteNameToPitchClass', () => { - const cPitch = noteNameToPitchClass('C') - const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') - expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true) - }) + const cPitch = noteNameToPitchClass('C'); + const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); + expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true); + }); it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => { // Verify the data has the expected structure - const data = KGCore.FUNCTIONAL_CHORDS_DATA - expect(data).toHaveProperty('ionian') - expect(data['ionian']).toHaveProperty('T') - expect(data['ionian']).toHaveProperty('chords') - }) - }) - }) + const data = KGCore.FUNCTIONAL_CHORDS_DATA; + expect(data).toHaveProperty('ionian'); + expect(data['ionian']).toHaveProperty('T'); + expect(data['ionian']).toHaveProperty('chords'); + }); + }); + }); describe('generatePianoGridBackground', () => { it('should generate CSS background for C major ionian', () => { - const result = generatePianoGridBackground('ionian', 'C major') - expect(result).toContain('linear-gradient') - expect(typeof result).toBe('string') - }) + const result = generatePianoGridBackground('ionian', 'C major'); + expect(result).toContain('linear-gradient'); + expect(typeof result).toBe('string'); + }); it('should generate different backgrounds for different modes', () => { - const ionian = generatePianoGridBackground('ionian', 'C major') - const dorian = generatePianoGridBackground('dorian', 'C major') - expect(ionian).not.toBe(dorian) - }) - }) + const ionian = generatePianoGridBackground('ionian', 'C major'); + const dorian = generatePianoGridBackground('dorian', 'C major'); + expect(ionian).not.toBe(dorian); + }); + }); describe('validateFunctionalChordsJSON', () => { const validJSON = `{ @@ -434,14 +434,14 @@ describe('scaleUtil', () => { "♭VII": ["Bb", "D", "F"] } } - }` + }`; describe('valid JSON', () => { it('should validate correct JSON with ionian and aeolian', () => { - const result = validateFunctionalChordsJSON(validJSON) - expect(result.valid).toBe(true) - expect(result.errors).toEqual([]) - }) + const result = validateFunctionalChordsJSON(validJSON); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); it('should accept empty T/S/D arrays', () => { const json = `{ @@ -453,10 +453,10 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(true); + }); it('should accept mode names with underscores, dashes, and spaces', () => { const json = `{ @@ -468,10 +468,10 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(true); + }); it('should accept notes with sharp and flat', () => { const json = `{ @@ -485,10 +485,10 @@ describe('scaleUtil', () => { "I": ["C#", "Eb", "F#"] } } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(true); + }); it('should accept chord symbols with special characters', () => { const json = `{ @@ -505,24 +505,24 @@ describe('scaleUtil', () => { "♭II": ["Db", "F", "Ab"] } } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(true) - }) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(true); + }); + }); describe('invalid JSON', () => { it('should reject malformed JSON', () => { - const result = validateFunctionalChordsJSON('{ invalid json }') - expect(result.valid).toBe(false) - expect(result.errors).toContain('Invalid JSON format') - }) + const result = validateFunctionalChordsJSON('{ invalid json }'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Invalid JSON format'); + }); it('should reject JSON array as root', () => { - const result = validateFunctionalChordsJSON('[]') - expect(result.valid).toBe(false) - expect(result.errors).toContain('Root must be an object') - }) + const result = validateFunctionalChordsJSON('[]'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Root must be an object'); + }); it('should reject missing ionian mode', () => { const json = `{ @@ -534,11 +534,11 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors).toContain('Missing required mode: "ionian"') - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required mode: "ionian"'); + }); it('should reject invalid mode name', () => { const json = `{ @@ -550,11 +550,11 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true); + }); it('should reject steps with wrong number of elements', () => { const json = `{ @@ -566,11 +566,11 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true); + }); it('should reject steps that do not sum to 12', () => { const json = `{ @@ -582,11 +582,11 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true); + }); it('should reject non-integer steps', () => { const json = `{ @@ -598,11 +598,11 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true); + }); it('should reject invalid chord symbol in T/S/D', () => { const json = `{ @@ -616,11 +616,11 @@ describe('scaleUtil', () => { "invalid123": ["C", "E", "G"] } } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true); + }); it('should reject chord referenced but not defined', () => { const json = `{ @@ -632,11 +632,11 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true); + }); it('should reject invalid note name', () => { const json = `{ @@ -650,11 +650,11 @@ describe('scaleUtil', () => { "I": ["C", "X", "G"] } } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true); + }); it('should reject note with invalid accidental', () => { const json = `{ @@ -668,11 +668,11 @@ describe('scaleUtil', () => { "I": ["C##", "E", "G"] } } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true); + }); it('should reject lowercase note names', () => { const json = `{ @@ -686,12 +686,12 @@ describe('scaleUtil', () => { "I": ["c", "e", "g"] } } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true) - }) - }) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true); + }); + }); describe('error messages', () => { it('should provide descriptive error messages for multiple errors', () => { @@ -704,11 +704,11 @@ describe('scaleUtil', () => { "D": [], "chords": {} } - }` - const result = validateFunctionalChordsJSON(json) - expect(result.valid).toBe(false) - expect(result.errors.length).toBeGreaterThan(1) - }) - }) - }) -}) + }`; + const result = validateFunctionalChordsJSON(json); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(1); + }); + }); + }); +}); diff --git a/src/util/timeUtil.test.ts b/src/util/timeUtil.test.ts index f9064e8..61696f6 100644 --- a/src/util/timeUtil.test.ts +++ b/src/util/timeUtil.test.ts @@ -1,316 +1,316 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil' -import { TIME_CONSTANTS } from '../constants/coreConstants' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil'; +import { TIME_CONSTANTS } from '../constants/coreConstants'; describe('timeUtil', () => { describe('parseTimeSignature', () => { it('should parse valid time signatures', () => { - expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 }) - expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 }) - expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 }) - expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 }) - expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 }) - }) + expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 }); + expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 }); + expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 }); + expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 }); + expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 }); + }); it('should handle whitespace around input', () => { - expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 }) - expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 }) - expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 }) - }) + expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 }); + expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 }); + expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 }); + }); it('should return null for invalid formats', () => { - expect(parseTimeSignature('4')).toBeNull() - expect(parseTimeSignature('4/4/4')).toBeNull() - expect(parseTimeSignature('4-4')).toBeNull() - expect(parseTimeSignature('4:4')).toBeNull() - expect(parseTimeSignature('')).toBeNull() - expect(parseTimeSignature('/')).toBeNull() - expect(parseTimeSignature('4/')).toBeNull() - expect(parseTimeSignature('/4')).toBeNull() - }) + expect(parseTimeSignature('4')).toBeNull(); + expect(parseTimeSignature('4/4/4')).toBeNull(); + expect(parseTimeSignature('4-4')).toBeNull(); + expect(parseTimeSignature('4:4')).toBeNull(); + expect(parseTimeSignature('')).toBeNull(); + expect(parseTimeSignature('/')).toBeNull(); + expect(parseTimeSignature('4/')).toBeNull(); + expect(parseTimeSignature('/4')).toBeNull(); + }); it('should return null for non-numeric values', () => { - expect(parseTimeSignature('a/4')).toBeNull() - expect(parseTimeSignature('4/b')).toBeNull() - expect(parseTimeSignature('x/y')).toBeNull() + expect(parseTimeSignature('a/4')).toBeNull(); + expect(parseTimeSignature('4/b')).toBeNull(); + expect(parseTimeSignature('x/y')).toBeNull(); // Note: parseInt('4.5') returns 4, so these will parse as integers // Testing the actual behavior of parseInt - expect(parseTimeSignature('4.5/4')).toEqual({ numerator: 4, denominator: 4 }) - expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 }) - }) + expect(parseTimeSignature('4.5/4')).toEqual({ numerator: 4, denominator: 4 }); + expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 }); + }); it('should return null for numerators not in available list', () => { // Assuming TIME_CONSTANTS has specific available numerators - expect(parseTimeSignature('99/4')).toBeNull() - expect(parseTimeSignature('0/4')).toBeNull() - expect(parseTimeSignature('-1/4')).toBeNull() - }) + expect(parseTimeSignature('99/4')).toBeNull(); + expect(parseTimeSignature('0/4')).toBeNull(); + expect(parseTimeSignature('-1/4')).toBeNull(); + }); it('should return null for denominators not in available list', () => { // Assuming TIME_CONSTANTS has specific available denominators - expect(parseTimeSignature('4/99')).toBeNull() - expect(parseTimeSignature('4/0')).toBeNull() - expect(parseTimeSignature('4/-1')).toBeNull() - }) + expect(parseTimeSignature('4/99')).toBeNull(); + expect(parseTimeSignature('4/0')).toBeNull(); + expect(parseTimeSignature('4/-1')).toBeNull(); + }); it('should validate against TIME_CONSTANTS available values', () => { // Test that function actually uses TIME_CONSTANTS for validation - const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0] - const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0] - const invalidNumerator = 999 // Assuming this is not in the available list - const invalidDenominator = 999 // Assuming this is not in the available list + const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0]; + const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0]; + const invalidNumerator = 999; // Assuming this is not in the available list + const invalidDenominator = 999; // Assuming this is not in the available list - expect(parseTimeSignature(`${validNumerator}/${validDenominator}`)).not.toBeNull() - expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull() - expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull() - }) - }) + expect(parseTimeSignature(`${validNumerator}/${validDenominator}`)).not.toBeNull(); + expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull(); + expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull(); + }); + }); describe('getTimeSignatureErrorMessage', () => { it('should return a formatted error message with available options', () => { - const message = getTimeSignatureErrorMessage() + const message = getTimeSignatureErrorMessage(); - expect(message).toContain('Invalid time signature format') - expect(message).toContain('numerator/denominator') - expect(message).toContain('Available numerators:') - expect(message).toContain('Available denominators:') - expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8') - }) + expect(message).toContain('Invalid time signature format'); + expect(message).toContain('numerator/denominator'); + expect(message).toContain('Available numerators:'); + expect(message).toContain('Available denominators:'); + expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8'); + }); it('should include actual available values from TIME_CONSTANTS', () => { - const message = getTimeSignatureErrorMessage() + const message = getTimeSignatureErrorMessage(); // Check that it includes values from TIME_CONSTANTS TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.forEach(numerator => { - expect(message).toContain(numerator.toString()) - }) + expect(message).toContain(numerator.toString()); + }); TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS.forEach(denominator => { - expect(message).toContain(denominator.toString()) - }) - }) + expect(message).toContain(denominator.toString()); + }); + }); it('should be a consistent message format', () => { - const message1 = getTimeSignatureErrorMessage() - const message2 = getTimeSignatureErrorMessage() + const message1 = getTimeSignatureErrorMessage(); + const message2 = getTimeSignatureErrorMessage(); - expect(message1).toBe(message2) - }) - }) + expect(message1).toBe(message2); + }); + }); describe('beatsToTimeString', () => { it('should format beats to BBB:B | mm:ss:mmm format', () => { // Test basic 4/4 time signature expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 })) - .toBe('001:1 | 00:00:000') + .toBe('001:1 | 00:00:000'); expect(beatsToTimeString(4, 120, { numerator: 4, denominator: 4 })) - .toBe('002:1 | 00:02:000') + .toBe('002:1 | 00:02:000'); expect(beatsToTimeString(8, 120, { numerator: 4, denominator: 4 })) - .toBe('003:1 | 00:04:000') - }) + .toBe('003:1 | 00:04:000'); + }); it('should handle different time signatures correctly', () => { // 3/4 time signature - 3 beats per bar expect(beatsToTimeString(0, 120, { numerator: 3, denominator: 4 })) - .toBe('001:1 | 00:00:000') + .toBe('001:1 | 00:00:000'); expect(beatsToTimeString(3, 120, { numerator: 3, denominator: 4 })) - .toBe('002:1 | 00:01:500') + .toBe('002:1 | 00:01:500'); expect(beatsToTimeString(6, 120, { numerator: 3, denominator: 4 })) - .toBe('003:1 | 00:03:000') + .toBe('003:1 | 00:03:000'); // 6/8 time signature - 6 beats per bar expect(beatsToTimeString(0, 120, { numerator: 6, denominator: 8 })) - .toBe('001:1 | 00:00:000') + .toBe('001:1 | 00:00:000'); expect(beatsToTimeString(6, 120, { numerator: 6, denominator: 8 })) - .toBe('002:1 | 00:03:000') - }) + .toBe('002:1 | 00:03:000'); + }); it('should handle different BPM values correctly', () => { // 60 BPM - 1 beat per second expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 })) - .toBe('001:2 | 00:01:000') + .toBe('001:2 | 00:01:000'); expect(beatsToTimeString(4, 60, { numerator: 4, denominator: 4 })) - .toBe('002:1 | 00:04:000') + .toBe('002:1 | 00:04:000'); // 240 BPM - 4 beats per second expect(beatsToTimeString(1, 240, { numerator: 4, denominator: 4 })) - .toBe('001:2 | 00:00:250') + .toBe('001:2 | 00:00:250'); expect(beatsToTimeString(4, 240, { numerator: 4, denominator: 4 })) - .toBe('002:1 | 00:01:000') - }) + .toBe('002:1 | 00:01:000'); + }); it('should handle fractional beats correctly', () => { expect(beatsToTimeString(1.5, 120, { numerator: 4, denominator: 4 })) - .toBe('001:2 | 00:00:750') + .toBe('001:2 | 00:00:750'); expect(beatsToTimeString(2.25, 120, { numerator: 4, denominator: 4 })) - .toBe('001:3 | 00:01:125') + .toBe('001:3 | 00:01:125'); expect(beatsToTimeString(4.75, 120, { numerator: 4, denominator: 4 })) - .toBe('002:1 | 00:02:375') - }) + .toBe('002:1 | 00:02:375'); + }); it('should pad numbers correctly in output format', () => { // Test bar padding (BBB format) expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 })) - .toMatch(/^001:/) + .toMatch(/^001:/); expect(beatsToTimeString(40, 120, { numerator: 4, denominator: 4 })) - .toMatch(/^011:/) // Bar 11 + .toMatch(/^011:/); // Bar 11 expect(beatsToTimeString(396, 120, { numerator: 4, denominator: 4 })) - .toMatch(/^100:/) // Bar 100 + .toMatch(/^100:/); // Bar 100 // Test time padding (mm:ss:mmm format) expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 })) - .toMatch(/\| 00:01:000$/) + .toMatch(/\| 00:01:000$/); expect(beatsToTimeString(75, 60, { numerator: 4, denominator: 4 })) - .toMatch(/\| 01:15:000$/) // 1 minute 15 seconds - }) + .toMatch(/\| 01:15:000$/); // 1 minute 15 seconds + }); it('should handle large beat values', () => { - const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 }) - expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/) - }) + const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 }); + expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/); + }); it('should handle edge case of zero BPM gracefully', () => { // This might cause division by zero, should handle gracefully expect(() => beatsToTimeString(1, 0, { numerator: 4, denominator: 4 })) - .not.toThrow() - }) + .not.toThrow(); + }); it('should handle negative beats', () => { // Edge case - negative beats may produce negative values in time format - const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 }) - expect(result).toBeDefined() - expect(typeof result).toBe('string') + const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 }); + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); // The function may produce negative time values for negative beats - expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/) - }) - }) + expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/); + }); + }); describe('formatLocalDateTime', () => { - let mockDate: Date + let mockDate: Date; beforeEach(() => { // Use a fixed date for consistent testing - mockDate = new Date('2025-08-21T19:57:11.123Z') - }) + mockDate = new Date('2025-08-21T19:57:11.123Z'); + }); it('should format date with correct structure', () => { - const result = formatLocalDateTime(mockDate) + const result = formatLocalDateTime(mockDate); // Should contain date parts - expect(result).toMatch(/\d{4}/) // Year - expect(result).toMatch(/\d{2}/) // Month/day/hour/minute/second - expect(result).toContain(':') // Time separator - expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/) // Timezone - }) + expect(result).toMatch(/\d{4}/); // Year + expect(result).toMatch(/\d{2}/); // Month/day/hour/minute/second + expect(result).toContain(':'); // Time separator + expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/); // Timezone + }); it('should use 24-hour format', () => { - const morningDate = new Date('2025-08-21T09:30:00Z') - const eveningDate = new Date('2025-08-21T21:30:00Z') + const morningDate = new Date('2025-08-21T09:30:00Z'); + const eveningDate = new Date('2025-08-21T21:30:00Z'); - const morningResult = formatLocalDateTime(morningDate) - const eveningResult = formatLocalDateTime(eveningDate) + const morningResult = formatLocalDateTime(morningDate); + const eveningResult = formatLocalDateTime(eveningDate); // Should not contain AM/PM indicators - expect(morningResult).not.toMatch(/AM|PM/i) - expect(eveningResult).not.toMatch(/AM|PM/i) - }) + expect(morningResult).not.toMatch(/AM|PM/i); + expect(eveningResult).not.toMatch(/AM|PM/i); + }); it('should include timezone information', () => { - const result = formatLocalDateTime(mockDate) + const result = formatLocalDateTime(mockDate); // Should contain some timezone indicator - expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}|\+\d{4}|-\d{4}/) - }) + expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}|\+\d{4}|-\d{4}/); + }); it('should handle different dates consistently', () => { const dates = [ new Date('2025-01-01T00:00:00Z'), new Date('2025-06-15T12:30:45Z'), new Date('2025-12-31T23:59:59Z') - ] + ]; dates.forEach(date => { - const result = formatLocalDateTime(date) - expect(result).toBeDefined() - expect(typeof result).toBe('string') - expect(result.length).toBeGreaterThan(10) - }) - }) + const result = formatLocalDateTime(date); + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(10); + }); + }); it('should handle edge dates', () => { const edgeDates = [ new Date('1970-01-01T00:00:00Z'), // Unix epoch new Date('2038-01-19T03:14:07Z'), // Near 32-bit timestamp limit new Date('2100-12-31T23:59:59Z') // Future date - ] + ]; edgeDates.forEach(date => { - expect(() => formatLocalDateTime(date)).not.toThrow() - const result = formatLocalDateTime(date) - expect(typeof result).toBe('string') - expect(result.length).toBeGreaterThan(0) - }) - }) + expect(() => formatLocalDateTime(date)).not.toThrow(); + const result = formatLocalDateTime(date); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + }); it('should be consistent for the same date', () => { - const result1 = formatLocalDateTime(mockDate) - const result2 = formatLocalDateTime(mockDate) + const result1 = formatLocalDateTime(mockDate); + const result2 = formatLocalDateTime(mockDate); - expect(result1).toBe(result2) - }) + expect(result1).toBe(result2); + }); it('should handle leap year dates', () => { - const leapYearDate = new Date('2024-02-29T12:00:00Z') // Leap year + const leapYearDate = new Date('2024-02-29T12:00:00Z'); // Leap year - expect(() => formatLocalDateTime(leapYearDate)).not.toThrow() - const result = formatLocalDateTime(leapYearDate) - expect(result).toContain('2024') - expect(result).toContain('02') - expect(result).toContain('29') - }) - }) + expect(() => formatLocalDateTime(leapYearDate)).not.toThrow(); + const result = formatLocalDateTime(leapYearDate); + expect(result).toContain('2024'); + expect(result).toContain('02'); + expect(result).toContain('29'); + }); + }); describe('integration tests', () => { it('should work together for typical DAW workflow', () => { // Parse time signature - const timeSignature = parseTimeSignature('4/4') - expect(timeSignature).not.toBeNull() + const timeSignature = parseTimeSignature('4/4'); + expect(timeSignature).not.toBeNull(); // Use parsed time signature in time formatting - const timeString = beatsToTimeString(16, 120, timeSignature!) - expect(timeString).toBe('005:1 | 00:08:000') + const timeString = beatsToTimeString(16, 120, timeSignature!); + expect(timeString).toBe('005:1 | 00:08:000'); // Format current time - const now = new Date() - const formattedTime = formatLocalDateTime(now) - expect(formattedTime).toBeDefined() - }) + const now = new Date(); + const formattedTime = formatLocalDateTime(now); + expect(formattedTime).toBeDefined(); + }); it('should handle error cases gracefully in workflow', () => { // Invalid time signature should not break workflow - const invalidTimeSignature = parseTimeSignature('invalid') - expect(invalidTimeSignature).toBeNull() + const invalidTimeSignature = parseTimeSignature('invalid'); + expect(invalidTimeSignature).toBeNull(); // Get error message for user feedback - const errorMessage = getTimeSignatureErrorMessage() - expect(errorMessage).toContain('Invalid time signature') + const errorMessage = getTimeSignatureErrorMessage(); + expect(errorMessage).toContain('Invalid time signature'); // Fallback to default time signature - const fallbackTimeSignature = { numerator: 4, denominator: 4 } - const timeString = beatsToTimeString(8, 120, fallbackTimeSignature) - expect(timeString).toBe('003:1 | 00:04:000') - }) - }) -}) \ No newline at end of file + const fallbackTimeSignature = { numerator: 4, denominator: 4 }; + const timeString = beatsToTimeString(8, 120, fallbackTimeSignature); + expect(timeString).toBe('003:1 | 00:04:000'); + }); + }); +}); \ No newline at end of file diff --git a/src/util/xmlUtil.test.ts b/src/util/xmlUtil.test.ts index 8e2ec1c..03f5d31 100644 --- a/src/util/xmlUtil.test.ts +++ b/src/util/xmlUtil.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil' +import { describe, it, expect } from 'vitest'; +import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil'; import { longTextWithAddNotes, longTextWithReadMusic, @@ -9,323 +9,323 @@ import { malformedXml, noXmlContent, emptyAndWhitespaceXml -} from '../test/fixtures/xml-samples' +} from '../test/fixtures/xml-samples'; describe('xmlUtil', () => { describe('extractXMLFromString', () => { it('should extract simple XML blocks', () => { - const input = `Here is some text with content and more text.` - const result = extractXMLFromString(input) + const input = `Here is some text with content and more text.`; + const result = extractXMLFromString(input); - expect(result).toHaveLength(1) - expect(result[0]).toBe('content') - }) + expect(result).toHaveLength(1); + expect(result[0]).toBe('content'); + }); it('should extract multiple XML blocks', () => { - const input = `content1 some text content2` - const result = extractXMLFromString(input) + const input = `content1 some text content2`; + const result = extractXMLFromString(input); - expect(result).toHaveLength(2) - expect(result[0]).toBe('content1') - expect(result[1]).toBe('content2') - }) + expect(result).toHaveLength(2); + expect(result[0]).toBe('content1'); + expect(result[1]).toBe('content2'); + }); it('should extract XML blocks with nested elements', () => { - const input = `nested content` - const result = extractXMLFromString(input) + const input = `nested content`; + const result = extractXMLFromString(input); - expect(result).toHaveLength(1) - expect(result[0]).toBe('nested content') - }) + expect(result).toHaveLength(1); + expect(result[0]).toBe('nested content'); + }); it('should extract XML blocks with attributes', () => { - const input = `content` - const result = extractXMLFromString(input) + const input = `content`; + const result = extractXMLFromString(input); - expect(result).toHaveLength(1) - expect(result[0]).toBe('content') - }) + expect(result).toHaveLength(1); + expect(result[0]).toBe('content'); + }); it('should handle multiline XML blocks', () => { const input = ` content1 content2 -` - const result = extractXMLFromString(input) +`; + const result = extractXMLFromString(input); - expect(result).toHaveLength(1) - expect(result[0]).toContain('') - expect(result[0]).toContain('content1') - expect(result[0]).toContain('content2') - expect(result[0]).toContain('') - }) + expect(result).toHaveLength(1); + expect(result[0]).toContain(''); + expect(result[0]).toContain('content1'); + expect(result[0]).toContain('content2'); + expect(result[0]).toContain(''); + }); it('should extract XML from complex nested content fixture', () => { - const result = extractXMLFromString(nestedXmlContent) + const result = extractXMLFromString(nestedXmlContent); - expect(result).toHaveLength(1) - expect(result[0]).toContain('') - expect(result[0]).toContain('') - expect(result[0]).toContain('') - expect(result[0]).toContain('Test Content') - expect(result[0]).toContain('') - }) + expect(result).toHaveLength(1); + expect(result[0]).toContain(''); + expect(result[0]).toContain(''); + expect(result[0]).toContain(''); + expect(result[0]).toContain('Test Content'); + expect(result[0]).toContain(''); + }); it('should extract multiple XML blocks from fixture', () => { - const result = extractXMLFromString(multipleXmlBlocks) + const result = extractXMLFromString(multipleXmlBlocks); - expect(result).toHaveLength(3) - expect(result[0]).toContain('') - expect(result[0]).toContain('') - expect(result[1]).toContain('') - expect(result[1]).toContain('') - expect(result[2]).toContain('') - expect(result[2]).toContain('') - }) + expect(result).toHaveLength(3); + expect(result[0]).toContain(''); + expect(result[0]).toContain(''); + expect(result[1]).toContain(''); + expect(result[1]).toContain(''); + expect(result[2]).toContain(''); + expect(result[2]).toContain(''); + }); it('should extract XML with attributes from fixture', () => { - const result = extractXMLFromString(xmlWithAttributes) + const result = extractXMLFromString(xmlWithAttributes); - expect(result).toHaveLength(1) - expect(result[0]).toContain('region_id="main"') - expect(result[0]).toContain('track="melody"') - expect(result[0]).toContain('id="1"') - expect(result[0]).toContain('velocity="127"') - }) + expect(result).toHaveLength(1); + expect(result[0]).toContain('region_id="main"'); + expect(result[0]).toContain('track="melody"'); + expect(result[0]).toContain('id="1"'); + expect(result[0]).toContain('velocity="127"'); + }); it('should handle the long text with add_notes fixture (Case 1)', () => { - const result = extractXMLFromString(longTextWithAddNotes) + const result = extractXMLFromString(longTextWithAddNotes); - expect(result).toHaveLength(2) // Contains thinking tag and add_notes block + expect(result).toHaveLength(2); // Contains thinking tag and add_notes block // Find the add_notes block - const addNotesBlock = result.find(block => block.includes('')) - expect(addNotesBlock).toBeDefined() - expect(addNotesBlock).toContain('') - expect(addNotesBlock).toContain('') - expect(addNotesBlock).toContain('C4') - expect(addNotesBlock).toContain('0') - expect(addNotesBlock).toContain('4') - expect(addNotesBlock).toContain('') + const addNotesBlock = result.find(block => block.includes('')); + expect(addNotesBlock).toBeDefined(); + expect(addNotesBlock).toContain(''); + expect(addNotesBlock).toContain(''); + expect(addNotesBlock).toContain('C4'); + expect(addNotesBlock).toContain('0'); + expect(addNotesBlock).toContain('4'); + expect(addNotesBlock).toContain(''); // Should contain all 12 notes - const noteMatches = addNotesBlock!.match(//g) - expect(noteMatches).toHaveLength(12) - }) + const noteMatches = addNotesBlock!.match(//g); + expect(noteMatches).toHaveLength(12); + }); it('should handle the long text with read_music fixture (Case 2)', () => { - const result = extractXMLFromString(longTextWithReadMusic) + const result = extractXMLFromString(longTextWithReadMusic); - expect(result).toHaveLength(2) + expect(result).toHaveLength(2); // First XML block (inside thinking tag) - expect(result[0]).toContain('') - expect(result[0]).toContain('0') - expect(result[0]).toContain('32') - expect(result[0]).toContain('') + expect(result[0]).toContain(''); + expect(result[0]).toContain('0'); + expect(result[0]).toContain('32'); + expect(result[0]).toContain(''); // Second XML block (at the end) - expect(result[1]).toContain('') - expect(result[1]).toContain('0') - expect(result[1]).toContain('32') - expect(result[1]).toContain('') - }) + expect(result[1]).toContain(''); + expect(result[1]).toContain('0'); + expect(result[1]).toContain('32'); + expect(result[1]).toContain(''); + }); it('should handle malformed XML gracefully', () => { - const result = extractXMLFromString(malformedXml) + const result = extractXMLFromString(malformedXml); // Should extract valid XML blocks (ignores malformed ones) - expect(result.length).toBeGreaterThanOrEqual(1) + expect(result.length).toBeGreaterThanOrEqual(1); // Find the definitely valid block - const validBlock = result.find(block => block.includes('')) - expect(validBlock).toBeDefined() - expect(validBlock).toContain('This is valid') - }) + const validBlock = result.find(block => block.includes('')); + expect(validBlock).toBeDefined(); + expect(validBlock).toContain('This is valid'); + }); it('should return empty array for content with no XML', () => { - const result = extractXMLFromString(noXmlContent) + const result = extractXMLFromString(noXmlContent); - expect(result).toHaveLength(0) - expect(result).toEqual([]) - }) + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); it('should handle empty and whitespace XML', () => { - const result = extractXMLFromString(emptyAndWhitespaceXml) + const result = extractXMLFromString(emptyAndWhitespaceXml); - expect(result).toHaveLength(3) - expect(result[0]).toBe('') - expect(result[1]).toContain('') - expect(result[1]).toContain('') - expect(result[2]).toContain('') - expect(result[2]).toContain('Some text with spaces') - expect(result[2]).toContain('') - }) + expect(result).toHaveLength(3); + expect(result[0]).toBe(''); + expect(result[1]).toContain(''); + expect(result[1]).toContain(''); + expect(result[2]).toContain(''); + expect(result[2]).toContain('Some text with spaces'); + expect(result[2]).toContain(''); + }); it('should handle XML with underscores and hyphens in tag names', () => { - const input = `content and content` - const result = extractXMLFromString(input) + const input = `content and content`; + const result = extractXMLFromString(input); - expect(result).toHaveLength(2) - expect(result[0]).toBe('content') - expect(result[1]).toBe('content') - }) + expect(result).toHaveLength(2); + expect(result[0]).toBe('content'); + expect(result[1]).toBe('content'); + }); it('should handle self-closing tags (not currently supported)', () => { - const input = ` and content` - const result = extractXMLFromString(input) + const input = ` and content`; + const result = extractXMLFromString(input); // Current implementation doesn't support self-closing tags - expect(result).toHaveLength(1) - expect(result[0]).toBe('content') - }) + expect(result).toHaveLength(1); + expect(result[0]).toBe('content'); + }); it('should trim whitespace around extracted XML', () => { - const input = ` content ` - const result = extractXMLFromString(input) + const input = ` content `; + const result = extractXMLFromString(input); - expect(result).toHaveLength(1) - expect(result[0]).toBe('content') - }) - }) + expect(result).toHaveLength(1); + expect(result[0]).toBe('content'); + }); + }); describe('wrapXmlBlocksInContent', () => { it('should wrap single XML block in fenced code block', () => { - const input = `Here is content in text.` - const result = wrapXmlBlocksInContent(input) + const input = `Here is content in text.`; + const result = wrapXmlBlocksInContent(input); - expect(result).toBe('Here is ```xml\ncontent\n``` in text.') - }) + expect(result).toBe('Here is ```xml\ncontent\n``` in text.'); + }); it('should wrap multiple XML blocks', () => { - const input = `content1 text content2` - const result = wrapXmlBlocksInContent(input) + const input = `content1 text content2`; + const result = wrapXmlBlocksInContent(input); - expect(result).toContain('```xml\ncontent1\n```') - expect(result).toContain('```xml\ncontent2\n```') - }) + expect(result).toContain('```xml\ncontent1\n```'); + expect(result).toContain('```xml\ncontent2\n```'); + }); it('should return original content when no XML blocks present', () => { - const input = noXmlContent - const result = wrapXmlBlocksInContent(input) + const input = noXmlContent; + const result = wrapXmlBlocksInContent(input); - expect(result).toBe(input) - }) + expect(result).toBe(input); + }); it('should handle empty input', () => { - expect(wrapXmlBlocksInContent('')).toBe('') - expect(wrapXmlBlocksInContent(null as any)).toBeNull() - expect(wrapXmlBlocksInContent(undefined as any)).toBeUndefined() - }) + expect(wrapXmlBlocksInContent('')).toBe(''); + expect(wrapXmlBlocksInContent(null as any)).toBeNull(); + expect(wrapXmlBlocksInContent(undefined as any)).toBeUndefined(); + }); it('should wrap XML blocks from multipleXmlBlocks fixture', () => { - const result = wrapXmlBlocksInContent(multipleXmlBlocks) + const result = wrapXmlBlocksInContent(multipleXmlBlocks); - expect(result).toContain('```xml\n') - expect(result).toContain('\n```') - expect(result).toContain('```xml\n') - expect(result).toContain('\n```') - expect(result).toContain('```xml\n') - expect(result).toContain('\n```') + expect(result).toContain('```xml\n'); + expect(result).toContain('\n```'); + expect(result).toContain('```xml\n'); + expect(result).toContain('\n```'); + expect(result).toContain('```xml\n'); + expect(result).toContain('\n```'); // Should preserve the surrounding text - expect(result).toContain('Here\'s how to add multiple musical elements:') - expect(result).toContain('First, let\'s add some notes:') - expect(result).toContain('Then we can read the current music:') - }) + expect(result).toContain('Here\'s how to add multiple musical elements:'); + expect(result).toContain('First, let\'s add some notes:'); + expect(result).toContain('Then we can read the current music:'); + }); it('should wrap the long add_notes XML block (Case 1)', () => { - const result = wrapXmlBlocksInContent(longTextWithAddNotes) + const result = wrapXmlBlocksInContent(longTextWithAddNotes); - expect(result).toContain('```xml\n') - expect(result).toContain('\n```') + expect(result).toContain('```xml\n'); + expect(result).toContain('\n```'); // Should preserve the thinking content and other text - expect(result).toContain('') - expect(result).toContain('Perfect! I can see this is a beautiful') - expect(result).toContain('Let me start by adding the harmony') - }) + expect(result).toContain(''); + expect(result).toContain('Perfect! I can see this is a beautiful'); + expect(result).toContain('Let me start by adding the harmony'); + }); it('should wrap the long read_music XML blocks (Case 2)', () => { - const result = wrapXmlBlocksInContent(longTextWithReadMusic) + const result = wrapXmlBlocksInContent(longTextWithReadMusic); // Should contain two wrapped XML blocks - const fencedBlocks = result.match(/```xml\n[\s\S]*?<\/read_music>\n```/g) - expect(fencedBlocks).toHaveLength(2) + const fencedBlocks = result.match(/```xml\n[\s\S]*?<\/read_music>\n```/g); + expect(fencedBlocks).toHaveLength(2); // Should preserve the thinking content and other text - expect(result).toContain('') - expect(result).toContain('I\'ll help you create a pad harmony track') - expect(result).toContain('Let me first read the existing music') - }) + expect(result).toContain(''); + expect(result).toContain('I\'ll help you create a pad harmony track'); + expect(result).toContain('Let me first read the existing music'); + }); it('should handle nested XML correctly', () => { - const result = wrapXmlBlocksInContent(nestedXmlContent) + const result = wrapXmlBlocksInContent(nestedXmlContent); - expect(result).toContain('```xml\n') - expect(result).toContain('') - expect(result).toContain('') - expect(result).toContain('Test Content') - expect(result).toContain('\n```') - }) + expect(result).toContain('```xml\n'); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('Test Content'); + expect(result).toContain('\n```'); + }); it('should preserve XML block integrity when wrapping', () => { - const input = `Text before\n\n value\n content\n\nText after` - const result = wrapXmlBlocksInContent(input) + const input = `Text before\n\n value\n content\n\nText after`; + const result = wrapXmlBlocksInContent(input); - expect(result).toBe(`Text before\n\`\`\`xml\n\n value\n content\n\n\`\`\`\nText after`) - }) + expect(result).toBe(`Text before\n\`\`\`xml\n\n value\n content\n\n\`\`\`\nText after`); + }); it('should handle duplicate XML blocks correctly', () => { - const input = `content and then content again` - const result = wrapXmlBlocksInContent(input) + const input = `content and then content again`; + const result = wrapXmlBlocksInContent(input); // Both instances should be wrapped - const wrappedBlocks = result.match(/```xml\ncontent<\/same>\n```/g) - expect(wrappedBlocks).toHaveLength(2) - }) - }) + const wrappedBlocks = result.match(/```xml\ncontent<\/same>\n```/g); + expect(wrappedBlocks).toHaveLength(2); + }); + }); describe('edge cases and error handling', () => { it('should handle very large XML blocks', () => { - const largeContent = 'x'.repeat(10000) - const input = `${largeContent}` + const largeContent = 'x'.repeat(10000); + const input = `${largeContent}`; - const extracted = extractXMLFromString(input) - expect(extracted).toHaveLength(1) - expect(extracted[0]).toContain(largeContent) + const extracted = extractXMLFromString(input); + expect(extracted).toHaveLength(1); + expect(extracted[0]).toContain(largeContent); - const wrapped = wrapXmlBlocksInContent(input) - expect(wrapped).toContain('```xml\n') - expect(wrapped).toContain('\n```') - }) + const wrapped = wrapXmlBlocksInContent(input); + expect(wrapped).toContain('```xml\n'); + expect(wrapped).toContain('\n```'); + }); it('should handle XML with special characters', () => { - const input = `Content with < > & " '` + const input = `Content with < > & " '`; - const extracted = extractXMLFromString(input) - expect(extracted).toHaveLength(1) - expect(extracted[0]).toContain('< > & " '') + const extracted = extractXMLFromString(input); + expect(extracted).toHaveLength(1); + expect(extracted[0]).toContain('< > & " ''); - const wrapped = wrapXmlBlocksInContent(input) - expect(wrapped).toContain('```xml\nContent with < > & " '\n```') - }) + const wrapped = wrapXmlBlocksInContent(input); + expect(wrapped).toContain('```xml\nContent with < > & " '\n```'); + }); it('should handle XML with CDATA sections', () => { - const input = ` chars]]>` + const input = ` chars]]>`; - const extracted = extractXMLFromString(input) - expect(extracted).toHaveLength(1) - expect(extracted[0]).toContain('') - }) + const extracted = extractXMLFromString(input); + expect(extracted).toHaveLength(1); + expect(extracted[0]).toContain(''); + }); it('should handle mixed content with partial XML-like text', () => { - const input = `This < is not XML and neither > is this content is valid` + const input = `This < is not XML and neither > is this content is valid`; - const extracted = extractXMLFromString(input) - expect(extracted).toHaveLength(1) - expect(extracted[0]).toBe('content') - }) - }) -}) \ No newline at end of file + const extracted = extractXMLFromString(input); + expect(extracted).toHaveLength(1); + expect(extracted[0]).toBe('content'); + }); + }); +}); \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 54eaa07..dbb4c62 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,3 +1,3 @@ /// -declare const __APP_VERSION__: string +declare const __APP_VERSION__: string;