feat: add Vitest unit testing infrastructure with initial test suite.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user