Merge pull request #19 from KGAudioLab/test/2025-09-03-initial-unit-tests
Test/2025 09 03 initial unit tests
This commit is contained in:
@@ -9,6 +9,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
res/
|
res/
|
||||||
|
coverage/
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|||||||
Generated
+1703
-9
File diff suppressed because it is too large
Load Diff
+15
-4
@@ -7,7 +7,11 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -24,20 +28,27 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
|
"@semantic-release/git": "^10.0.1",
|
||||||
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^22.9.3",
|
"@types/node": "^22.9.3",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"@semantic-release/git": "^10.0.1",
|
"@vitest/ui": "^3.2.4",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.2.0",
|
"globals": "^16.2.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"semantic-release": "^21.1.2",
|
"semantic-release": "^21.1.2",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.34.1",
|
"typescript-eslint": "^8.34.1",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,11 @@ body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-dropdown .quant-dropdown {
|
||||||
|
width: 250px;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Main content */
|
/* Main content */
|
||||||
.main-content {
|
.main-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
FaCog
|
FaCog
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||||
import { plainToClass, instanceToPlain } from 'class-transformer';
|
import { plainToInstance, instanceToPlain } from 'class-transformer';
|
||||||
import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
|
import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
|
||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||||
@@ -315,9 +315,9 @@ const Toolbar: React.FC = () => {
|
|||||||
const projectData = JSON.parse(fileContent);
|
const projectData = JSON.parse(fileContent);
|
||||||
|
|
||||||
// Deserialize the project data using class-transformer (same as KGStorage)
|
// Deserialize the project data using class-transformer (same as KGStorage)
|
||||||
const deserializedResult = plainToClass(KGProject, projectData);
|
const deserializedResult = plainToInstance(KGProject, projectData);
|
||||||
|
|
||||||
// Handle case where plainToClass might return an array
|
// Handle case where plainToInstance might return an array
|
||||||
const deserializedProject = Array.isArray(deserializedResult)
|
const deserializedProject = Array.isArray(deserializedResult)
|
||||||
? deserializedResult[0] || null
|
? deserializedResult[0] || null
|
||||||
: deserializedResult;
|
: deserializedResult;
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
interface MockCore {
|
||||||
|
getCurrentProject: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CreateNoteCommand', () => {
|
||||||
|
let mockCore: MockCore
|
||||||
|
let mockProject: ReturnType<typeof createMockProject>
|
||||||
|
let mockTrack: ReturnType<typeof createMockMidiTrack>
|
||||||
|
let mockRegion: ReturnType<typeof createMockMidiRegion>
|
||||||
|
let command: CreateNoteCommand
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create test data
|
||||||
|
mockRegion = createMockMidiRegion({
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Create command
|
||||||
|
command = new CreateNoteCommand(
|
||||||
|
'test-region', // regionId
|
||||||
|
0, // startBeat
|
||||||
|
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')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// The generateUniqueId mock should have been called
|
||||||
|
expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use provided ID when given', () => {
|
||||||
|
// Clear previous calls
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id')
|
||||||
|
|
||||||
|
// Should not call generateUniqueId when ID is provided
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Execute the command
|
||||||
|
command.execute()
|
||||||
|
|
||||||
|
// Verify note was added
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Should throw error for non-existent region
|
||||||
|
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')
|
||||||
|
|
||||||
|
command.execute()
|
||||||
|
|
||||||
|
// Note should be created and stored internally for undo
|
||||||
|
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('undo', () => {
|
||||||
|
it('should remove the created note', () => {
|
||||||
|
// Execute first to create the note
|
||||||
|
command.execute()
|
||||||
|
|
||||||
|
// Mock removeNote method
|
||||||
|
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote')
|
||||||
|
|
||||||
|
// Undo the command
|
||||||
|
command.undo()
|
||||||
|
|
||||||
|
// Verify note was removed
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
|
||||||
|
command.execute() // Commands are re-executed for redo
|
||||||
|
|
||||||
|
// Note should be added again
|
||||||
|
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDescription', () => {
|
||||||
|
it('should return descriptive text', () => {
|
||||||
|
const description = command.getDescription()
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Execute -> Undo -> Execute -> Undo
|
||||||
|
command.execute()
|
||||||
|
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
command.undo()
|
||||||
|
expect(removeNoteSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
command.execute() // Re-execute for redo
|
||||||
|
expect(addNoteSpy).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
command.undo()
|
||||||
|
expect(removeNoteSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { openDB } from 'idb'
|
import { openDB } from 'idb'
|
||||||
import type { IDBPDatabase } from 'idb'
|
import type { IDBPDatabase } from 'idb'
|
||||||
import { plainToClass, instanceToPlain } from 'class-transformer'
|
import { plainToInstance, instanceToPlain } from 'class-transformer'
|
||||||
import { DB_CONSTANTS } from '../../constants/coreConstants'
|
import { DB_CONSTANTS } from '../../constants/coreConstants'
|
||||||
|
|
||||||
export interface StorageEntry {
|
export interface StorageEntry {
|
||||||
@@ -96,7 +96,7 @@ export class KGStorage {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = plainToClass(classType, entry.data)
|
const instance = plainToInstance(classType, entry.data)
|
||||||
const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
|
const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
|
||||||
|
|
||||||
if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
|
if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { KGMidiNote } from './KGMidiNote'
|
||||||
|
|
||||||
|
describe('KGMidiNote', () => {
|
||||||
|
let note: KGMidiNote
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
note.setStartBeat(1.5)
|
||||||
|
expect(note.getStartBeat()).toBe(1.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get and set end beat', () => {
|
||||||
|
expect(note.getEndBeat()).toBe(1)
|
||||||
|
|
||||||
|
note.setEndBeat(3.5)
|
||||||
|
expect(note.getEndBeat()).toBe(3.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get and set pitch', () => {
|
||||||
|
expect(note.getPitch()).toBe(60)
|
||||||
|
|
||||||
|
note.setPitch(72)
|
||||||
|
expect(note.getPitch()).toBe(72)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get and set velocity', () => {
|
||||||
|
expect(note.getVelocity()).toBe(80)
|
||||||
|
|
||||||
|
note.setVelocity(100)
|
||||||
|
expect(note.getVelocity()).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get and set ID', () => {
|
||||||
|
expect(note.getId()).toBe('test-note')
|
||||||
|
|
||||||
|
note.setId('new-id')
|
||||||
|
expect(note.getId()).toBe('new-id')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selection', () => {
|
||||||
|
it('should start unselected', () => {
|
||||||
|
expect(note.isSelected()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select and deselect', () => {
|
||||||
|
note.select()
|
||||||
|
expect(note.isSelected()).toBe(true)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle zero duration', () => {
|
||||||
|
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(127)
|
||||||
|
expect(note.getPitch()).toBe(127)
|
||||||
|
|
||||||
|
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(127)
|
||||||
|
expect(note.getVelocity()).toBe(127)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
expect(note1.getId()).not.toBe(note2.getId())
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle start beat after end beat', () => {
|
||||||
|
note.setStartBeat(5)
|
||||||
|
note.setEndBeat(2)
|
||||||
|
|
||||||
|
expect(note.getStartBeat()).toBe(5)
|
||||||
|
expect(note.getEndBeat()).toBe(2)
|
||||||
|
// Note: The class might need validation logic to prevent this
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
region = new KGMidiRegion(
|
||||||
|
'test-region-1',
|
||||||
|
'test-track-1',
|
||||||
|
0,
|
||||||
|
'Test Region',
|
||||||
|
0,
|
||||||
|
4
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create a MIDI region with correct properties', () => {
|
||||||
|
const testRegion = new KGMidiRegion(
|
||||||
|
'region-1',
|
||||||
|
'track-1',
|
||||||
|
2,
|
||||||
|
'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([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use default values for optional parameters', () => {
|
||||||
|
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([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set the correct type identifier', () => {
|
||||||
|
expect(region.getCurrentType()).toBe('KGMidiRegion')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('note management', () => {
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addNote', () => {
|
||||||
|
it('should add a single note to empty region', () => {
|
||||||
|
region.addNote(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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove note by ID', () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle removing non-existent note gracefully', () => {
|
||||||
|
const initialLength = region.getNotes().length
|
||||||
|
|
||||||
|
region.removeNote('non-existent-note')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
expect(region.getNotes()).toHaveLength(4)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle removing from empty region', () => {
|
||||||
|
const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region')
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return all notes in region', () => {
|
||||||
|
region.addNote(note1)
|
||||||
|
region.addNote(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()
|
||||||
|
|
||||||
|
expect(notes1).toBe(notes2) // Same reference
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setNotes', () => {
|
||||||
|
it('should replace all notes with new array', () => {
|
||||||
|
region.addNote(note1)
|
||||||
|
region.addNote(note2)
|
||||||
|
|
||||||
|
expect(region.getNotes()).toHaveLength(2)
|
||||||
|
|
||||||
|
region.setNotes([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.setNotes([])
|
||||||
|
|
||||||
|
expect(region.getNotes()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept notes array with multiple notes', () => {
|
||||||
|
const newNotes = [note1, note2, note3]
|
||||||
|
region.setNotes(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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should inherit selection functionality', () => {
|
||||||
|
expect(region.isSelected()).toBe(false)
|
||||||
|
|
||||||
|
region.select()
|
||||||
|
expect(region.isSelected()).toBe(true)
|
||||||
|
|
||||||
|
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.setStartFromBeat(8)
|
||||||
|
expect(region.getStartFromBeat()).toBe(8)
|
||||||
|
|
||||||
|
region.setLength(12)
|
||||||
|
expect(region.getLength()).toBe(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('type identification', () => {
|
||||||
|
it('should return correct current type', () => {
|
||||||
|
expect(region.getCurrentType()).toBe('KGMidiRegion')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return correct root type', () => {
|
||||||
|
expect(region.getRootType()).toBe('KGRegion')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be instanceof both KGMidiRegion and 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 })
|
||||||
|
|
||||||
|
region.addNote(overlappingNote1)
|
||||||
|
region.addNote(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 })
|
||||||
|
|
||||||
|
region.addNote(sameNote1)
|
||||||
|
region.addNote(sameNote2)
|
||||||
|
|
||||||
|
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 })
|
||||||
|
|
||||||
|
region.addNote(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 })
|
||||||
|
|
||||||
|
zeroRegion.addNote(note)
|
||||||
|
|
||||||
|
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 })
|
||||||
|
|
||||||
|
region.addNote(originalNote)
|
||||||
|
const retrievedNote = region.getNotes()[0]
|
||||||
|
|
||||||
|
expect(retrievedNote).toBe(originalNote) // Same reference
|
||||||
|
|
||||||
|
// Modify original note
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Remove middle note
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
track = new KGMidiTrack()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create track with default values', () => {
|
||||||
|
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.8) // DEFAULT_TRACK_VOLUME
|
||||||
|
expect(defaultTrack.getRegions()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create track with custom parameters', () => {
|
||||||
|
const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', 0.6)
|
||||||
|
|
||||||
|
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(0.6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set correct type identifier', () => {
|
||||||
|
expect(track.getCurrentType()).toBe('KGMidiTrack')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should inherit from 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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return current instrument', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setInstrument', () => {
|
||||||
|
it('should update instrument', () => {
|
||||||
|
track.setInstrument('violin')
|
||||||
|
expect(track.getInstrument()).toBe('violin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle different instrument types', () => {
|
||||||
|
const instruments: InstrumentType[] = [
|
||||||
|
'acoustic_grand_piano',
|
||||||
|
'electric_piano_1',
|
||||||
|
'electric_guitar_clean',
|
||||||
|
'acoustic_bass',
|
||||||
|
'violin',
|
||||||
|
'trumpet',
|
||||||
|
'flute'
|
||||||
|
]
|
||||||
|
|
||||||
|
instruments.forEach(instrument => {
|
||||||
|
track.setInstrument(instrument)
|
||||||
|
expect(track.getInstrument()).toBe(instrument)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle rapid instrument changes', () => {
|
||||||
|
track.setInstrument('piano')
|
||||||
|
track.setInstrument('guitar')
|
||||||
|
track.setInstrument('violin')
|
||||||
|
|
||||||
|
expect(track.getInstrument()).toBe('violin')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('region management', () => {
|
||||||
|
let region1: KGMidiRegion
|
||||||
|
let region2: KGMidiRegion
|
||||||
|
let region3: KGMidiRegion
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
region1 = createMockMidiRegion({
|
||||||
|
id: 'region-1',
|
||||||
|
trackId: track.getId().toString(),
|
||||||
|
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)
|
||||||
|
|
||||||
|
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([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([])
|
||||||
|
|
||||||
|
expect(track.getRegions()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enforce KGMidiRegion type', () => {
|
||||||
|
const regions: KGMidiRegion[] = [region1, region2]
|
||||||
|
track.setRegions(regions)
|
||||||
|
|
||||||
|
const retrievedRegions = track.getRegions()
|
||||||
|
retrievedRegions.forEach(region => {
|
||||||
|
expect(region).toBeInstanceOf(KGMidiRegion)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inherited region methods', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
track.setRegions([region1, region2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should inherit addRegion method', () => {
|
||||||
|
track.addRegion(region3)
|
||||||
|
|
||||||
|
const regions = track.getRegions()
|
||||||
|
expect(regions).toHaveLength(3)
|
||||||
|
expect(regions).toContain(region3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should inherit removeRegion method', () => {
|
||||||
|
track.removeRegion('region-1')
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inheritance from KGTrack', () => {
|
||||||
|
it('should inherit all base track properties', () => {
|
||||||
|
const customTrack = new KGMidiTrack('Test Track', 42, 'violin', 0.9)
|
||||||
|
|
||||||
|
expect(customTrack.getName()).toBe('Test Track')
|
||||||
|
expect(customTrack.getId()).toBe(42)
|
||||||
|
expect(customTrack.getType()).toBe(TrackType.MIDI)
|
||||||
|
expect(customTrack.getVolume()).toBe(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should inherit base track setters', () => {
|
||||||
|
track.setName('Updated Track')
|
||||||
|
expect(track.getName()).toBe('Updated Track')
|
||||||
|
|
||||||
|
track.setVolume(0.5)
|
||||||
|
expect(track.getVolume()).toBe(0.5)
|
||||||
|
|
||||||
|
track.setTrackIndex(3)
|
||||||
|
expect(track.getTrackIndex()).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should inherit volume controls', () => {
|
||||||
|
expect(track.getVolume()).toBe(0.8) // Default volume
|
||||||
|
|
||||||
|
track.setVolume(0.5)
|
||||||
|
expect(track.getVolume()).toBe(0.5)
|
||||||
|
|
||||||
|
track.setVolume(1.0)
|
||||||
|
expect(track.getVolume()).toBe(1.0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('type identification', () => {
|
||||||
|
it('should return correct current type', () => {
|
||||||
|
expect(track.getCurrentType()).toBe('KGMidiTrack')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return correct root type', () => {
|
||||||
|
expect(track.getRootType()).toBe('KGTrack')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have MIDI track type', () => {
|
||||||
|
expect(track.getType()).toBe(TrackType.MIDI)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('instrument type validation', () => {
|
||||||
|
it('should handle all valid General MIDI instruments', () => {
|
||||||
|
// Test a selection of valid GM instruments
|
||||||
|
const validInstruments: InstrumentType[] = [
|
||||||
|
'acoustic_grand_piano',
|
||||||
|
'electric_piano_1',
|
||||||
|
'electric_piano_2',
|
||||||
|
'electric_guitar_clean',
|
||||||
|
'electric_guitar_muted',
|
||||||
|
'acoustic_bass',
|
||||||
|
'electric_bass_finger',
|
||||||
|
'violin',
|
||||||
|
'viola',
|
||||||
|
'cello',
|
||||||
|
'contrabass',
|
||||||
|
'trumpet',
|
||||||
|
'trombone',
|
||||||
|
'french_horn',
|
||||||
|
'flute',
|
||||||
|
'clarinet',
|
||||||
|
'soprano_sax',
|
||||||
|
'alto_sax'
|
||||||
|
]
|
||||||
|
|
||||||
|
validInstruments.forEach(instrument => {
|
||||||
|
expect(() => {
|
||||||
|
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(0.7)
|
||||||
|
|
||||||
|
const regions = [
|
||||||
|
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
|
||||||
|
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
|
||||||
|
]
|
||||||
|
track.setRegions(regions)
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
expect(track.getName()).toBe('Piano Track')
|
||||||
|
expect(track.getInstrument()).toBe('acoustic_grand_piano')
|
||||||
|
expect(track.getVolume()).toBe(0.7)
|
||||||
|
expect(track.getRegions()).toHaveLength(2)
|
||||||
|
|
||||||
|
// Modify state
|
||||||
|
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(0.7)
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Verify region is in track
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Verify region properties
|
||||||
|
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('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle negative track ID', () => {
|
||||||
|
const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano')
|
||||||
|
expect(negativeIdTrack.getId()).toBe(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle volume boundaries', () => {
|
||||||
|
track.setVolume(0.0)
|
||||||
|
expect(track.getVolume()).toBe(0.0)
|
||||||
|
|
||||||
|
track.setVolume(1.0)
|
||||||
|
expect(track.getVolume()).toBe(1.0)
|
||||||
|
|
||||||
|
// Volume outside normal range (should still work)
|
||||||
|
track.setVolume(1.5)
|
||||||
|
expect(track.getVolume()).toBe(1.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle large number of regions', () => {
|
||||||
|
const manyRegions = Array.from({ length: 100 }, (_, i) =>
|
||||||
|
createMockMidiRegion({
|
||||||
|
id: `region-${i}`,
|
||||||
|
trackId: track.getId().toString(),
|
||||||
|
name: `Region ${i}`
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Test that default instrument fallback works
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Simulate what happens during serialization/deserialization
|
||||||
|
const retrievedRegions = track.getRegions()
|
||||||
|
expect(retrievedRegions).toHaveLength(2)
|
||||||
|
retrievedRegions.forEach(region => {
|
||||||
|
expect(region).toBeInstanceOf(KGMidiRegion)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Vendored
+299
@@ -0,0 +1,299 @@
|
|||||||
|
{
|
||||||
|
"name": "joy",
|
||||||
|
"maxBars": 32,
|
||||||
|
"currentBars": 0,
|
||||||
|
"timeSignature": {
|
||||||
|
"numerator": 4,
|
||||||
|
"denominator": 4
|
||||||
|
},
|
||||||
|
"bpm": 125,
|
||||||
|
"keySignature": "C major",
|
||||||
|
"projectStructureVersion": 1,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"__type": "KGMidiTrack",
|
||||||
|
"name": "Melody",
|
||||||
|
"id": 1,
|
||||||
|
"trackIndex": 0,
|
||||||
|
"type": "MIDI",
|
||||||
|
"volume": 0.8,
|
||||||
|
"regions": [
|
||||||
|
{
|
||||||
|
"__type": "KGMidiRegion",
|
||||||
|
"id": "KGMidiRegion_1755134493138_iufo370n9",
|
||||||
|
"trackId": "1",
|
||||||
|
"trackIndex": 0,
|
||||||
|
"name": "Melody Region 1",
|
||||||
|
"startFromBeat": 0,
|
||||||
|
"length": 32,
|
||||||
|
"selected": true,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134524628_dfmfpzoa9",
|
||||||
|
"startBeat": 0,
|
||||||
|
"endBeat": 1,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134528480_2uml69rkg",
|
||||||
|
"startBeat": 1,
|
||||||
|
"endBeat": 2,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134530480_4r34d57ig",
|
||||||
|
"startBeat": 2,
|
||||||
|
"endBeat": 3,
|
||||||
|
"pitch": 65,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134531066_zahyopqtg",
|
||||||
|
"startBeat": 3,
|
||||||
|
"endBeat": 4,
|
||||||
|
"pitch": 67,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134531647_65jgmc4m2",
|
||||||
|
"startBeat": 4,
|
||||||
|
"endBeat": 5,
|
||||||
|
"pitch": 67,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134532248_mnd24k18e",
|
||||||
|
"startBeat": 5,
|
||||||
|
"endBeat": 6,
|
||||||
|
"pitch": 65,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134532815_506vblsde",
|
||||||
|
"startBeat": 6,
|
||||||
|
"endBeat": 7,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134533716_ah1hw0kfe",
|
||||||
|
"startBeat": 7,
|
||||||
|
"endBeat": 8,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134534566_m8bunoc6t",
|
||||||
|
"startBeat": 8,
|
||||||
|
"endBeat": 9,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134536265_7qeciq0m6",
|
||||||
|
"startBeat": 9,
|
||||||
|
"endBeat": 10,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134537068_o6bb5y7ha",
|
||||||
|
"startBeat": 10,
|
||||||
|
"endBeat": 11,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134537752_ya958b30e",
|
||||||
|
"startBeat": 11,
|
||||||
|
"endBeat": 12,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134538401_bzer7pq8h",
|
||||||
|
"startBeat": 12,
|
||||||
|
"endBeat": 13.5,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134541568_so9s6pa1u",
|
||||||
|
"startBeat": 13.5,
|
||||||
|
"endBeat": 14,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134545236_r9xnsoggt",
|
||||||
|
"startBeat": 14,
|
||||||
|
"endBeat": 16,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_idvq2hr0t",
|
||||||
|
"startBeat": 16,
|
||||||
|
"endBeat": 17,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_i6za508pa",
|
||||||
|
"startBeat": 17,
|
||||||
|
"endBeat": 18,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_0kru65kt6",
|
||||||
|
"startBeat": 18,
|
||||||
|
"endBeat": 19,
|
||||||
|
"pitch": 65,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_ayj4954ko",
|
||||||
|
"startBeat": 19,
|
||||||
|
"endBeat": 20,
|
||||||
|
"pitch": 67,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_5toqdpq5n",
|
||||||
|
"startBeat": 20,
|
||||||
|
"endBeat": 21,
|
||||||
|
"pitch": 67,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_yilvvambd",
|
||||||
|
"startBeat": 21,
|
||||||
|
"endBeat": 22,
|
||||||
|
"pitch": 65,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_r8w8tr21a",
|
||||||
|
"startBeat": 22,
|
||||||
|
"endBeat": 23,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_d8t91xm5l",
|
||||||
|
"startBeat": 23,
|
||||||
|
"endBeat": 24,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_hk0q0ow9q",
|
||||||
|
"startBeat": 24,
|
||||||
|
"endBeat": 25,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561530_gnl53ldaz",
|
||||||
|
"startBeat": 25,
|
||||||
|
"endBeat": 26,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561531_53kdyl7u5",
|
||||||
|
"startBeat": 26,
|
||||||
|
"endBeat": 27,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561531_les717v12",
|
||||||
|
"startBeat": 27,
|
||||||
|
"endBeat": 28,
|
||||||
|
"pitch": 64,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561531_owdko4big",
|
||||||
|
"startBeat": 28,
|
||||||
|
"endBeat": 29.5,
|
||||||
|
"pitch": 62,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561531_b04zbsgnf",
|
||||||
|
"startBeat": 29.5,
|
||||||
|
"endBeat": 30,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "KGMidiNote_1755134561531_jgvrlacer",
|
||||||
|
"startBeat": 30,
|
||||||
|
"endBeat": 32,
|
||||||
|
"pitch": 60,
|
||||||
|
"velocity": 127,
|
||||||
|
"selected": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"instrument": "acoustic_grand_piano"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__type": "KGMidiTrack",
|
||||||
|
"name": "Pad Chord",
|
||||||
|
"id": 2,
|
||||||
|
"trackIndex": 1,
|
||||||
|
"type": "MIDI",
|
||||||
|
"volume": 1,
|
||||||
|
"regions": [
|
||||||
|
{
|
||||||
|
"__type": "KGMidiRegion",
|
||||||
|
"id": "KGMidiRegion_1755326973407_9hsnkcgwr",
|
||||||
|
"trackId": "2",
|
||||||
|
"trackIndex": 1,
|
||||||
|
"name": "Pad Chord Region 1",
|
||||||
|
"startFromBeat": 0,
|
||||||
|
"length": 32,
|
||||||
|
"selected": true,
|
||||||
|
"notes": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"instrument": "pad_1_new_age"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+197
@@ -0,0 +1,197 @@
|
|||||||
|
// Test fixtures for XML utility testing
|
||||||
|
|
||||||
|
export const longTextWithAddNotes = `<thinking>
|
||||||
|
Now I can see the melody! This is a beautiful classical-style melody in C major. Let me analyze it:
|
||||||
|
|
||||||
|
First 4 bars (beats 0-16):
|
||||||
|
- Bar 1: E E F G (likely suggesting C major - Am - F - C progressions)
|
||||||
|
- Bar 2: G F E D (descending line, could work with G - F - C)
|
||||||
|
- Bar 3: C C D E (starting on tonic, building up)
|
||||||
|
- Bar 4: E3/2 D1/2 D2 (resolution to D, suggesting dominant function)
|
||||||
|
|
||||||
|
Last 4 bars (beats 16-32):
|
||||||
|
- Bar 5: E E F G (similar to bar 1)
|
||||||
|
- Bar 6: G F E D (similar to bar 2)
|
||||||
|
- Bar 7: C C D E (similar to bar 3)
|
||||||
|
- Bar 8: D3/2 C1/2 C2 (final resolution to C)
|
||||||
|
|
||||||
|
This looks like "Ode to Joy" or a similar classical melody. For the harmony, I should:
|
||||||
|
|
||||||
|
1. First 4 bars (soft and light): Use simple triads in root position or gentle inversions
|
||||||
|
2. Last 4 bars (fuller and brighter): Add some extensions, maybe use inversions for smoother voice leading, and possibly add an additional voice
|
||||||
|
|
||||||
|
Let me think about chord progressions:
|
||||||
|
- Bar 1 (E E F G): C major works well
|
||||||
|
- Bar 2 (G F E D): G major (dominant)
|
||||||
|
- Bar 3 (C C D E): F major or Am could work
|
||||||
|
- Bar 4 (E D D): G major resolving the phrase
|
||||||
|
|
||||||
|
For the second half, I can use similar progressions but make them richer.
|
||||||
|
|
||||||
|
I'll start with the first 4 bars, keeping them light with simple triads in a comfortable mid-range.
|
||||||
|
</thinking>
|
||||||
|
|
||||||
|
Perfect! I can see this is a beautiful classical-style melody. Let me analyze the harmonic structure and create a supportive pad harmony track.
|
||||||
|
|
||||||
|
For the first four bars, I'll use gentle, simple triads to support the melody without overpowering it. For the last four bars, I'll add more fullness and brightness while maintaining harmonic support.
|
||||||
|
|
||||||
|
Let me start by adding the harmony for the first four bars (beats 0-16):
|
||||||
|
|
||||||
|
<add_notes>
|
||||||
|
<notes>
|
||||||
|
<note>
|
||||||
|
<pitch>C4</pitch>
|
||||||
|
<start_beat>0</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>E4</pitch>
|
||||||
|
<start_beat>0</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>G4</pitch>
|
||||||
|
<start_beat>0</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>G3</pitch>
|
||||||
|
<start_beat>4</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>B3</pitch>
|
||||||
|
<start_beat>4</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>D4</pitch>
|
||||||
|
<start_beat>4</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>A3</pitch>
|
||||||
|
<start_beat>8</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>C4</pitch>
|
||||||
|
<start_beat>8</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>E4</pitch>
|
||||||
|
<start_beat>8</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>G3</pitch>
|
||||||
|
<start_beat>12</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>B3</pitch>
|
||||||
|
<start_beat>12</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>D4</pitch>
|
||||||
|
<start_beat>12</start_beat>
|
||||||
|
<length>4</length>
|
||||||
|
</note>
|
||||||
|
</notes>
|
||||||
|
</add_notes>`;
|
||||||
|
|
||||||
|
export const longTextWithReadMusic = `<thinking>
|
||||||
|
I need to create a pad harmony track for an existing melody. First, I should read the music to see what melody I'm working with, then analyze its harmonic structure to create appropriate pad chords. The user wants the first four bars (beats 0-16) to be soft and light, and the last four bars (beats 16-32) to be fuller and brighter while remaining supportive.
|
||||||
|
<read_music>
|
||||||
|
<start_beat>0</start_beat>
|
||||||
|
<length>32</length>
|
||||||
|
</read_music>
|
||||||
|
Let me start by reading the current music to understand the melody I'm harmonizing.
|
||||||
|
</thinking>
|
||||||
|
|
||||||
|
I'll help you create a pad harmony track for your melody. Let me first read the existing music to understand the melodic content and structure.
|
||||||
|
|
||||||
|
<read_music>
|
||||||
|
<start_beat>0</start_beat>
|
||||||
|
<length>32</length>
|
||||||
|
</read_music>`;
|
||||||
|
|
||||||
|
export const multipleXmlBlocks = `Here's how to add multiple musical elements:
|
||||||
|
|
||||||
|
First, let's add some notes:
|
||||||
|
<add_notes>
|
||||||
|
<notes>
|
||||||
|
<note>
|
||||||
|
<pitch>C4</pitch>
|
||||||
|
<start_beat>0</start_beat>
|
||||||
|
<length>2</length>
|
||||||
|
</note>
|
||||||
|
</notes>
|
||||||
|
</add_notes>
|
||||||
|
|
||||||
|
Then we can read the current music:
|
||||||
|
<read_music>
|
||||||
|
<start_beat>0</start_beat>
|
||||||
|
<length>8</length>
|
||||||
|
</read_music>
|
||||||
|
|
||||||
|
Finally, let's modify the tempo:
|
||||||
|
<modify_tempo>
|
||||||
|
<bpm>120</bpm>
|
||||||
|
</modify_tempo>
|
||||||
|
|
||||||
|
That's how you work with multiple XML commands!`;
|
||||||
|
|
||||||
|
export const nestedXmlContent = `<container>
|
||||||
|
<inner_element>
|
||||||
|
<deep_nested>
|
||||||
|
<value>Test Content</value>
|
||||||
|
</deep_nested>
|
||||||
|
</inner_element>
|
||||||
|
<another_element>
|
||||||
|
<data>More content here</data>
|
||||||
|
</another_element>
|
||||||
|
</container>`;
|
||||||
|
|
||||||
|
export const xmlWithAttributes = `Here's an XML block with attributes:
|
||||||
|
|
||||||
|
<add_notes region_id="main" track="melody">
|
||||||
|
<notes>
|
||||||
|
<note id="1" velocity="127">
|
||||||
|
<pitch>C4</pitch>
|
||||||
|
<start_beat>0</start_beat>
|
||||||
|
<length>1</length>
|
||||||
|
</note>
|
||||||
|
</notes>
|
||||||
|
</add_notes>
|
||||||
|
|
||||||
|
That was an example with attributes.`;
|
||||||
|
|
||||||
|
export const malformedXml = `This contains some malformed XML:
|
||||||
|
<unclosed_tag>
|
||||||
|
<properly_closed>content</properly_closed>
|
||||||
|
<another_unclosed>
|
||||||
|
<mismatched_tag>content</wrong_tag>
|
||||||
|
|
||||||
|
But this should work:
|
||||||
|
<valid_tag>
|
||||||
|
<content>This is valid</content>
|
||||||
|
</valid_tag>`;
|
||||||
|
|
||||||
|
export const noXmlContent = `This is just plain text content without any XML blocks.
|
||||||
|
It contains some angle brackets like <this> and </that> but no complete XML elements.
|
||||||
|
Also some <incomplete tags and random characters.`;
|
||||||
|
|
||||||
|
export const emptyAndWhitespaceXml = `
|
||||||
|
<empty_tag></empty_tag>
|
||||||
|
|
||||||
|
<whitespace_tag>
|
||||||
|
|
||||||
|
</whitespace_tag>
|
||||||
|
|
||||||
|
<mixed_content>
|
||||||
|
Some text with spaces
|
||||||
|
</mixed_content>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock implementation of Tone.js for testing
|
||||||
|
* This provides consistent, deterministic behavior for audio-related tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock Sampler class
|
||||||
|
export const MockSampler = vi.fn().mockImplementation(() => ({
|
||||||
|
triggerAttackRelease: vi.fn(),
|
||||||
|
triggerAttack: vi.fn(),
|
||||||
|
triggerRelease: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
loaded: true,
|
||||||
|
volume: {
|
||||||
|
value: -12
|
||||||
|
},
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
toDestination: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock Transport object
|
||||||
|
export const MockTransport = {
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
pause: vi.fn(),
|
||||||
|
position: '0:0:0',
|
||||||
|
bpm: {
|
||||||
|
value: 120,
|
||||||
|
rampTo: vi.fn()
|
||||||
|
},
|
||||||
|
timeSignature: [4, 4],
|
||||||
|
state: 'stopped',
|
||||||
|
scheduleOnce: vi.fn(),
|
||||||
|
scheduleRepeat: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
clear: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock Destination
|
||||||
|
export const MockDestination = {
|
||||||
|
volume: {
|
||||||
|
value: -12
|
||||||
|
},
|
||||||
|
mute: false,
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock ToneAudioBuffer
|
||||||
|
export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
|
||||||
|
loaded: true,
|
||||||
|
dispose: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
load: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock Gain node
|
||||||
|
export const MockGain = vi.fn().mockImplementation(() => ({
|
||||||
|
gain: {
|
||||||
|
value: 1,
|
||||||
|
setValueAtTime: vi.fn(),
|
||||||
|
linearRampToValueAtTime: vi.fn(),
|
||||||
|
exponentialRampToValueAtTime: vi.fn()
|
||||||
|
},
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
dispose: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock Meter
|
||||||
|
export const MockMeter = vi.fn().mockImplementation(() => ({
|
||||||
|
getValue: vi.fn().mockReturnValue(-Infinity),
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
dispose: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Complete Tone.js mock
|
||||||
|
export const ToneMock = {
|
||||||
|
Sampler: MockSampler,
|
||||||
|
Transport: MockTransport,
|
||||||
|
Destination: MockDestination,
|
||||||
|
ToneAudioBuffer: MockToneAudioBuffer,
|
||||||
|
Gain: MockGain,
|
||||||
|
Meter: MockMeter,
|
||||||
|
|
||||||
|
// Context management
|
||||||
|
start: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getContext: vi.fn().mockReturnValue({
|
||||||
|
state: 'running',
|
||||||
|
resume: vi.fn().mockResolvedValue(undefined),
|
||||||
|
suspend: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Time utilities
|
||||||
|
Time: vi.fn().mockImplementation((time) => ({
|
||||||
|
toSeconds: vi.fn().mockReturnValue(parseFloat(time) || 0),
|
||||||
|
valueOf: vi.fn().mockReturnValue(parseFloat(time) || 0)
|
||||||
|
})),
|
||||||
|
|
||||||
|
// Frequency utilities
|
||||||
|
Frequency: vi.fn().mockImplementation((freq) => ({
|
||||||
|
toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440),
|
||||||
|
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup the global mock
|
||||||
|
export const setupToneMocks = () => {
|
||||||
|
vi.doMock('tone', () => ToneMock)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
// Setup global mocks
|
||||||
|
setupToneMocks()
|
||||||
|
|
||||||
|
// Mock KGCore globally to prevent store initialization issues
|
||||||
|
vi.mock('../core/KGCore', () => ({
|
||||||
|
KGCore: {
|
||||||
|
instance: vi.fn().mockReturnValue({
|
||||||
|
getCurrentProject: vi.fn().mockReturnValue({
|
||||||
|
getName: vi.fn().mockReturnValue('Test Project'),
|
||||||
|
getBpm: vi.fn().mockReturnValue(120),
|
||||||
|
getTimeSignature: vi.fn().mockReturnValue({ numerator: 4, denominator: 4 }),
|
||||||
|
getTracks: vi.fn().mockReturnValue([])
|
||||||
|
}),
|
||||||
|
getSelectedItems: vi.fn().mockReturnValue([]),
|
||||||
|
setSelectedItems: vi.fn(),
|
||||||
|
executeCommand: vi.fn()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Global test setup for all unit tests
|
||||||
|
|
||||||
|
// Clean up after each test
|
||||||
|
afterEach(() => {
|
||||||
|
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(() => {})
|
||||||
|
|
||||||
|
// Mock window.matchMedia (needed for some UI components)
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(), // deprecated
|
||||||
|
removeListener: vi.fn(), // deprecated
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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
|
||||||
|
* These help create consistent test data across different test files
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const createMockMidiNote = (overrides: Partial<{
|
||||||
|
pitch: number
|
||||||
|
velocity: number
|
||||||
|
startBeat: number
|
||||||
|
endBeat: number
|
||||||
|
id: string
|
||||||
|
}> = {}): KGMidiNote => {
|
||||||
|
const defaults = {
|
||||||
|
id: 'test-note-1',
|
||||||
|
startBeat: 0,
|
||||||
|
endBeat: 1,
|
||||||
|
pitch: 60, // Middle C
|
||||||
|
velocity: 80,
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
return new KGMidiNote(
|
||||||
|
defaults.id,
|
||||||
|
defaults.startBeat,
|
||||||
|
defaults.endBeat,
|
||||||
|
defaults.pitch,
|
||||||
|
defaults.velocity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMockMidiRegion = (overrides: Partial<{
|
||||||
|
id: string
|
||||||
|
trackId: string
|
||||||
|
trackIndex: number
|
||||||
|
name: string
|
||||||
|
startFromBeat: number
|
||||||
|
length: number
|
||||||
|
notes: KGMidiNote[]
|
||||||
|
}> = {}): KGMidiRegion => {
|
||||||
|
const defaults = {
|
||||||
|
id: 'test-region-1',
|
||||||
|
trackId: 'test-track-1',
|
||||||
|
trackIndex: 0,
|
||||||
|
name: 'Test Region',
|
||||||
|
startFromBeat: 0,
|
||||||
|
length: 4,
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = new KGMidiRegion(
|
||||||
|
defaults.id,
|
||||||
|
defaults.trackId,
|
||||||
|
defaults.trackIndex,
|
||||||
|
defaults.name,
|
||||||
|
defaults.startFromBeat,
|
||||||
|
defaults.length
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add notes if provided
|
||||||
|
if (overrides.notes) {
|
||||||
|
overrides.notes.forEach(note => region.addNote(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
return region
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMockMidiTrack = (overrides: Partial<{
|
||||||
|
name: string
|
||||||
|
id: number
|
||||||
|
instrument: string
|
||||||
|
volume: number
|
||||||
|
regions: KGMidiRegion[]
|
||||||
|
}> = {}): KGMidiTrack => {
|
||||||
|
const defaults = {
|
||||||
|
name: 'Test Track',
|
||||||
|
id: 0,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMockProject = (overrides: Partial<{
|
||||||
|
name: string
|
||||||
|
bpm: number
|
||||||
|
timeSignature: { numerator: number; denominator: number }
|
||||||
|
tracks: KGMidiTrack[]
|
||||||
|
}> = {}): KGProject => {
|
||||||
|
const defaults = {
|
||||||
|
name: 'Test Project',
|
||||||
|
bpm: 120,
|
||||||
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
|
tracks: [],
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = new KGProject(
|
||||||
|
defaults.name,
|
||||||
|
32, // maxBars
|
||||||
|
0, // currentBars
|
||||||
|
defaults.bpm,
|
||||||
|
defaults.timeSignature,
|
||||||
|
'C major', // keySignature
|
||||||
|
defaults.tracks, // tracks
|
||||||
|
1 // projectStructureVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
type CustomRenderOptions = Omit<RenderOptions, 'wrapper'>
|
||||||
|
|
||||||
|
const customRender = (
|
||||||
|
ui: ReactElement,
|
||||||
|
options?: CustomRenderOptions
|
||||||
|
) => {
|
||||||
|
// If you need to wrap components with providers (like Zustand store),
|
||||||
|
// you can create an AllTheProviders wrapper here
|
||||||
|
|
||||||
|
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 }
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { plainToInstance } from 'class-transformer'
|
||||||
|
import { convertRegionToABCNotation } from './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 '../test/fixtures/joy-project.json'
|
||||||
|
|
||||||
|
// Helper function to load project using real class-transformer deserialization (same as UI)
|
||||||
|
function loadProjectFromJSON(projectData: Record<string, unknown>): KGProject {
|
||||||
|
// Use the exact same deserialization process as the UI (Toolbar.tsx handleKGStudioJSONImport)
|
||||||
|
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
|
||||||
|
|
||||||
|
if (!deserializedProject) {
|
||||||
|
throw new Error("Failed to deserialize project data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserializedProject
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('abcNotationUtil - Integration Tests with Real Project Data', () => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Then deserialize using class-transformer (same as UI)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
// Expected ABC notation output (exactly as provided)
|
||||||
|
const expectedABCNotation = `X:1
|
||||||
|
T:Melody Region 1
|
||||||
|
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 |`
|
||||||
|
|
||||||
|
// Verify exact match
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 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[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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple tracks with different instruments', () => {
|
||||||
|
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 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 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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should verify complete deserialization hierarchy', () => {
|
||||||
|
// Test that class-transformer properly restored the entire object hierarchy
|
||||||
|
expect(joyProject).toBeInstanceOf(KGProject)
|
||||||
|
|
||||||
|
const tracks = joyProject.getTracks()
|
||||||
|
tracks.forEach(track => {
|
||||||
|
expect(track).toBeInstanceOf(KGMidiTrack)
|
||||||
|
|
||||||
|
const regions = track.getRegions()
|
||||||
|
regions.forEach(region => {
|
||||||
|
expect(region).toBeInstanceOf(KGMidiRegion)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify type identifiers are preserved
|
||||||
|
tracks.forEach(track => {
|
||||||
|
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
|
||||||
|
|
||||||
|
// Test converting only first 8 beats (2 bars)
|
||||||
|
const result = convertRegionToABCNotation(melodyRegion, 0, 8)
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mid-region start point', () => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
beatsToBar,
|
||||||
|
pitchToNoteNameString,
|
||||||
|
pitchToNoteName,
|
||||||
|
pianoRollIndexToPitch,
|
||||||
|
noteNameToPitch
|
||||||
|
} 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 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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 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
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle edge cases', () => {
|
||||||
|
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']
|
||||||
|
|
||||||
|
for (let i = 0; i < 12; 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 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return different pitches for different indices', () => {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle different octaves', () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle very large values', () => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain beat-to-bar relationships', () => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
expect(convertedPitch).toBe(originalPitch)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for non-numeric values', () => {
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Check that it includes values from TIME_CONSTANTS
|
||||||
|
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.forEach(numerator => {
|
||||||
|
expect(message).toContain(numerator.toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS.forEach(denominator => {
|
||||||
|
expect(message).toContain(denominator.toString())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be a consistent message format', () => {
|
||||||
|
const message1 = getTimeSignatureErrorMessage()
|
||||||
|
const message2 = getTimeSignatureErrorMessage()
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(4, 120, { numerator: 4, denominator: 4 }))
|
||||||
|
.toBe('002:1 | 00:02:000')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(8, 120, { numerator: 4, denominator: 4 }))
|
||||||
|
.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')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(3, 120, { numerator: 3, denominator: 4 }))
|
||||||
|
.toBe('002:1 | 00:01:500')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(6, 120, { numerator: 3, denominator: 4 }))
|
||||||
|
.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')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(6, 120, { numerator: 6, denominator: 8 }))
|
||||||
|
.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')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(4, 60, { numerator: 4, denominator: 4 }))
|
||||||
|
.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')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(4, 240, { numerator: 4, denominator: 4 }))
|
||||||
|
.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')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(2.25, 120, { numerator: 4, denominator: 4 }))
|
||||||
|
.toBe('001:3 | 00:01:125')
|
||||||
|
|
||||||
|
expect(beatsToTimeString(4.75, 120, { numerator: 4, denominator: 4 }))
|
||||||
|
.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:/)
|
||||||
|
|
||||||
|
expect(beatsToTimeString(40, 120, { numerator: 4, denominator: 4 }))
|
||||||
|
.toMatch(/^011:/) // Bar 11
|
||||||
|
|
||||||
|
expect(beatsToTimeString(396, 120, { numerator: 4, denominator: 4 }))
|
||||||
|
.toMatch(/^100:/) // Bar 100
|
||||||
|
|
||||||
|
// Test time padding (mm:ss:mmm format)
|
||||||
|
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
|
||||||
|
.toMatch(/\| 00:01:000$/)
|
||||||
|
|
||||||
|
expect(beatsToTimeString(75, 60, { numerator: 4, denominator: 4 }))
|
||||||
|
.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}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
// The function may produce negative time values for negative beats
|
||||||
|
expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatLocalDateTime', () => {
|
||||||
|
let mockDate: Date
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Use a fixed date for consistent testing
|
||||||
|
mockDate = new Date('2025-08-21T19:57:11.123Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format date with correct structure', () => {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include timezone information', () => {
|
||||||
|
const result = formatLocalDateTime(mockDate)
|
||||||
|
|
||||||
|
// Should contain some timezone indicator
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be consistent for the same date', () => {
|
||||||
|
const result1 = formatLocalDateTime(mockDate)
|
||||||
|
const result2 = formatLocalDateTime(mockDate)
|
||||||
|
|
||||||
|
expect(result1).toBe(result2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle leap year dates', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('integration tests', () => {
|
||||||
|
it('should work together for typical DAW workflow', () => {
|
||||||
|
// Parse time signature
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Format current time
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Get error message for user feedback
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil'
|
||||||
|
import {
|
||||||
|
longTextWithAddNotes,
|
||||||
|
longTextWithReadMusic,
|
||||||
|
multipleXmlBlocks,
|
||||||
|
nestedXmlContent,
|
||||||
|
xmlWithAttributes,
|
||||||
|
malformedXml,
|
||||||
|
noXmlContent,
|
||||||
|
emptyAndWhitespaceXml
|
||||||
|
} from '../test/fixtures/xml-samples'
|
||||||
|
|
||||||
|
describe('xmlUtil', () => {
|
||||||
|
describe('extractXMLFromString', () => {
|
||||||
|
it('should extract simple XML blocks', () => {
|
||||||
|
const input = `Here is some text with <test>content</test> and more text.`
|
||||||
|
const result = extractXMLFromString(input)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toBe('<test>content</test>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract multiple XML blocks', () => {
|
||||||
|
const input = `<first>content1</first> some text <second>content2</second>`
|
||||||
|
const result = extractXMLFromString(input)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0]).toBe('<first>content1</first>')
|
||||||
|
expect(result[1]).toBe('<second>content2</second>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract XML blocks with nested elements', () => {
|
||||||
|
const input = `<outer><inner>nested content</inner></outer>`
|
||||||
|
const result = extractXMLFromString(input)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toBe('<outer><inner>nested content</inner></outer>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract XML blocks with attributes', () => {
|
||||||
|
const input = `<tag attr="value" id="123">content</tag>`
|
||||||
|
const result = extractXMLFromString(input)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toBe('<tag attr="value" id="123">content</tag>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiline XML blocks', () => {
|
||||||
|
const input = `<multiline>
|
||||||
|
<line1>content1</line1>
|
||||||
|
<line2>content2</line2>
|
||||||
|
</multiline>`
|
||||||
|
const result = extractXMLFromString(input)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toContain('<multiline>')
|
||||||
|
expect(result[0]).toContain('<line1>content1</line1>')
|
||||||
|
expect(result[0]).toContain('<line2>content2</line2>')
|
||||||
|
expect(result[0]).toContain('</multiline>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract XML from complex nested content fixture', () => {
|
||||||
|
const result = extractXMLFromString(nestedXmlContent)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toContain('<container>')
|
||||||
|
expect(result[0]).toContain('<inner_element>')
|
||||||
|
expect(result[0]).toContain('<deep_nested>')
|
||||||
|
expect(result[0]).toContain('<value>Test Content</value>')
|
||||||
|
expect(result[0]).toContain('</container>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract multiple XML blocks from fixture', () => {
|
||||||
|
const result = extractXMLFromString(multipleXmlBlocks)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
expect(result[0]).toContain('<add_notes>')
|
||||||
|
expect(result[0]).toContain('</add_notes>')
|
||||||
|
expect(result[1]).toContain('<read_music>')
|
||||||
|
expect(result[1]).toContain('</read_music>')
|
||||||
|
expect(result[2]).toContain('<modify_tempo>')
|
||||||
|
expect(result[2]).toContain('</modify_tempo>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract XML with attributes from fixture', () => {
|
||||||
|
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"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle the long text with add_notes fixture (Case 1)', () => {
|
||||||
|
const result = extractXMLFromString(longTextWithAddNotes)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2) // Contains thinking tag and add_notes block
|
||||||
|
|
||||||
|
// Find the add_notes block
|
||||||
|
const addNotesBlock = result.find(block => block.includes('<add_notes>'))
|
||||||
|
expect(addNotesBlock).toBeDefined()
|
||||||
|
expect(addNotesBlock).toContain('<notes>')
|
||||||
|
expect(addNotesBlock).toContain('<note>')
|
||||||
|
expect(addNotesBlock).toContain('<pitch>C4</pitch>')
|
||||||
|
expect(addNotesBlock).toContain('<start_beat>0</start_beat>')
|
||||||
|
expect(addNotesBlock).toContain('<length>4</length>')
|
||||||
|
expect(addNotesBlock).toContain('</add_notes>')
|
||||||
|
|
||||||
|
// Should contain all 12 notes
|
||||||
|
const noteMatches = addNotesBlock!.match(/<note>/g)
|
||||||
|
expect(noteMatches).toHaveLength(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle the long text with read_music fixture (Case 2)', () => {
|
||||||
|
const result = extractXMLFromString(longTextWithReadMusic)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
|
||||||
|
// First XML block (inside thinking tag)
|
||||||
|
expect(result[0]).toContain('<read_music>')
|
||||||
|
expect(result[0]).toContain('<start_beat>0</start_beat>')
|
||||||
|
expect(result[0]).toContain('<length>32</length>')
|
||||||
|
expect(result[0]).toContain('</read_music>')
|
||||||
|
|
||||||
|
// Second XML block (at the end)
|
||||||
|
expect(result[1]).toContain('<read_music>')
|
||||||
|
expect(result[1]).toContain('<start_beat>0</start_beat>')
|
||||||
|
expect(result[1]).toContain('<length>32</length>')
|
||||||
|
expect(result[1]).toContain('</read_music>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle malformed XML gracefully', () => {
|
||||||
|
const result = extractXMLFromString(malformedXml)
|
||||||
|
|
||||||
|
// Should extract valid XML blocks (ignores malformed ones)
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
// Find the definitely valid block
|
||||||
|
const validBlock = result.find(block => block.includes('<valid_tag>'))
|
||||||
|
expect(validBlock).toBeDefined()
|
||||||
|
expect(validBlock).toContain('<content>This is valid</content>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array for content with no XML', () => {
|
||||||
|
const result = extractXMLFromString(noXmlContent)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty and whitespace XML', () => {
|
||||||
|
const result = extractXMLFromString(emptyAndWhitespaceXml)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
expect(result[0]).toBe('<empty_tag></empty_tag>')
|
||||||
|
expect(result[1]).toContain('<whitespace_tag>')
|
||||||
|
expect(result[1]).toContain('</whitespace_tag>')
|
||||||
|
expect(result[2]).toContain('<mixed_content>')
|
||||||
|
expect(result[2]).toContain('Some text with spaces')
|
||||||
|
expect(result[2]).toContain('</mixed_content>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle XML with underscores and hyphens in tag names', () => {
|
||||||
|
const input = `<tag_name>content</tag_name> and <tag-name>content</tag-name>`
|
||||||
|
const result = extractXMLFromString(input)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0]).toBe('<tag_name>content</tag_name>')
|
||||||
|
expect(result[1]).toBe('<tag-name>content</tag-name>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle self-closing tags (not currently supported)', () => {
|
||||||
|
const input = `<self-closing /> and <normal>content</normal>`
|
||||||
|
const result = extractXMLFromString(input)
|
||||||
|
|
||||||
|
// Current implementation doesn't support self-closing tags
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toBe('<normal>content</normal>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should trim whitespace around extracted XML', () => {
|
||||||
|
const input = ` <tag>content</tag> `
|
||||||
|
const result = extractXMLFromString(input)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toBe('<tag>content</tag>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('wrapXmlBlocksInContent', () => {
|
||||||
|
it('should wrap single XML block in fenced code block', () => {
|
||||||
|
const input = `Here is <test>content</test> in text.`
|
||||||
|
const result = wrapXmlBlocksInContent(input)
|
||||||
|
|
||||||
|
expect(result).toBe('Here is ```xml\n<test>content</test>\n``` in text.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap multiple XML blocks', () => {
|
||||||
|
const input = `<first>content1</first> text <second>content2</second>`
|
||||||
|
const result = wrapXmlBlocksInContent(input)
|
||||||
|
|
||||||
|
expect(result).toContain('```xml\n<first>content1</first>\n```')
|
||||||
|
expect(result).toContain('```xml\n<second>content2</second>\n```')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return original content when no XML blocks present', () => {
|
||||||
|
const input = noXmlContent
|
||||||
|
const result = wrapXmlBlocksInContent(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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap XML blocks from multipleXmlBlocks fixture', () => {
|
||||||
|
const result = wrapXmlBlocksInContent(multipleXmlBlocks)
|
||||||
|
|
||||||
|
expect(result).toContain('```xml\n<add_notes>')
|
||||||
|
expect(result).toContain('</add_notes>\n```')
|
||||||
|
expect(result).toContain('```xml\n<read_music>')
|
||||||
|
expect(result).toContain('</read_music>\n```')
|
||||||
|
expect(result).toContain('```xml\n<modify_tempo>')
|
||||||
|
expect(result).toContain('</modify_tempo>\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:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap the long add_notes XML block (Case 1)', () => {
|
||||||
|
const result = wrapXmlBlocksInContent(longTextWithAddNotes)
|
||||||
|
|
||||||
|
expect(result).toContain('```xml\n<add_notes>')
|
||||||
|
expect(result).toContain('</add_notes>\n```')
|
||||||
|
|
||||||
|
// Should preserve the thinking content and other text
|
||||||
|
expect(result).toContain('<thinking>')
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Should contain two wrapped XML blocks
|
||||||
|
const fencedBlocks = result.match(/```xml\n<read_music>[\s\S]*?<\/read_music>\n```/g)
|
||||||
|
expect(fencedBlocks).toHaveLength(2)
|
||||||
|
|
||||||
|
// Should preserve the thinking content and other text
|
||||||
|
expect(result).toContain('<thinking>')
|
||||||
|
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)
|
||||||
|
|
||||||
|
expect(result).toContain('```xml\n<container>')
|
||||||
|
expect(result).toContain('<inner_element>')
|
||||||
|
expect(result).toContain('<deep_nested>')
|
||||||
|
expect(result).toContain('<value>Test Content</value>')
|
||||||
|
expect(result).toContain('</container>\n```')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve XML block integrity when wrapping', () => {
|
||||||
|
const input = `Text before\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\nText after`
|
||||||
|
const result = wrapXmlBlocksInContent(input)
|
||||||
|
|
||||||
|
expect(result).toBe(`Text before\n\`\`\`xml\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\n\`\`\`\nText after`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle duplicate XML blocks correctly', () => {
|
||||||
|
const input = `<same>content</same> and then <same>content</same> again`
|
||||||
|
const result = wrapXmlBlocksInContent(input)
|
||||||
|
|
||||||
|
// Both instances should be wrapped
|
||||||
|
const wrappedBlocks = result.match(/```xml\n<same>content<\/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 = `<large>${largeContent}</large>`
|
||||||
|
|
||||||
|
const extracted = extractXMLFromString(input)
|
||||||
|
expect(extracted).toHaveLength(1)
|
||||||
|
expect(extracted[0]).toContain(largeContent)
|
||||||
|
|
||||||
|
const wrapped = wrapXmlBlocksInContent(input)
|
||||||
|
expect(wrapped).toContain('```xml\n<large>')
|
||||||
|
expect(wrapped).toContain('</large>\n```')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle XML with special characters', () => {
|
||||||
|
const input = `<tag>Content with < > & " '</tag>`
|
||||||
|
|
||||||
|
const extracted = extractXMLFromString(input)
|
||||||
|
expect(extracted).toHaveLength(1)
|
||||||
|
expect(extracted[0]).toContain('< > & " '')
|
||||||
|
|
||||||
|
const wrapped = wrapXmlBlocksInContent(input)
|
||||||
|
expect(wrapped).toContain('```xml\n<tag>Content with < > & " '</tag>\n```')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle XML with CDATA sections', () => {
|
||||||
|
const input = `<tag><![CDATA[Some content with <special> chars]]></tag>`
|
||||||
|
|
||||||
|
const extracted = extractXMLFromString(input)
|
||||||
|
expect(extracted).toHaveLength(1)
|
||||||
|
expect(extracted[0]).toContain('<![CDATA[')
|
||||||
|
expect(extracted[0]).toContain(']]>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mixed content with partial XML-like text', () => {
|
||||||
|
const input = `This < is not XML and neither > is this <incomplete and <valid>content</valid> is valid`
|
||||||
|
|
||||||
|
const extracted = extractXMLFromString(input)
|
||||||
|
expect(extracted).toHaveLength(1)
|
||||||
|
expect(extracted[0]).toBe('<valid>content</valid>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
// Use jsdom environment for DOM testing
|
||||||
|
environment: 'jsdom',
|
||||||
|
|
||||||
|
// Global test setup
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
|
||||||
|
// Include unit test files co-located with source
|
||||||
|
include: [
|
||||||
|
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
|
||||||
|
],
|
||||||
|
|
||||||
|
// Exclude directories
|
||||||
|
exclude: [
|
||||||
|
'node_modules',
|
||||||
|
'dist',
|
||||||
|
'.git',
|
||||||
|
'.cache',
|
||||||
|
'tests/' // Exclude integration/e2e folder for now
|
||||||
|
],
|
||||||
|
|
||||||
|
// Enable global test functions (describe, it, expect)
|
||||||
|
globals: true,
|
||||||
|
|
||||||
|
// Coverage configuration
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'src/test/',
|
||||||
|
'**/*.d.ts',
|
||||||
|
'**/*.config.ts',
|
||||||
|
'src/main.tsx',
|
||||||
|
'src/vite-env.d.ts'
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
global: {
|
||||||
|
branches: 70,
|
||||||
|
functions: 70,
|
||||||
|
lines: 70,
|
||||||
|
statements: 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test timeout
|
||||||
|
testTimeout: 10000,
|
||||||
|
|
||||||
|
// Retry failed tests once
|
||||||
|
retry: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user