diff --git a/src/App.css b/src/App.css index 8ce0d16..6249762 100644 --- a/src/App.css +++ b/src/App.css @@ -196,6 +196,11 @@ body { color: #fff; } +.export-dropdown .quant-dropdown { + width: 250px; + left: 0; +} + /* Main content */ .main-content { display: flex; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 242c3f9..b21e984 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -14,7 +14,7 @@ import { FaCog } from 'react-icons/fa'; 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 { KGMainContentState } from '../core/state/KGMainContentState'; import { regionDeleteManager } from '../util/regionDeleteUtil'; @@ -315,9 +315,9 @@ const Toolbar: React.FC = () => { const projectData = JSON.parse(fileContent); // 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) ? deserializedResult[0] || null : deserializedResult; diff --git a/src/core/io/KGStorage.ts b/src/core/io/KGStorage.ts index e186a95..a5441c1 100644 --- a/src/core/io/KGStorage.ts +++ b/src/core/io/KGStorage.ts @@ -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') { diff --git a/src/core/region/KGMidiRegion.test.ts b/src/core/region/KGMidiRegion.test.ts new file mode 100644 index 0000000..d47c6b6 --- /dev/null +++ b/src/core/region/KGMidiRegion.test.ts @@ -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 + }) + }) +}) \ No newline at end of file diff --git a/src/core/track/KGMidiTrack.test.ts b/src/core/track/KGMidiTrack.test.ts new file mode 100644 index 0000000..be30ef1 --- /dev/null +++ b/src/core/track/KGMidiTrack.test.ts @@ -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) + }) + }) + }) +}) \ No newline at end of file diff --git a/src/test/fixtures/joy-project.json b/src/test/fixtures/joy-project.json new file mode 100644 index 0000000..50960c5 --- /dev/null +++ b/src/test/fixtures/joy-project.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/src/util/abcNotationUtil.test.ts b/src/util/abcNotationUtil.test.ts new file mode 100644 index 0000000..211a20c --- /dev/null +++ b/src/util/abcNotationUtil.test.ts @@ -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): 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() + }) + }) +}) \ No newline at end of file