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
+5
View File
@@ -196,6 +196,11 @@ body {
color: #fff; color: #fff;
} }
.export-dropdown .quant-dropdown {
width: 250px;
left: 0;
}
/* Main content */ /* Main content */
.main-content { .main-content {
display: flex; display: flex;
+3 -3
View File
@@ -14,7 +14,7 @@ import {
FaCog FaCog
} from 'react-icons/fa'; } from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject'; import { KGProject, type KeySignature } from '../core/KGProject';
import { plainToClass, instanceToPlain } from 'class-transformer'; import { plainToInstance, instanceToPlain } from 'class-transformer';
import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6'; import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
import { KGMainContentState } from '../core/state/KGMainContentState'; import { KGMainContentState } from '../core/state/KGMainContentState';
import { regionDeleteManager } from '../util/regionDeleteUtil'; import { regionDeleteManager } from '../util/regionDeleteUtil';
@@ -315,9 +315,9 @@ const Toolbar: React.FC = () => {
const projectData = JSON.parse(fileContent); const projectData = JSON.parse(fileContent);
// Deserialize the project data using class-transformer (same as KGStorage) // Deserialize the project data using class-transformer (same as KGStorage)
const deserializedResult = plainToClass(KGProject, projectData); const deserializedResult = plainToInstance(KGProject, projectData);
// Handle case where plainToClass might return an array // Handle case where plainToInstance might return an array
const deserializedProject = Array.isArray(deserializedResult) const deserializedProject = Array.isArray(deserializedResult)
? deserializedResult[0] || null ? deserializedResult[0] || null
: deserializedResult; : deserializedResult;
+2 -2
View File
@@ -2,7 +2,7 @@
import { openDB } from 'idb' import { openDB } from 'idb'
import type { IDBPDatabase } from 'idb' import type { IDBPDatabase } from 'idb'
import { plainToClass, instanceToPlain } from 'class-transformer' import { plainToInstance, instanceToPlain } from 'class-transformer'
import { DB_CONSTANTS } from '../../constants/coreConstants' import { DB_CONSTANTS } from '../../constants/coreConstants'
export interface StorageEntry { export interface StorageEntry {
@@ -96,7 +96,7 @@ export class KGStorage {
return null return null
} }
const instance = plainToClass(classType, entry.data) const instance = plainToInstance(classType, entry.data)
const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') { if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
+353
View File
@@ -0,0 +1,353 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { KGMidiRegion } from './KGMidiRegion'
import { KGRegion } from './KGRegion'
import { KGMidiNote } from '../midi/KGMidiNote'
import { createMockMidiNote } from '../../test/utils/mock-data'
describe('KGMidiRegion', () => {
let region: KGMidiRegion
beforeEach(() => {
region = new KGMidiRegion(
'test-region-1',
'test-track-1',
0,
'Test Region',
0,
4
)
})
describe('constructor', () => {
it('should create a MIDI region with correct properties', () => {
const testRegion = new KGMidiRegion(
'region-1',
'track-1',
2,
'My Region',
4,
8
)
expect(testRegion.getId()).toBe('region-1')
expect(testRegion.getTrackId()).toBe('track-1')
expect(testRegion.getTrackIndex()).toBe(2)
expect(testRegion.getName()).toBe('My Region')
expect(testRegion.getStartFromBeat()).toBe(4)
expect(testRegion.getLength()).toBe(8)
expect(testRegion.getNotes()).toEqual([])
})
it('should use default values for optional parameters', () => {
const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region')
expect(defaultRegion.getStartFromBeat()).toBe(0)
expect(defaultRegion.getLength()).toBe(0)
expect(defaultRegion.getNotes()).toEqual([])
})
it('should set the correct type identifier', () => {
expect(region.getCurrentType()).toBe('KGMidiRegion')
})
})
describe('note management', () => {
let note1: KGMidiNote
let note2: KGMidiNote
let note3: KGMidiNote
beforeEach(() => {
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 })
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 })
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 })
})
describe('addNote', () => {
it('should add a single note to empty region', () => {
region.addNote(note1)
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(note1)
})
it('should add multiple notes to region', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
const notes = region.getNotes()
expect(notes).toHaveLength(3)
expect(notes).toContain(note1)
expect(notes).toContain(note2)
expect(notes).toContain(note3)
})
it('should maintain note order when adding', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
const notes = region.getNotes()
expect(notes[0]).toBe(note1)
expect(notes[1]).toBe(note2)
expect(notes[2]).toBe(note3)
})
it('should allow adding the same note multiple times', () => {
region.addNote(note1)
region.addNote(note1)
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes[0]).toBe(note1)
expect(notes[1]).toBe(note1)
})
})
describe('removeNote', () => {
beforeEach(() => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
})
it('should remove note by ID', () => {
region.removeNote('note-2')
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(note1)
expect(notes).toContain(note3)
expect(notes).not.toContain(note2)
})
it('should handle removing non-existent note gracefully', () => {
const initialLength = region.getNotes().length
region.removeNote('non-existent-note')
expect(region.getNotes()).toHaveLength(initialLength)
})
it('should remove all instances when note ID appears multiple times', () => {
// Add another note with same ID as note1
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 })
region.addNote(duplicateNote)
expect(region.getNotes()).toHaveLength(4)
region.removeNote('note-1')
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(note2)
expect(notes).toContain(note3)
expect(notes).not.toContain(note1)
expect(notes).not.toContain(duplicateNote)
})
it('should handle removing from empty region', () => {
const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region')
expect(() => emptyRegion.removeNote('note-1')).not.toThrow()
expect(emptyRegion.getNotes()).toHaveLength(0)
})
})
describe('getNotes', () => {
it('should return empty array for new region', () => {
const notes = region.getNotes()
expect(notes).toEqual([])
expect(notes).toHaveLength(0)
})
it('should return all notes in region', () => {
region.addNote(note1)
region.addNote(note2)
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toEqual([note1, note2])
})
it('should return a reference to the internal notes array', () => {
region.addNote(note1)
const notes1 = region.getNotes()
const notes2 = region.getNotes()
expect(notes1).toBe(notes2) // Same reference
})
})
describe('setNotes', () => {
it('should replace all notes with new array', () => {
region.addNote(note1)
region.addNote(note2)
expect(region.getNotes()).toHaveLength(2)
region.setNotes([note3])
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(note3)
})
it('should allow setting empty notes array', () => {
region.addNote(note1)
region.addNote(note2)
region.setNotes([])
expect(region.getNotes()).toHaveLength(0)
})
it('should accept notes array with multiple notes', () => {
const newNotes = [note1, note2, note3]
region.setNotes(newNotes)
const retrievedNotes = region.getNotes()
expect(retrievedNotes).toHaveLength(3)
expect(retrievedNotes).toEqual(newNotes)
})
})
})
describe('inheritance from KGRegion', () => {
it('should inherit all base region properties', () => {
expect(region.getId()).toBe('test-region-1')
expect(region.getTrackId()).toBe('test-track-1')
expect(region.getTrackIndex()).toBe(0)
expect(region.getName()).toBe('Test Region')
expect(region.getStartFromBeat()).toBe(0)
expect(region.getLength()).toBe(4)
})
it('should inherit selection functionality', () => {
expect(region.isSelected()).toBe(false)
region.select()
expect(region.isSelected()).toBe(true)
region.deselect()
expect(region.isSelected()).toBe(false)
})
it('should inherit setters from base class', () => {
region.setName('Updated Region')
expect(region.getName()).toBe('Updated Region')
region.setStartFromBeat(8)
expect(region.getStartFromBeat()).toBe(8)
region.setLength(12)
expect(region.getLength()).toBe(12)
})
})
describe('type identification', () => {
it('should return correct current type', () => {
expect(region.getCurrentType()).toBe('KGMidiRegion')
})
it('should return correct root type', () => {
expect(region.getRootType()).toBe('KGRegion')
})
it('should be instanceof both KGMidiRegion and KGRegion', () => {
expect(region).toBeInstanceOf(KGMidiRegion)
expect(region).toBeInstanceOf(KGRegion)
})
})
describe('edge cases and error handling', () => {
it('should handle notes with overlapping time ranges', () => {
const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 })
const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 })
region.addNote(overlappingNote1)
region.addNote(overlappingNote2)
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(overlappingNote1)
expect(notes).toContain(overlappingNote2)
})
it('should handle notes with same pitch but different timing', () => {
const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 })
const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 })
region.addNote(sameNote1)
region.addNote(sameNote2)
expect(region.getNotes()).toHaveLength(2)
})
it('should handle notes outside region boundaries', () => {
// Region is from beat 0 to 4, but note extends beyond
const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 })
region.addNote(outsideNote)
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(outsideNote)
// Note: The region doesn't enforce boundary constraints - that's application logic
})
it('should handle zero-length region', () => {
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0)
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 })
zeroRegion.addNote(note)
expect(zeroRegion.getNotes()).toHaveLength(1)
expect(zeroRegion.getLength()).toBe(0)
})
})
describe('data consistency', () => {
it('should maintain note references correctly', () => {
const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 })
region.addNote(originalNote)
const retrievedNote = region.getNotes()[0]
expect(retrievedNote).toBe(originalNote) // Same reference
// Modify original note
originalNote.setPitch(64)
expect(retrievedNote.getPitch()).toBe(64) // Should reflect change
})
it('should handle concurrent modifications correctly', () => {
const notes = [
createMockMidiNote({ id: 'concurrent-1', pitch: 60 }),
createMockMidiNote({ id: 'concurrent-2', pitch: 64 }),
createMockMidiNote({ id: 'concurrent-3', pitch: 67 })
]
// Add notes
notes.forEach(note => region.addNote(note))
expect(region.getNotes()).toHaveLength(3)
// Remove middle note
region.removeNote('concurrent-2')
expect(region.getNotes()).toHaveLength(2)
// Add new note
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 })
region.addNote(newNote)
expect(region.getNotes()).toHaveLength(3)
// Verify final state
const finalNotes = region.getNotes()
expect(finalNotes).toContain(notes[0]) // concurrent-1
expect(finalNotes).not.toContain(notes[1]) // concurrent-2 (removed)
expect(finalNotes).toContain(notes[2]) // concurrent-3
expect(finalNotes).toContain(newNote) // concurrent-4
})
})
})
+398
View File
@@ -0,0 +1,398 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack'
import { KGTrack, TrackType } from './KGTrack'
import { KGMidiRegion } from '../region/KGMidiRegion'
import { createMockMidiRegion } from '../../test/utils/mock-data'
describe('KGMidiTrack', () => {
let track: KGMidiTrack
beforeEach(() => {
track = new KGMidiTrack()
})
describe('constructor', () => {
it('should create track with default values', () => {
const defaultTrack = new KGMidiTrack()
expect(defaultTrack.getName()).toBe('Untitled MIDI Track')
expect(defaultTrack.getId()).toBe(0)
expect(defaultTrack.getType()).toBe(TrackType.MIDI)
expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano')
expect(defaultTrack.getVolume()).toBe(0.8) // DEFAULT_TRACK_VOLUME
expect(defaultTrack.getRegions()).toEqual([])
})
it('should create track with custom parameters', () => {
const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', 0.6)
expect(customTrack.getName()).toBe('My Piano Track')
expect(customTrack.getId()).toBe(5)
expect(customTrack.getType()).toBe(TrackType.MIDI)
expect(customTrack.getInstrument()).toBe('electric_piano_1')
expect(customTrack.getVolume()).toBe(0.6)
})
it('should set correct type identifier', () => {
expect(track.getCurrentType()).toBe('KGMidiTrack')
})
it('should inherit from KGTrack', () => {
expect(track).toBeInstanceOf(KGMidiTrack)
expect(track).toBeInstanceOf(KGTrack)
})
})
describe('instrument management', () => {
describe('getInstrument', () => {
it('should return default instrument when not set', () => {
expect(track.getInstrument()).toBe('acoustic_grand_piano')
})
it('should return current instrument', () => {
const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean')
expect(customTrack.getInstrument()).toBe('electric_guitar_clean')
})
it('should provide backward compatibility for undefined instrument', () => {
// This tests the backward compatibility mentioned in the code
expect(track.getInstrument()).toBe('acoustic_grand_piano')
})
})
describe('setInstrument', () => {
it('should update instrument', () => {
track.setInstrument('violin')
expect(track.getInstrument()).toBe('violin')
})
it('should handle different instrument types', () => {
const instruments: InstrumentType[] = [
'acoustic_grand_piano',
'electric_piano_1',
'electric_guitar_clean',
'acoustic_bass',
'violin',
'trumpet',
'flute'
]
instruments.forEach(instrument => {
track.setInstrument(instrument)
expect(track.getInstrument()).toBe(instrument)
})
})
it('should handle rapid instrument changes', () => {
track.setInstrument('piano')
track.setInstrument('guitar')
track.setInstrument('violin')
expect(track.getInstrument()).toBe('violin')
})
})
})
describe('region management', () => {
let region1: KGMidiRegion
let region2: KGMidiRegion
let region3: KGMidiRegion
beforeEach(() => {
region1 = createMockMidiRegion({
id: 'region-1',
trackId: track.getId().toString(),
name: 'Region 1',
startFromBeat: 0,
length: 4
})
region2 = createMockMidiRegion({
id: 'region-2',
trackId: track.getId().toString(),
name: 'Region 2',
startFromBeat: 4,
length: 4
})
region3 = createMockMidiRegion({
id: 'region-3',
trackId: track.getId().toString(),
name: 'Region 3',
startFromBeat: 8,
length: 4
})
})
describe('setRegions', () => {
it('should set regions array', () => {
const regions = [region1, region2]
track.setRegions(regions)
expect(track.getRegions()).toHaveLength(2)
expect(track.getRegions()).toEqual(regions)
})
it('should replace existing regions', () => {
track.setRegions([region1])
expect(track.getRegions()).toHaveLength(1)
track.setRegions([region2, region3])
expect(track.getRegions()).toHaveLength(2)
expect(track.getRegions()).toContain(region2)
expect(track.getRegions()).toContain(region3)
expect(track.getRegions()).not.toContain(region1)
})
it('should accept empty array', () => {
track.setRegions([region1, region2])
track.setRegions([])
expect(track.getRegions()).toHaveLength(0)
})
it('should enforce KGMidiRegion type', () => {
const regions: KGMidiRegion[] = [region1, region2]
track.setRegions(regions)
const retrievedRegions = track.getRegions()
retrievedRegions.forEach(region => {
expect(region).toBeInstanceOf(KGMidiRegion)
})
})
})
describe('inherited region methods', () => {
beforeEach(() => {
track.setRegions([region1, region2])
})
it('should inherit addRegion method', () => {
track.addRegion(region3)
const regions = track.getRegions()
expect(regions).toHaveLength(3)
expect(regions).toContain(region3)
})
it('should inherit removeRegion method', () => {
track.removeRegion('region-1')
const regions = track.getRegions()
expect(regions).toHaveLength(1)
expect(regions).not.toContain(region1)
expect(regions).toContain(region2)
})
it('should inherit getRegions method', () => {
const regions = track.getRegions()
expect(regions).toHaveLength(2)
expect(regions).toContain(region1)
expect(regions).toContain(region2)
})
})
})
describe('inheritance from KGTrack', () => {
it('should inherit all base track properties', () => {
const customTrack = new KGMidiTrack('Test Track', 42, 'violin', 0.9)
expect(customTrack.getName()).toBe('Test Track')
expect(customTrack.getId()).toBe(42)
expect(customTrack.getType()).toBe(TrackType.MIDI)
expect(customTrack.getVolume()).toBe(0.9)
})
it('should inherit base track setters', () => {
track.setName('Updated Track')
expect(track.getName()).toBe('Updated Track')
track.setVolume(0.5)
expect(track.getVolume()).toBe(0.5)
track.setTrackIndex(3)
expect(track.getTrackIndex()).toBe(3)
})
it('should inherit volume controls', () => {
expect(track.getVolume()).toBe(0.8) // Default volume
track.setVolume(0.5)
expect(track.getVolume()).toBe(0.5)
track.setVolume(1.0)
expect(track.getVolume()).toBe(1.0)
})
})
describe('type identification', () => {
it('should return correct current type', () => {
expect(track.getCurrentType()).toBe('KGMidiTrack')
})
it('should return correct root type', () => {
expect(track.getRootType()).toBe('KGTrack')
})
it('should have MIDI track type', () => {
expect(track.getType()).toBe(TrackType.MIDI)
})
})
describe('instrument type validation', () => {
it('should handle all valid General MIDI instruments', () => {
// Test a selection of valid GM instruments
const validInstruments: InstrumentType[] = [
'acoustic_grand_piano',
'electric_piano_1',
'electric_piano_2',
'electric_guitar_clean',
'electric_guitar_muted',
'acoustic_bass',
'electric_bass_finger',
'violin',
'viola',
'cello',
'contrabass',
'trumpet',
'trombone',
'french_horn',
'flute',
'clarinet',
'soprano_sax',
'alto_sax'
]
validInstruments.forEach(instrument => {
expect(() => {
track.setInstrument(instrument)
expect(track.getInstrument()).toBe(instrument)
}).not.toThrow()
})
})
})
describe('track state consistency', () => {
it('should maintain consistent state after multiple operations', () => {
// Setup initial state
track.setName('Piano Track')
track.setInstrument('acoustic_grand_piano')
track.setVolume(0.7)
const regions = [
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
]
track.setRegions(regions)
// Verify initial state
expect(track.getName()).toBe('Piano Track')
expect(track.getInstrument()).toBe('acoustic_grand_piano')
expect(track.getVolume()).toBe(0.7)
expect(track.getRegions()).toHaveLength(2)
// Modify state
track.setInstrument('electric_piano_1')
track.addRegion(createMockMidiRegion({
id: 'r3',
trackId: '0',
name: 'Chorus'
}))
// Verify modified state
expect(track.getName()).toBe('Piano Track')
expect(track.getInstrument()).toBe('electric_piano_1')
expect(track.getVolume()).toBe(0.7)
expect(track.getRegions()).toHaveLength(3)
})
it('should handle region-track relationship correctly', () => {
const region = createMockMidiRegion({
id: 'test-region',
trackId: track.getId().toString(),
name: 'Test Region'
})
track.addRegion(region)
// Verify region is in track
expect(track.getRegions()).toContain(region)
// Find region manually since getRegionById doesn't exist
const foundRegion = track.getRegions().find(r => r.getId() === 'test-region')
expect(foundRegion).toBe(region)
// Verify region properties
expect(region.getTrackId()).toBe(track.getId().toString())
})
})
describe('edge cases and error handling', () => {
it('should handle empty track name', () => {
const emptyNameTrack = new KGMidiTrack('', 0, 'piano')
expect(emptyNameTrack.getName()).toBe('')
})
it('should handle negative track ID', () => {
const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano')
expect(negativeIdTrack.getId()).toBe(-1)
})
it('should handle volume boundaries', () => {
track.setVolume(0.0)
expect(track.getVolume()).toBe(0.0)
track.setVolume(1.0)
expect(track.getVolume()).toBe(1.0)
// Volume outside normal range (should still work)
track.setVolume(1.5)
expect(track.getVolume()).toBe(1.5)
})
it('should handle large number of regions', () => {
const manyRegions = Array.from({ length: 100 }, (_, i) =>
createMockMidiRegion({
id: `region-${i}`,
trackId: track.getId().toString(),
name: `Region ${i}`
})
)
track.setRegions(manyRegions)
expect(track.getRegions()).toHaveLength(100)
// Should be able to find any region
const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50')
const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99')
expect(foundRegion50).toBeDefined()
expect(foundRegion99).toBeDefined()
})
})
describe('class-transformer compatibility', () => {
it('should have proper type annotations for serialization', () => {
// Verify the class has the necessary decorators for serialization
expect(track.getCurrentType()).toBe('KGMidiTrack')
// Test that default instrument fallback works
const instrument = track.getInstrument()
expect(instrument).toBe('acoustic_grand_piano')
})
it('should maintain regions type after serialization simulation', () => {
const regions = [
createMockMidiRegion({ id: 'r1', trackId: '0' }),
createMockMidiRegion({ id: 'r2', trackId: '0' })
]
track.setRegions(regions)
// Simulate what happens during serialization/deserialization
const retrievedRegions = track.getRegions()
expect(retrievedRegions).toHaveLength(2)
retrievedRegions.forEach(region => {
expect(region).toBeInstanceOf(KGMidiRegion)
})
})
})
})
+299
View File
@@ -0,0 +1,299 @@
{
"name": "joy",
"maxBars": 32,
"currentBars": 0,
"timeSignature": {
"numerator": 4,
"denominator": 4
},
"bpm": 125,
"keySignature": "C major",
"projectStructureVersion": 1,
"tracks": [
{
"__type": "KGMidiTrack",
"name": "Melody",
"id": 1,
"trackIndex": 0,
"type": "MIDI",
"volume": 0.8,
"regions": [
{
"__type": "KGMidiRegion",
"id": "KGMidiRegion_1755134493138_iufo370n9",
"trackId": "1",
"trackIndex": 0,
"name": "Melody Region 1",
"startFromBeat": 0,
"length": 32,
"selected": true,
"notes": [
{
"id": "KGMidiNote_1755134524628_dfmfpzoa9",
"startBeat": 0,
"endBeat": 1,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134528480_2uml69rkg",
"startBeat": 1,
"endBeat": 2,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134530480_4r34d57ig",
"startBeat": 2,
"endBeat": 3,
"pitch": 65,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134531066_zahyopqtg",
"startBeat": 3,
"endBeat": 4,
"pitch": 67,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134531647_65jgmc4m2",
"startBeat": 4,
"endBeat": 5,
"pitch": 67,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134532248_mnd24k18e",
"startBeat": 5,
"endBeat": 6,
"pitch": 65,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134532815_506vblsde",
"startBeat": 6,
"endBeat": 7,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134533716_ah1hw0kfe",
"startBeat": 7,
"endBeat": 8,
"pitch": 62,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134534566_m8bunoc6t",
"startBeat": 8,
"endBeat": 9,
"pitch": 60,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134536265_7qeciq0m6",
"startBeat": 9,
"endBeat": 10,
"pitch": 60,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134537068_o6bb5y7ha",
"startBeat": 10,
"endBeat": 11,
"pitch": 62,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134537752_ya958b30e",
"startBeat": 11,
"endBeat": 12,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134538401_bzer7pq8h",
"startBeat": 12,
"endBeat": 13.5,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134541568_so9s6pa1u",
"startBeat": 13.5,
"endBeat": 14,
"pitch": 62,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134545236_r9xnsoggt",
"startBeat": 14,
"endBeat": 16,
"pitch": 62,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_idvq2hr0t",
"startBeat": 16,
"endBeat": 17,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_i6za508pa",
"startBeat": 17,
"endBeat": 18,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_0kru65kt6",
"startBeat": 18,
"endBeat": 19,
"pitch": 65,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_ayj4954ko",
"startBeat": 19,
"endBeat": 20,
"pitch": 67,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_5toqdpq5n",
"startBeat": 20,
"endBeat": 21,
"pitch": 67,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_yilvvambd",
"startBeat": 21,
"endBeat": 22,
"pitch": 65,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_r8w8tr21a",
"startBeat": 22,
"endBeat": 23,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_d8t91xm5l",
"startBeat": 23,
"endBeat": 24,
"pitch": 62,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_hk0q0ow9q",
"startBeat": 24,
"endBeat": 25,
"pitch": 60,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561530_gnl53ldaz",
"startBeat": 25,
"endBeat": 26,
"pitch": 60,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561531_53kdyl7u5",
"startBeat": 26,
"endBeat": 27,
"pitch": 62,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561531_les717v12",
"startBeat": 27,
"endBeat": 28,
"pitch": 64,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561531_owdko4big",
"startBeat": 28,
"endBeat": 29.5,
"pitch": 62,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561531_b04zbsgnf",
"startBeat": 29.5,
"endBeat": 30,
"pitch": 60,
"velocity": 127,
"selected": false
},
{
"id": "KGMidiNote_1755134561531_jgvrlacer",
"startBeat": 30,
"endBeat": 32,
"pitch": 60,
"velocity": 127,
"selected": false
}
]
}
],
"instrument": "acoustic_grand_piano"
},
{
"__type": "KGMidiTrack",
"name": "Pad Chord",
"id": 2,
"trackIndex": 1,
"type": "MIDI",
"volume": 1,
"regions": [
{
"__type": "KGMidiRegion",
"id": "KGMidiRegion_1755326973407_9hsnkcgwr",
"trackId": "2",
"trackIndex": 1,
"name": "Pad Chord Region 1",
"startFromBeat": 0,
"length": 32,
"selected": true,
"notes": []
}
],
"instrument": "pad_1_new_age"
}
]
}
+225
View File
@@ -0,0 +1,225 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { plainToInstance } from 'class-transformer'
import { convertRegionToABCNotation } from './abcNotationUtil'
import { KGMidiRegion } from '../core/region/KGMidiRegion'
import { KGMidiTrack } from '../core/track/KGMidiTrack'
import { KGCore } from '../core/KGCore'
import { KGProject } from '../core/KGProject'
// Import the test fixture
import joyProjectData from '../test/fixtures/joy-project.json'
// Helper function to load project using real class-transformer deserialization (same as UI)
function loadProjectFromJSON(projectData: Record<string, unknown>): KGProject {
// Use the exact same deserialization process as the UI (Toolbar.tsx handleKGStudioJSONImport)
const deserializedResult = plainToInstance(KGProject, projectData)
// Handle case where plainToInstance might return an array (same as UI)
const deserializedProject = Array.isArray(deserializedResult)
? deserializedResult[0] || null
: deserializedResult
if (!deserializedProject) {
throw new Error("Failed to deserialize project data")
}
return deserializedProject
}
describe('abcNotationUtil - Integration Tests with Real Project Data', () => {
let joyProject: KGProject
beforeEach(() => {
// Simulate the exact same process as Toolbar.tsx handleKGStudioJSONImport
// First parse as JSON (simulating file.text() -> JSON.parse())
const fileContent = JSON.stringify(joyProjectData)
const projectData = JSON.parse(fileContent)
// Then deserialize using class-transformer (same as UI)
joyProject = loadProjectFromJSON(projectData)
// Mock KGCore to return the loaded project (same as production flow)
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => joyProject
} as unknown as KGCore)
})
describe('convertRegionToABCNotation with real project data', () => {
it('should convert joy project melody region to exact ABC notation', () => {
// Get the melody track and region using real project structure
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
expect(melodyTrack.getName()).toBe('Melody')
expect(melodyTrack.getRegions()).toHaveLength(1)
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
expect(melodyRegion.getName()).toBe('Melody Region 1')
expect(melodyRegion.getNotes()).toHaveLength(30) // Verify we have all 30 notes
// Convert to ABC notation using real region data
const result = convertRegionToABCNotation(melodyRegion, 0, 32)
// Expected ABC notation output (exactly as provided)
const expectedABCNotation = `X:1
T:Melody Region 1
M:4/4
L:1/4
Q:1/4=125
K:C
E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2 C1/2 C2 |`
// Verify exact match
expect(result).toBe(expectedABCNotation)
})
it('should handle empty pad chord region from real project', () => {
// Get the pad chord track and region
const padTrack = joyProject.getTracks()[1] as KGMidiTrack
expect(padTrack.getName()).toBe('Pad Chord')
expect(padTrack.getInstrument()).toBe('pad_1_new_age')
const padRegion = padTrack.getRegions()[0] as KGMidiRegion
expect(padRegion.getName()).toBe('Pad Chord Region 1')
expect(padRegion.getNotes()).toHaveLength(0) // Empty region
// Convert empty region to ABC notation
const result = convertRegionToABCNotation(padRegion, 0, 32)
// Verify it contains rest notation and proper headers
expect(result).toBeDefined()
expect(result).toContain('T:Pad Chord Region 1')
expect(result).toContain('M:4/4')
expect(result).toContain('Q:1/4=125')
expect(result).toContain('K:C')
expect(result).toContain('z') // Should contain rest notation
})
it('should use correct project settings from real project data', () => {
// Verify project settings are loaded correctly
expect(joyProject.getName()).toBe('joy')
expect(joyProject.getBpm()).toBe(125)
expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 })
expect(joyProject.getKeySignature()).toBe('C major')
expect(joyProject.getMaxBars()).toBe(32)
// These settings should be reflected in ABC notation headers
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
const result = convertRegionToABCNotation(melodyRegion, 0, 32)
expect(result).toContain('Q:1/4=125') // BPM
expect(result).toContain('M:4/4') // Time signature
expect(result).toContain('K:C') // Key signature
})
it('should handle real note timing and pitches correctly', () => {
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
const notes = melodyRegion.getNotes()
// Verify some key notes from the real data
expect(notes[0].getPitch()).toBe(64) // First note is E (64)
expect(notes[0].getStartBeat()).toBe(0)
expect(notes[0].getEndBeat()).toBe(1)
expect(notes[3].getPitch()).toBe(67) // Fourth note is G (67)
expect(notes[3].getStartBeat()).toBe(3)
expect(notes[3].getEndBeat()).toBe(4)
// Verify fractional timing note (beat 12-13.5)
const fractionalNote = notes.find(note => note.getEndBeat() === 13.5)
expect(fractionalNote).toBeDefined()
expect(fractionalNote!.getPitch()).toBe(64) // E
expect(fractionalNote!.getStartBeat()).toBe(12)
// Convert and verify these real timings are reflected in ABC notation
const result = convertRegionToABCNotation(melodyRegion, 0, 32)
expect(result).toContain('E3/2 D1/2') // Fractional timing should appear in ABC
})
it('should handle multiple tracks with different instruments', () => {
const tracks = joyProject.getTracks()
expect(tracks).toHaveLength(2)
// Verify track properties from real project
const melodyTrack = tracks[0] as KGMidiTrack
expect(melodyTrack.getName()).toBe('Melody')
expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano')
expect(melodyTrack.getVolume()).toBe(0.8)
const padTrack = tracks[1] as KGMidiTrack
expect(padTrack.getName()).toBe('Pad Chord')
expect(padTrack.getInstrument()).toBe('pad_1_new_age')
expect(padTrack.getVolume()).toBe(1)
// Both tracks should be processable for ABC notation
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
const padRegion = padTrack.getRegions()[0] as KGMidiRegion
const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32)
const padABC = convertRegionToABCNotation(padRegion, 0, 32)
expect(melodyABC).toContain('T:Melody Region 1')
expect(padABC).toContain('T:Pad Chord Region 1')
})
it('should verify complete deserialization hierarchy', () => {
// Test that class-transformer properly restored the entire object hierarchy
expect(joyProject).toBeInstanceOf(KGProject)
const tracks = joyProject.getTracks()
tracks.forEach(track => {
expect(track).toBeInstanceOf(KGMidiTrack)
const regions = track.getRegions()
regions.forEach(region => {
expect(region).toBeInstanceOf(KGMidiRegion)
const notes = (region as KGMidiRegion).getNotes()
notes.forEach(note => {
// Notes should have proper methods and properties
expect(typeof note.getId()).toBe('string')
expect(typeof note.getPitch()).toBe('number')
expect(typeof note.getStartBeat()).toBe('number')
expect(typeof note.getEndBeat()).toBe('number')
expect(typeof note.getVelocity()).toBe('number')
})
})
})
// Verify type identifiers are preserved
tracks.forEach(track => {
expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack')
})
})
})
describe('edge cases with real project data', () => {
it('should handle partial region conversion', () => {
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
// Test converting only first 8 beats (2 bars)
const result = convertRegionToABCNotation(melodyRegion, 0, 8)
expect(result).toBeDefined()
expect(result).toContain('T:Melody Region 1')
// Should only contain the first 2 bars of music
expect(result).not.toContain('D3/2 C1/2 C2') // This appears later in the song
})
it('should handle mid-region start point', () => {
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
// Test converting from beat 16 to 24 (second half of melody)
const result = convertRegionToABCNotation(melodyRegion, 16, 24)
expect(result).toBeDefined()
expect(result).toContain('T:Melody Region 1')
// Should start from the second repetition
const lines = result.split('\n')
const musicLine = lines[lines.length - 1] // Last line contains the music
expect(musicLine).toBeTruthy()
})
})
})