feat: added unit tests for KGMidiRegion and abcNotationUtil; update all deprecated method plainToClass to plainToInstance.

This commit is contained in:
Xiaohan-Tian
2025-09-03 23:05:13 -07:00
parent aa2b19966b
commit 2bf5328af7
7 changed files with 1285 additions and 5 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
import { openDB } from 'idb'
import type { IDBPDatabase } from 'idb'
import { plainToClass, instanceToPlain } from 'class-transformer'
import { plainToInstance, instanceToPlain } from 'class-transformer'
import { DB_CONSTANTS } from '../../constants/coreConstants'
export interface StorageEntry {
@@ -96,7 +96,7 @@ export class KGStorage {
return null
}
const instance = plainToClass(classType, entry.data)
const instance = plainToInstance(classType, entry.data)
const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
+353
View File
@@ -0,0 +1,353 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { KGMidiRegion } from './KGMidiRegion'
import { KGRegion } from './KGRegion'
import { KGMidiNote } from '../midi/KGMidiNote'
import { createMockMidiNote } from '../../test/utils/mock-data'
describe('KGMidiRegion', () => {
let region: KGMidiRegion
beforeEach(() => {
region = new KGMidiRegion(
'test-region-1',
'test-track-1',
0,
'Test Region',
0,
4
)
})
describe('constructor', () => {
it('should create a MIDI region with correct properties', () => {
const testRegion = new KGMidiRegion(
'region-1',
'track-1',
2,
'My Region',
4,
8
)
expect(testRegion.getId()).toBe('region-1')
expect(testRegion.getTrackId()).toBe('track-1')
expect(testRegion.getTrackIndex()).toBe(2)
expect(testRegion.getName()).toBe('My Region')
expect(testRegion.getStartFromBeat()).toBe(4)
expect(testRegion.getLength()).toBe(8)
expect(testRegion.getNotes()).toEqual([])
})
it('should use default values for optional parameters', () => {
const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region')
expect(defaultRegion.getStartFromBeat()).toBe(0)
expect(defaultRegion.getLength()).toBe(0)
expect(defaultRegion.getNotes()).toEqual([])
})
it('should set the correct type identifier', () => {
expect(region.getCurrentType()).toBe('KGMidiRegion')
})
})
describe('note management', () => {
let note1: KGMidiNote
let note2: KGMidiNote
let note3: KGMidiNote
beforeEach(() => {
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 })
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 })
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 })
})
describe('addNote', () => {
it('should add a single note to empty region', () => {
region.addNote(note1)
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(note1)
})
it('should add multiple notes to region', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
const notes = region.getNotes()
expect(notes).toHaveLength(3)
expect(notes).toContain(note1)
expect(notes).toContain(note2)
expect(notes).toContain(note3)
})
it('should maintain note order when adding', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
const notes = region.getNotes()
expect(notes[0]).toBe(note1)
expect(notes[1]).toBe(note2)
expect(notes[2]).toBe(note3)
})
it('should allow adding the same note multiple times', () => {
region.addNote(note1)
region.addNote(note1)
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes[0]).toBe(note1)
expect(notes[1]).toBe(note1)
})
})
describe('removeNote', () => {
beforeEach(() => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
})
it('should remove note by ID', () => {
region.removeNote('note-2')
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(note1)
expect(notes).toContain(note3)
expect(notes).not.toContain(note2)
})
it('should handle removing non-existent note gracefully', () => {
const initialLength = region.getNotes().length
region.removeNote('non-existent-note')
expect(region.getNotes()).toHaveLength(initialLength)
})
it('should remove all instances when note ID appears multiple times', () => {
// Add another note with same ID as note1
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 })
region.addNote(duplicateNote)
expect(region.getNotes()).toHaveLength(4)
region.removeNote('note-1')
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(note2)
expect(notes).toContain(note3)
expect(notes).not.toContain(note1)
expect(notes).not.toContain(duplicateNote)
})
it('should handle removing from empty region', () => {
const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region')
expect(() => emptyRegion.removeNote('note-1')).not.toThrow()
expect(emptyRegion.getNotes()).toHaveLength(0)
})
})
describe('getNotes', () => {
it('should return empty array for new region', () => {
const notes = region.getNotes()
expect(notes).toEqual([])
expect(notes).toHaveLength(0)
})
it('should return all notes in region', () => {
region.addNote(note1)
region.addNote(note2)
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toEqual([note1, note2])
})
it('should return a reference to the internal notes array', () => {
region.addNote(note1)
const notes1 = region.getNotes()
const notes2 = region.getNotes()
expect(notes1).toBe(notes2) // Same reference
})
})
describe('setNotes', () => {
it('should replace all notes with new array', () => {
region.addNote(note1)
region.addNote(note2)
expect(region.getNotes()).toHaveLength(2)
region.setNotes([note3])
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(note3)
})
it('should allow setting empty notes array', () => {
region.addNote(note1)
region.addNote(note2)
region.setNotes([])
expect(region.getNotes()).toHaveLength(0)
})
it('should accept notes array with multiple notes', () => {
const newNotes = [note1, note2, note3]
region.setNotes(newNotes)
const retrievedNotes = region.getNotes()
expect(retrievedNotes).toHaveLength(3)
expect(retrievedNotes).toEqual(newNotes)
})
})
})
describe('inheritance from KGRegion', () => {
it('should inherit all base region properties', () => {
expect(region.getId()).toBe('test-region-1')
expect(region.getTrackId()).toBe('test-track-1')
expect(region.getTrackIndex()).toBe(0)
expect(region.getName()).toBe('Test Region')
expect(region.getStartFromBeat()).toBe(0)
expect(region.getLength()).toBe(4)
})
it('should inherit selection functionality', () => {
expect(region.isSelected()).toBe(false)
region.select()
expect(region.isSelected()).toBe(true)
region.deselect()
expect(region.isSelected()).toBe(false)
})
it('should inherit setters from base class', () => {
region.setName('Updated Region')
expect(region.getName()).toBe('Updated Region')
region.setStartFromBeat(8)
expect(region.getStartFromBeat()).toBe(8)
region.setLength(12)
expect(region.getLength()).toBe(12)
})
})
describe('type identification', () => {
it('should return correct current type', () => {
expect(region.getCurrentType()).toBe('KGMidiRegion')
})
it('should return correct root type', () => {
expect(region.getRootType()).toBe('KGRegion')
})
it('should be instanceof both KGMidiRegion and KGRegion', () => {
expect(region).toBeInstanceOf(KGMidiRegion)
expect(region).toBeInstanceOf(KGRegion)
})
})
describe('edge cases and error handling', () => {
it('should handle notes with overlapping time ranges', () => {
const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 })
const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 })
region.addNote(overlappingNote1)
region.addNote(overlappingNote2)
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(overlappingNote1)
expect(notes).toContain(overlappingNote2)
})
it('should handle notes with same pitch but different timing', () => {
const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 })
const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 })
region.addNote(sameNote1)
region.addNote(sameNote2)
expect(region.getNotes()).toHaveLength(2)
})
it('should handle notes outside region boundaries', () => {
// Region is from beat 0 to 4, but note extends beyond
const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 })
region.addNote(outsideNote)
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(outsideNote)
// Note: The region doesn't enforce boundary constraints - that's application logic
})
it('should handle zero-length region', () => {
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0)
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 })
zeroRegion.addNote(note)
expect(zeroRegion.getNotes()).toHaveLength(1)
expect(zeroRegion.getLength()).toBe(0)
})
})
describe('data consistency', () => {
it('should maintain note references correctly', () => {
const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 })
region.addNote(originalNote)
const retrievedNote = region.getNotes()[0]
expect(retrievedNote).toBe(originalNote) // Same reference
// Modify original note
originalNote.setPitch(64)
expect(retrievedNote.getPitch()).toBe(64) // Should reflect change
})
it('should handle concurrent modifications correctly', () => {
const notes = [
createMockMidiNote({ id: 'concurrent-1', pitch: 60 }),
createMockMidiNote({ id: 'concurrent-2', pitch: 64 }),
createMockMidiNote({ id: 'concurrent-3', pitch: 67 })
]
// Add notes
notes.forEach(note => region.addNote(note))
expect(region.getNotes()).toHaveLength(3)
// Remove middle note
region.removeNote('concurrent-2')
expect(region.getNotes()).toHaveLength(2)
// Add new note
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 })
region.addNote(newNote)
expect(region.getNotes()).toHaveLength(3)
// Verify final state
const finalNotes = region.getNotes()
expect(finalNotes).toContain(notes[0]) // concurrent-1
expect(finalNotes).not.toContain(notes[1]) // concurrent-2 (removed)
expect(finalNotes).toContain(notes[2]) // concurrent-3
expect(finalNotes).toContain(newNote) // concurrent-4
})
})
})
+398
View File
@@ -0,0 +1,398 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack'
import { KGTrack, TrackType } from './KGTrack'
import { KGMidiRegion } from '../region/KGMidiRegion'
import { createMockMidiRegion } from '../../test/utils/mock-data'
describe('KGMidiTrack', () => {
let track: KGMidiTrack
beforeEach(() => {
track = new KGMidiTrack()
})
describe('constructor', () => {
it('should create track with default values', () => {
const defaultTrack = new KGMidiTrack()
expect(defaultTrack.getName()).toBe('Untitled MIDI Track')
expect(defaultTrack.getId()).toBe(0)
expect(defaultTrack.getType()).toBe(TrackType.MIDI)
expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano')
expect(defaultTrack.getVolume()).toBe(0.8) // DEFAULT_TRACK_VOLUME
expect(defaultTrack.getRegions()).toEqual([])
})
it('should create track with custom parameters', () => {
const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', 0.6)
expect(customTrack.getName()).toBe('My Piano Track')
expect(customTrack.getId()).toBe(5)
expect(customTrack.getType()).toBe(TrackType.MIDI)
expect(customTrack.getInstrument()).toBe('electric_piano_1')
expect(customTrack.getVolume()).toBe(0.6)
})
it('should set correct type identifier', () => {
expect(track.getCurrentType()).toBe('KGMidiTrack')
})
it('should inherit from KGTrack', () => {
expect(track).toBeInstanceOf(KGMidiTrack)
expect(track).toBeInstanceOf(KGTrack)
})
})
describe('instrument management', () => {
describe('getInstrument', () => {
it('should return default instrument when not set', () => {
expect(track.getInstrument()).toBe('acoustic_grand_piano')
})
it('should return current instrument', () => {
const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean')
expect(customTrack.getInstrument()).toBe('electric_guitar_clean')
})
it('should provide backward compatibility for undefined instrument', () => {
// This tests the backward compatibility mentioned in the code
expect(track.getInstrument()).toBe('acoustic_grand_piano')
})
})
describe('setInstrument', () => {
it('should update instrument', () => {
track.setInstrument('violin')
expect(track.getInstrument()).toBe('violin')
})
it('should handle different instrument types', () => {
const instruments: InstrumentType[] = [
'acoustic_grand_piano',
'electric_piano_1',
'electric_guitar_clean',
'acoustic_bass',
'violin',
'trumpet',
'flute'
]
instruments.forEach(instrument => {
track.setInstrument(instrument)
expect(track.getInstrument()).toBe(instrument)
})
})
it('should handle rapid instrument changes', () => {
track.setInstrument('piano')
track.setInstrument('guitar')
track.setInstrument('violin')
expect(track.getInstrument()).toBe('violin')
})
})
})
describe('region management', () => {
let region1: KGMidiRegion
let region2: KGMidiRegion
let region3: KGMidiRegion
beforeEach(() => {
region1 = createMockMidiRegion({
id: 'region-1',
trackId: track.getId().toString(),
name: 'Region 1',
startFromBeat: 0,
length: 4
})
region2 = createMockMidiRegion({
id: 'region-2',
trackId: track.getId().toString(),
name: 'Region 2',
startFromBeat: 4,
length: 4
})
region3 = createMockMidiRegion({
id: 'region-3',
trackId: track.getId().toString(),
name: 'Region 3',
startFromBeat: 8,
length: 4
})
})
describe('setRegions', () => {
it('should set regions array', () => {
const regions = [region1, region2]
track.setRegions(regions)
expect(track.getRegions()).toHaveLength(2)
expect(track.getRegions()).toEqual(regions)
})
it('should replace existing regions', () => {
track.setRegions([region1])
expect(track.getRegions()).toHaveLength(1)
track.setRegions([region2, region3])
expect(track.getRegions()).toHaveLength(2)
expect(track.getRegions()).toContain(region2)
expect(track.getRegions()).toContain(region3)
expect(track.getRegions()).not.toContain(region1)
})
it('should accept empty array', () => {
track.setRegions([region1, region2])
track.setRegions([])
expect(track.getRegions()).toHaveLength(0)
})
it('should enforce KGMidiRegion type', () => {
const regions: KGMidiRegion[] = [region1, region2]
track.setRegions(regions)
const retrievedRegions = track.getRegions()
retrievedRegions.forEach(region => {
expect(region).toBeInstanceOf(KGMidiRegion)
})
})
})
describe('inherited region methods', () => {
beforeEach(() => {
track.setRegions([region1, region2])
})
it('should inherit addRegion method', () => {
track.addRegion(region3)
const regions = track.getRegions()
expect(regions).toHaveLength(3)
expect(regions).toContain(region3)
})
it('should inherit removeRegion method', () => {
track.removeRegion('region-1')
const regions = track.getRegions()
expect(regions).toHaveLength(1)
expect(regions).not.toContain(region1)
expect(regions).toContain(region2)
})
it('should inherit getRegions method', () => {
const regions = track.getRegions()
expect(regions).toHaveLength(2)
expect(regions).toContain(region1)
expect(regions).toContain(region2)
})
})
})
describe('inheritance from KGTrack', () => {
it('should inherit all base track properties', () => {
const customTrack = new KGMidiTrack('Test Track', 42, 'violin', 0.9)
expect(customTrack.getName()).toBe('Test Track')
expect(customTrack.getId()).toBe(42)
expect(customTrack.getType()).toBe(TrackType.MIDI)
expect(customTrack.getVolume()).toBe(0.9)
})
it('should inherit base track setters', () => {
track.setName('Updated Track')
expect(track.getName()).toBe('Updated Track')
track.setVolume(0.5)
expect(track.getVolume()).toBe(0.5)
track.setTrackIndex(3)
expect(track.getTrackIndex()).toBe(3)
})
it('should inherit volume controls', () => {
expect(track.getVolume()).toBe(0.8) // Default volume
track.setVolume(0.5)
expect(track.getVolume()).toBe(0.5)
track.setVolume(1.0)
expect(track.getVolume()).toBe(1.0)
})
})
describe('type identification', () => {
it('should return correct current type', () => {
expect(track.getCurrentType()).toBe('KGMidiTrack')
})
it('should return correct root type', () => {
expect(track.getRootType()).toBe('KGTrack')
})
it('should have MIDI track type', () => {
expect(track.getType()).toBe(TrackType.MIDI)
})
})
describe('instrument type validation', () => {
it('should handle all valid General MIDI instruments', () => {
// Test a selection of valid GM instruments
const validInstruments: InstrumentType[] = [
'acoustic_grand_piano',
'electric_piano_1',
'electric_piano_2',
'electric_guitar_clean',
'electric_guitar_muted',
'acoustic_bass',
'electric_bass_finger',
'violin',
'viola',
'cello',
'contrabass',
'trumpet',
'trombone',
'french_horn',
'flute',
'clarinet',
'soprano_sax',
'alto_sax'
]
validInstruments.forEach(instrument => {
expect(() => {
track.setInstrument(instrument)
expect(track.getInstrument()).toBe(instrument)
}).not.toThrow()
})
})
})
describe('track state consistency', () => {
it('should maintain consistent state after multiple operations', () => {
// Setup initial state
track.setName('Piano Track')
track.setInstrument('acoustic_grand_piano')
track.setVolume(0.7)
const regions = [
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
]
track.setRegions(regions)
// Verify initial state
expect(track.getName()).toBe('Piano Track')
expect(track.getInstrument()).toBe('acoustic_grand_piano')
expect(track.getVolume()).toBe(0.7)
expect(track.getRegions()).toHaveLength(2)
// Modify state
track.setInstrument('electric_piano_1')
track.addRegion(createMockMidiRegion({
id: 'r3',
trackId: '0',
name: 'Chorus'
}))
// Verify modified state
expect(track.getName()).toBe('Piano Track')
expect(track.getInstrument()).toBe('electric_piano_1')
expect(track.getVolume()).toBe(0.7)
expect(track.getRegions()).toHaveLength(3)
})
it('should handle region-track relationship correctly', () => {
const region = createMockMidiRegion({
id: 'test-region',
trackId: track.getId().toString(),
name: 'Test Region'
})
track.addRegion(region)
// Verify region is in track
expect(track.getRegions()).toContain(region)
// Find region manually since getRegionById doesn't exist
const foundRegion = track.getRegions().find(r => r.getId() === 'test-region')
expect(foundRegion).toBe(region)
// Verify region properties
expect(region.getTrackId()).toBe(track.getId().toString())
})
})
describe('edge cases and error handling', () => {
it('should handle empty track name', () => {
const emptyNameTrack = new KGMidiTrack('', 0, 'piano')
expect(emptyNameTrack.getName()).toBe('')
})
it('should handle negative track ID', () => {
const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano')
expect(negativeIdTrack.getId()).toBe(-1)
})
it('should handle volume boundaries', () => {
track.setVolume(0.0)
expect(track.getVolume()).toBe(0.0)
track.setVolume(1.0)
expect(track.getVolume()).toBe(1.0)
// Volume outside normal range (should still work)
track.setVolume(1.5)
expect(track.getVolume()).toBe(1.5)
})
it('should handle large number of regions', () => {
const manyRegions = Array.from({ length: 100 }, (_, i) =>
createMockMidiRegion({
id: `region-${i}`,
trackId: track.getId().toString(),
name: `Region ${i}`
})
)
track.setRegions(manyRegions)
expect(track.getRegions()).toHaveLength(100)
// Should be able to find any region
const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50')
const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99')
expect(foundRegion50).toBeDefined()
expect(foundRegion99).toBeDefined()
})
})
describe('class-transformer compatibility', () => {
it('should have proper type annotations for serialization', () => {
// Verify the class has the necessary decorators for serialization
expect(track.getCurrentType()).toBe('KGMidiTrack')
// Test that default instrument fallback works
const instrument = track.getInstrument()
expect(instrument).toBe('acoustic_grand_piano')
})
it('should maintain regions type after serialization simulation', () => {
const regions = [
createMockMidiRegion({ id: 'r1', trackId: '0' }),
createMockMidiRegion({ id: 'r2', trackId: '0' })
]
track.setRegions(regions)
// Simulate what happens during serialization/deserialization
const retrievedRegions = track.getRegions()
expect(retrievedRegions).toHaveLength(2)
retrievedRegions.forEach(region => {
expect(region).toBeInstanceOf(KGMidiRegion)
})
})
})
})