feat: add Vitest unit testing infrastructure with initial test suite.
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,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,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,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