feat: add Vitest unit testing infrastructure with initial test suite.

This commit is contained in:
Xiaohan-Tian
2025-09-03 22:15:05 -07:00
parent be39392aee
commit aa2b19966b
11 changed files with 2673 additions and 13 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"
} }
} }
@@ -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)
})
})
})
+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
})
})
})
+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 }
+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)
})
})
})
+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
}
})