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:
Xiaohan-Tian
2025-09-06 19:09:16 -07:00
committed by GitHub
21 changed files with 4802 additions and 18 deletions
+1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules node_modules
res/ res/
coverage/
dist dist
dist-ssr dist-ssr
*.local *.local
+1703 -9
View File
File diff suppressed because it is too large Load Diff
+15 -4
View File
@@ -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"
} }
} }
+5
View File
@@ -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;
+3 -3
View File
@@ -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 -2
View File
@@ -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') {
+151
View File
@@ -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
})
})
})
+353
View File
@@ -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
})
})
})
+398
View File
@@ -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)
})
})
})
})
+299
View File
@@ -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"
}
]
}
+197
View File
@@ -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>
`;
+115
View File
@@ -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)
}
+69
View File
@@ -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()
})
+155
View File
@@ -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 }
}
+22
View File
@@ -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 }
+225
View File
@@ -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()
})
})
})
+186
View File
@@ -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)
})
})
})
+316
View File
@@ -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')
})
})
})
+331
View File
@@ -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 &lt; &gt; &amp; &quot; &apos;</tag>`
const extracted = extractXMLFromString(input)
expect(extracted).toHaveLength(1)
expect(extracted[0]).toContain('&lt; &gt; &amp; &quot; &apos;')
const wrapped = wrapXmlBlocksInContent(input)
expect(wrapped).toContain('```xml\n<tag>Content with &lt; &gt; &amp; &quot; &apos;</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>')
})
})
})
+59
View File
@@ -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
}
})