From 02a7a61b1de85ac8e630c91abe814fb5c6709368 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:56:58 -0800 Subject: [PATCH] feat: added integration tests --- .../command-execution.integration.test.ts | 335 +++++++++++++ .../project-store-sync.integration.test.ts | 446 ++++++++++++++++++ .../utils/abc-notation.integration.test.ts} | 12 +- src/test/mocks/audio-interface.ts | 40 ++ src/test/mocks/indexed-db.ts | 55 +++ src/test/mocks/tone-js.ts | 58 +++ src/test/utils/setup-integration-tests.ts | 92 ++++ vitest.config.ts | 8 +- 8 files changed, 1036 insertions(+), 10 deletions(-) create mode 100644 src/test/integration/store/command-execution.integration.test.ts create mode 100644 src/test/integration/store/project-store-sync.integration.test.ts rename src/{util/abcNotationUtil.test.ts => test/integration/utils/abc-notation.integration.test.ts} (96%) create mode 100644 src/test/mocks/audio-interface.ts create mode 100644 src/test/mocks/indexed-db.ts create mode 100644 src/test/mocks/tone-js.ts create mode 100644 src/test/utils/setup-integration-tests.ts diff --git a/src/test/integration/store/command-execution.integration.test.ts b/src/test/integration/store/command-execution.integration.test.ts new file mode 100644 index 0000000..888890f --- /dev/null +++ b/src/test/integration/store/command-execution.integration.test.ts @@ -0,0 +1,335 @@ +/** + * Integration tests for command execution and undo/redo functionality + * Tests the complete flow: Command execution → Core model updates → UI state sync + */ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { createRoot } from 'react-dom/client' + +// Import core classes +import { KGCore } from '../../../core/KGCore' +import { KGProject } from '../../../core/KGProject' +import { KGMidiTrack } from '../../../core/track/KGMidiTrack' +import { KGMidiRegion } from '../../../core/region/KGMidiRegion' +import { KGMidiNote } from '../../../core/midi/KGMidiNote' +import { KGCommandHistory } from '../../../core/commands/KGCommandHistory' + +// Import commands +import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand' +import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand' +import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand' + +// Import store +import { useProjectStore } from '../../../stores/projectStore' + +// Import test utilities +import '../../utils/setup-integration-tests' + +describe('Command Execution Integration Tests', () => { + let testProject: KGProject + let testTrack: KGMidiTrack + let testRegion: KGMidiRegion + + beforeEach(async () => { + // Create a real project with track and region for testing + testProject = new KGProject('Test Project') + testProject.setBpm(120) + testProject.setTimeSignature({ numerator: 4, denominator: 4 }) + testProject.setKeySignature('C major') + testProject.setMaxBars(32) + + // Create test track and region + testTrack = new KGMidiTrack('Test Track', 'acoustic_grand_piano') + testRegion = new KGMidiRegion('Test Region', 0, 16) + + // Set up the project hierarchy + testTrack.addRegion(testRegion) + testProject.addTrack(testTrack) + + // Initialize KGCore with test project + const core = KGCore.instance() + await core.initializeAsync() + core.setCurrentProject(testProject) + + // Clear command history + KGCommandHistory.instance().clear() + + // Initialize store with project + const { loadProject } = useProjectStore.getState() + await loadProject(testProject) + }) + + describe('Note Command Integration', () => { + it('should execute CreateNoteCommand and update both core model and store', async () => { + const regionId = testRegion.getId() + const initialNoteCount = testRegion.getNotes().length + + // Create and execute command + const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) + const commandHistory = KGCommandHistory.instance() + + // Execute command through command history (simulates real usage) + act(() => { + commandHistory.executeCommand(createCommand) + }) + + // Verify core model was updated + const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion + expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1) + + const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId()) + expect(createdNote).toBeDefined() + expect(createdNote!.getPitch()).toBe(60) + expect(createdNote!.getStartBeat()).toBe(0) + expect(createdNote!.getEndBeat()).toBe(1) + + // Verify command history state + expect(commandHistory.canUndo()).toBe(true) + expect(commandHistory.canRedo()).toBe(false) + expect(commandHistory.getUndoDescription()).toBe('Create note C4') + + // Verify store undo/redo state is updated + const storeState = useProjectStore.getState() + expect(storeState.canUndo).toBe(true) + expect(storeState.canRedo).toBe(false) + }) + + it('should execute undo and restore previous state', async () => { + const regionId = testRegion.getId() + const initialNoteCount = testRegion.getNotes().length + + // Create and execute command + const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120) // E4 + const commandHistory = KGCommandHistory.instance() + + act(() => { + commandHistory.executeCommand(createCommand) + }) + + // Verify note was created + const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion + expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1) + + // Execute undo + act(() => { + const undoSuccess = commandHistory.undo() + expect(undoSuccess).toBe(true) + }) + + // Verify core model was restored + const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion + expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount) + + // Verify the specific note was removed + const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId()) + expect(noteExists).toBe(false) + + // Verify command history state + expect(commandHistory.canUndo()).toBe(false) + expect(commandHistory.canRedo()).toBe(true) + expect(commandHistory.getRedoDescription()).toBe('Create note E4') + }) + + it('should execute redo and restore forward state', async () => { + const regionId = testRegion.getId() + const initialNoteCount = testRegion.getNotes().length + + // Create, execute, and undo a command + const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110) // G4 + const commandHistory = KGCommandHistory.instance() + + act(() => { + commandHistory.executeCommand(createCommand) + commandHistory.undo() + }) + + // Verify we're back to initial state + expect(testRegion.getNotes()).toHaveLength(initialNoteCount) + + // Execute redo + act(() => { + const redoSuccess = commandHistory.redo() + expect(redoSuccess).toBe(true) + }) + + // Verify core model was restored to post-create state + const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion + expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1) + + // Verify the specific note was recreated + const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId()) + expect(recreatedNote).toBeDefined() + expect(recreatedNote!.getPitch()).toBe(67) + + // Verify command history state + expect(commandHistory.canUndo()).toBe(true) + expect(commandHistory.canRedo()).toBe(false) + }) + }) + + describe('Multiple Command Integration', () => { + it('should execute multiple commands and maintain history integrity', async () => { + const regionId = testRegion.getId() + const commandHistory = KGCommandHistory.instance() + + // Execute multiple note creation commands + const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100) // C4 + const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100) // E4 + const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100) // G4 + + act(() => { + commandHistory.executeCommand(command1) + commandHistory.executeCommand(command2) + commandHistory.executeCommand(command3) + }) + + // Verify all notes were created + const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion + expect(region.getNotes()).toHaveLength(3) + + // Verify command history + expect(commandHistory.canUndo()).toBe(true) + expect(commandHistory.getUndoDescription()).toBe('Create note G4') + + // Undo middle command by undoing twice + act(() => { + commandHistory.undo() // Remove G4 + commandHistory.undo() // Remove E4 + }) + + // Verify only first note remains + expect(region.getNotes()).toHaveLength(1) + expect(region.getNotes()[0].getPitch()).toBe(60) // C4 + + // Verify redo state + expect(commandHistory.canRedo()).toBe(true) + expect(commandHistory.getRedoDescription()).toBe('Create note E4') + }) + + it('should handle command execution with different command types', async () => { + const commandHistory = KGCommandHistory.instance() + const initialTrackCount = testProject.getTracks().length + + // Execute track addition command + const addTrackCommand = new AddTrackCommand('Bass Track', 'acoustic_bass') + + act(() => { + commandHistory.executeCommand(addTrackCommand) + }) + + // Verify track was added to core model + expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1) + const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack + expect(newTrack.getName()).toBe('Bass Track') + expect(newTrack.getInstrument()).toBe('acoustic_bass') + + // Add a note to the original region + const createNoteCommand = new CreateNoteCommand(testRegion.getId(), 0, 1, 48, 100) // C3 + + act(() => { + commandHistory.executeCommand(createNoteCommand) + }) + + // Verify both commands are in history + expect(commandHistory.canUndo()).toBe(true) + expect(commandHistory.getUndoDescription()).toBe('Create note C3') + + // Undo both commands + act(() => { + commandHistory.undo() // Undo note creation + commandHistory.undo() // Undo track addition + }) + + // Verify both operations were undone + expect(testProject.getTracks()).toHaveLength(initialTrackCount) + const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion + expect(originalRegion.getNotes()).toHaveLength(0) + }) + }) + + describe('Error Handling Integration', () => { + it('should handle command execution errors gracefully', async () => { + const commandHistory = KGCommandHistory.instance() + + // Try to create note in non-existent region + const invalidCommand = new CreateNoteCommand('invalid-region-id', 0, 1, 60, 100) + + // Execute command - should not throw but should not add to history + act(() => { + commandHistory.executeCommand(invalidCommand) + }) + + // Verify command was not added to history due to execution failure + expect(commandHistory.canUndo()).toBe(false) + expect(commandHistory.getUndoDescription()).toBeNull() + + // Verify project state unchanged + const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion + expect(region.getNotes()).toHaveLength(0) + }) + + it('should handle undo failures gracefully', async () => { + const regionId = testRegion.getId() + const commandHistory = KGCommandHistory.instance() + + // Create a command that will succeed initially + const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) + + act(() => { + commandHistory.executeCommand(createCommand) + }) + + // Manually remove the region to cause undo to fail + testTrack.removeRegion(testRegion.getId()) + + // Try to undo - should fail gracefully + act(() => { + const undoSuccess = commandHistory.undo() + expect(undoSuccess).toBe(false) + }) + + // Verify command is still in undo stack after failed undo + expect(commandHistory.canUndo()).toBe(true) + }) + }) + + describe('Store Integration', () => { + it('should keep store undo/redo state synchronized with command history', async () => { + const regionId = testRegion.getId() + const commandHistory = KGCommandHistory.instance() + const { refreshUndoRedoState } = useProjectStore.getState() + + // Initial state + expect(useProjectStore.getState().canUndo).toBe(false) + expect(useProjectStore.getState().canRedo).toBe(false) + + // Execute command + const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) + + act(() => { + commandHistory.executeCommand(createCommand) + refreshUndoRedoState() // Simulate store sync + }) + + // Verify store state updated + let storeState = useProjectStore.getState() + expect(storeState.canUndo).toBe(true) + expect(storeState.canRedo).toBe(false) + expect(storeState.undoDescription).toBe('Create note C4') + expect(storeState.redoDescription).toBeNull() + + // Execute undo + act(() => { + commandHistory.undo() + refreshUndoRedoState() // Simulate store sync + }) + + // Verify store state updated after undo + storeState = useProjectStore.getState() + expect(storeState.canUndo).toBe(false) + expect(storeState.canRedo).toBe(true) + expect(storeState.undoDescription).toBeNull() + expect(storeState.redoDescription).toBe('Create note C4') + }) + }) +}) \ No newline at end of file diff --git a/src/test/integration/store/project-store-sync.integration.test.ts b/src/test/integration/store/project-store-sync.integration.test.ts new file mode 100644 index 0000000..c1f7b54 --- /dev/null +++ b/src/test/integration/store/project-store-sync.integration.test.ts @@ -0,0 +1,446 @@ +/** + * Integration tests for project store synchronization with core models + * Tests the critical data flow: Store Actions → Core Models → UI State Updates + */ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { act, renderHook } from '@testing-library/react' + +// Import core classes +import { KGCore } from '../../../core/KGCore' +import { KGProject } from '../../../core/KGProject' +import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack' +import { KGMidiRegion } from '../../../core/region/KGMidiRegion' +import { KGMidiNote } from '../../../core/midi/KGMidiNote' + +// Import store +import { useProjectStore } from '../../../stores/projectStore' + +// Import test utilities and mocks +import '../../utils/setup-integration-tests' +import { mockAudioInterface } from '../../mocks/audio-interface' + +describe('Project Store Synchronization Integration Tests', () => { + let testProject: KGProject + + beforeEach(async () => { + // Create a real project for testing + testProject = new KGProject('Sync Test Project') + testProject.setBpm(120) + testProject.setTimeSignature({ numerator: 4, denominator: 4 }) + testProject.setKeySignature('C major') + testProject.setMaxBars(32) + + // Initialize KGCore + const core = KGCore.instance() + await core.initializeAsync() + core.setCurrentProject(testProject) + + // Reset store state + const store = useProjectStore.getState() + await store.loadProject(testProject) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('Project Properties Synchronization', () => { + it('should sync BPM changes between store and core model', async () => { + const { setBpm } = useProjectStore.getState() + const newBpm = 140 + + // Execute store action + act(() => { + setBpm(newBpm) + }) + + // Verify core model was updated + expect(testProject.getBpm()).toBe(newBpm) + + // Verify store state reflects change + const storeState = useProjectStore.getState() + expect(storeState.bpm).toBe(newBpm) + + // Verify CSS custom property was updated + const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') + expect(cssValue).toBeTruthy() // CSS should be updated by store action + }) + + it('should sync time signature changes and update CSS properties', async () => { + const { setTimeSignature } = useProjectStore.getState() + const newTimeSignature = { numerator: 3, denominator: 4 } + + act(() => { + setTimeSignature(newTimeSignature) + }) + + // Verify core model was updated + expect(testProject.getTimeSignature()).toEqual(newTimeSignature) + + // Verify store state reflects change + const storeState = useProjectStore.getState() + expect(storeState.timeSignature).toEqual(newTimeSignature) + + // Verify CSS custom property was updated for UI calculations + const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') + expect(cssValue.trim()).toBe('3') + }) + + it('should sync max bars changes and update layout CSS', async () => { + const { setMaxBars } = useProjectStore.getState() + const newMaxBars = 64 + + act(() => { + setMaxBars(newMaxBars) + }) + + // Verify core model was updated + expect(testProject.getMaxBars()).toBe(newMaxBars) + + // Verify store state reflects change + const storeState = useProjectStore.getState() + expect(storeState.maxBars).toBe(newMaxBars) + + // Verify CSS custom property was updated for layout + const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars') + expect(cssValue.trim()).toBe('64') + }) + + it('should sync key signature changes', async () => { + const { setKeySignature } = useProjectStore.getState() + const newKeySignature = 'G major' + + act(() => { + setKeySignature(newKeySignature) + }) + + // Verify core model was updated + expect(testProject.getKeySignature()).toBe(newKeySignature) + + // Verify store state reflects change + const storeState = useProjectStore.getState() + expect(storeState.keySignature).toBe(newKeySignature) + }) + }) + + describe('Track Management Synchronization', () => { + it('should sync track addition with core model and audio interface', async () => { + const { addTrack } = useProjectStore.getState() + const initialTrackCount = testProject.getTracks().length + + // Execute store action + await act(async () => { + await addTrack() + }) + + // Verify core model was updated + expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1) + const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack + expect(newTrack).toBeInstanceOf(KGMidiTrack) + expect(newTrack.getName()).toContain('Track') + + // Verify store state reflects change + const storeState = useProjectStore.getState() + expect(storeState.tracks).toHaveLength(initialTrackCount + 1) + + // Verify audio interface was notified + expect(mockAudioInterface.createTrackBus).toHaveBeenCalled() + }) + + it('should sync track removal with core model and audio interface', async () => { + // First add a track + const { addTrack, removeTrack } = useProjectStore.getState() + + await act(async () => { + await addTrack() + }) + + const trackCountAfterAdd = testProject.getTracks().length + const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1] + + // Remove the track + await act(async () => { + await removeTrack(trackCountAfterAdd - 1) // Remove last track + }) + + // Verify core model was updated + expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1) + + // Verify store state reflects change + const storeState = useProjectStore.getState() + expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1) + + // Verify audio interface was notified + expect(mockAudioInterface.removeTrackBus).toHaveBeenCalledWith(trackToRemove.getId()) + }) + + it('should sync track instrument changes with audio interface', async () => { + // Add a track first + const { addTrack, setTrackInstrument } = useProjectStore.getState() + + await act(async () => { + await addTrack() + }) + + const trackIndex = testProject.getTracks().length - 1 + const track = testProject.getTracks()[trackIndex] as KGMidiTrack + const newInstrument: InstrumentType = 'electric_bass' + + // Change track instrument + await act(async () => { + await setTrackInstrument(trackIndex, newInstrument) + }) + + // Verify core model was updated + expect(track.getInstrument()).toBe(newInstrument) + + // Verify store state reflects change + const storeState = useProjectStore.getState() + const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack + expect(storeTrack.getInstrument()).toBe(newInstrument) + + // Verify audio interface was notified + expect(mockAudioInterface.setTrackInstrument).toHaveBeenCalledWith(track.getId(), newInstrument) + }) + + it('should sync track reordering with core model', async () => { + const { addTrack, reorderTracks } = useProjectStore.getState() + + // Add two tracks + await act(async () => { + await addTrack() // Track at index 0 + await addTrack() // Track at index 1 + }) + + const track0Before = testProject.getTracks()[0] + const track1Before = testProject.getTracks()[1] + + // Reorder tracks (move track 0 to position 1) + act(() => { + reorderTracks(0, 1) + }) + + // Verify core model track order changed + const track0After = testProject.getTracks()[0] + const track1After = testProject.getTracks()[1] + + expect(track0After.getId()).toBe(track1Before.getId()) + expect(track1After.getId()).toBe(track0Before.getId()) + + // Verify store state reflects change + const storeState = useProjectStore.getState() + expect(storeState.tracks[0].getId()).toBe(track1Before.getId()) + expect(storeState.tracks[1].getId()).toBe(track0Before.getId()) + }) + }) + + describe('Playback State Synchronization', () => { + it('should sync playhead position with formatted time string', async () => { + const { setPlayheadPosition } = useProjectStore.getState() + const newPosition = 8.5 // beats + + act(() => { + setPlayheadPosition(newPosition) + }) + + // Verify store state updated + const storeState = useProjectStore.getState() + expect(storeState.playheadPosition).toBe(newPosition) + + // Verify formatted time string was updated + expect(storeState.currentTime).toBeDefined() + expect(storeState.currentTime).toContain('|') // Should contain BBB:B | mm:ss:mmm format + }) + + it('should sync playback state changes', async () => { + const { startPlaying, stopPlaying } = useProjectStore.getState() + + // Start playing + await act(async () => { + await startPlaying() + }) + + // Verify store state updated + let storeState = useProjectStore.getState() + expect(storeState.isPlaying).toBe(true) + + // Verify audio interface was called + expect(mockAudioInterface.startPlayback).toHaveBeenCalled() + + // Stop playing + await act(async () => { + await stopPlaying() + }) + + // Verify store state updated + storeState = useProjectStore.getState() + expect(storeState.isPlaying).toBe(false) + + // Verify audio interface was called + expect(mockAudioInterface.stopPlayback).toHaveBeenCalled() + }) + }) + + describe('Selection State Synchronization', () => { + it('should sync selection state with core piano roll state', async () => { + const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState() + + // Add a track and region for testing + const testTrack = new KGMidiTrack('Test Track', 'acoustic_grand_piano') + const testRegion = new KGMidiRegion('Test Region', 0, 16) + testTrack.addRegion(testRegion) + testProject.addTrack(testTrack) + + // Set active region + act(() => { + setActiveRegionId(testRegion.getId()) + }) + + // Verify store state updated + let storeState = useProjectStore.getState() + expect(storeState.activeRegionId).toBe(testRegion.getId()) + + // Simulate core selection changes and sync + act(() => { + syncSelectionFromCore() + }) + + // Verify store selection state is synchronized + storeState = useProjectStore.getState() + expect(storeState.selectedNoteIds).toBeDefined() + expect(storeState.selectedRegionIds).toBeDefined() + }) + + it('should clear all selections and sync state', async () => { + const { clearAllSelections, setSelectedTrack } = useProjectStore.getState() + + // Set some initial selection state + act(() => { + setSelectedTrack('test-track-id') + }) + + // Verify selection was set + let storeState = useProjectStore.getState() + expect(storeState.selectedTrackId).toBe('test-track-id') + + // Clear all selections + act(() => { + clearAllSelections() + }) + + // Verify all selections were cleared + storeState = useProjectStore.getState() + expect(storeState.selectedTrackId).toBeNull() + expect(storeState.selectedNoteIds).toEqual([]) + expect(storeState.selectedRegionIds).toEqual([]) + }) + }) + + describe('Piano Roll State Integration', () => { + it('should sync piano roll visibility and active region', async () => { + const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState() + + // Add test region + const testTrack = new KGMidiTrack('Test Track', 'acoustic_grand_piano') + const testRegion = new KGMidiRegion('Test Region', 0, 16) + testTrack.addRegion(testRegion) + testProject.addTrack(testTrack) + + // Show piano roll with active region + act(() => { + setActiveRegionId(testRegion.getId()) + setShowPianoRoll(true) + }) + + // Verify store state updated + const storeState = useProjectStore.getState() + expect(storeState.showPianoRoll).toBe(true) + expect(storeState.activeRegionId).toBe(testRegion.getId()) + }) + }) + + describe('Error Handling and Edge Cases', () => { + it('should handle invalid track operations gracefully', async () => { + const { removeTrack } = useProjectStore.getState() + const initialTrackCount = testProject.getTracks().length + + // Try to remove non-existent track + await act(async () => { + await removeTrack(999) // Invalid index + }) + + // Verify project state unchanged + expect(testProject.getTracks()).toHaveLength(initialTrackCount) + + // Verify store state unchanged + const storeState = useProjectStore.getState() + expect(storeState.tracks).toHaveLength(initialTrackCount) + }) + + it('should handle concurrent state updates correctly', async () => { + const { setBpm, setMaxBars } = useProjectStore.getState() + + // Execute multiple state updates concurrently + await act(async () => { + setBpm(140) + setMaxBars(64) + }) + + // Verify both updates were applied to core model + expect(testProject.getBpm()).toBe(140) + expect(testProject.getMaxBars()).toBe(64) + + // Verify store state is consistent + const storeState = useProjectStore.getState() + expect(storeState.bpm).toBe(140) + expect(storeState.maxBars).toBe(64) + }) + }) + + describe('Project Loading Integration', () => { + it('should completely sync store state when loading new project', async () => { + const { loadProject } = useProjectStore.getState() + + // Create a new project with specific properties + const newProject = new KGProject('New Loaded Project') + newProject.setBpm(160) + newProject.setTimeSignature({ numerator: 6, denominator: 8 }) + newProject.setKeySignature('D major') + newProject.setMaxBars(48) + + // Add a track with region and notes + const track = new KGMidiTrack('Loaded Track', 'violin') + const region = new KGMidiRegion('Loaded Region', 0, 8) + const note = new KGMidiNote('test-note', 0, 1, 64, 100) + + region.addNote(note) + track.addRegion(region) + newProject.addTrack(track) + + // Load the new project + await act(async () => { + await loadProject(newProject) + }) + + // Verify store state completely matches new project + const storeState = useProjectStore.getState() + expect(storeState.projectName).toBe('New Loaded Project') + expect(storeState.bpm).toBe(160) + expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 }) + expect(storeState.keySignature).toBe('D major') + expect(storeState.maxBars).toBe(48) + expect(storeState.tracks).toHaveLength(1) + + // Verify core model is updated + const core = KGCore.instance() + expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project') + + // Verify CSS properties were updated + const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') + expect(timeSignatureCSS.trim()).toBe('6') + + const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars') + expect(maxBarsCSS.trim()).toBe('48') + }) + }) +}) \ No newline at end of file diff --git a/src/util/abcNotationUtil.test.ts b/src/test/integration/utils/abc-notation.integration.test.ts similarity index 96% rename from src/util/abcNotationUtil.test.ts rename to src/test/integration/utils/abc-notation.integration.test.ts index 211a20c..4525500 100644 --- a/src/util/abcNotationUtil.test.ts +++ b/src/test/integration/utils/abc-notation.integration.test.ts @@ -1,13 +1,13 @@ 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 { convertRegionToABCNotation } from '../../../util/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' +import joyProjectData from '../../fixtures/joy-project.json' // Helper function to load project using real class-transformer deserialization (same as UI) function loadProjectFromJSON(projectData: Record): KGProject { diff --git a/src/test/mocks/audio-interface.ts b/src/test/mocks/audio-interface.ts new file mode 100644 index 0000000..e780d81 --- /dev/null +++ b/src/test/mocks/audio-interface.ts @@ -0,0 +1,40 @@ +/** + * Mock implementation of KGAudioInterface for integration tests + * Provides interface compatibility while avoiding actual audio operations + */ +import { vi } from 'vitest' + +export const mockAudioInterface = { + // Audio context management + startAudioContext: vi.fn().mockResolvedValue(true), + isAudioContextStarted: vi.fn().mockReturnValue(true), + + // Track management + createTrackBus: vi.fn().mockResolvedValue(undefined), + setTrackInstrument: vi.fn().mockResolvedValue(undefined), + setTrackVolume: vi.fn().mockReturnValue(undefined), + setTrackMuted: vi.fn().mockReturnValue(undefined), + setTrackSoloed: vi.fn().mockReturnValue(undefined), + removeTrackBus: vi.fn().mockReturnValue(undefined), + + // Playback control + startPlayback: vi.fn().mockReturnValue(undefined), + stopPlayback: vi.fn().mockReturnValue(undefined), + pausePlayback: vi.fn().mockReturnValue(undefined), + setPlaybackPosition: vi.fn().mockReturnValue(undefined), + + // Note scheduling + scheduleNote: vi.fn().mockReturnValue(undefined), + scheduleNotes: vi.fn().mockReturnValue(undefined), + clearScheduledNotes: vi.fn().mockReturnValue(undefined), + + // Transport + getCurrentBeat: vi.fn().mockReturnValue(0), + setBpm: vi.fn().mockReturnValue(undefined), + + // Singleton pattern + getInstance: vi.fn().mockReturnThis(), +} + +// Mock the class constructor +export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface) \ No newline at end of file diff --git a/src/test/mocks/indexed-db.ts b/src/test/mocks/indexed-db.ts new file mode 100644 index 0000000..dafdec1 --- /dev/null +++ b/src/test/mocks/indexed-db.ts @@ -0,0 +1,55 @@ +/** + * Mock implementation of IndexedDB for integration tests + * Provides in-memory storage that mimics IndexedDB interface + */ +import { vi } from 'vitest' + +// In-memory storage for tests +const mockStorage = new Map() + +export const mockIndexedDB = { + openDB: vi.fn().mockImplementation(() => { + return Promise.resolve({ + put: vi.fn().mockImplementation((storeName: string, data: any, key?: string) => { + const actualKey = key || data.id || 'default' + mockStorage.set(`${storeName}:${actualKey}`, data) + return Promise.resolve(actualKey) + }), + + get: vi.fn().mockImplementation((storeName: string, key: string) => { + return Promise.resolve(mockStorage.get(`${storeName}:${key}`)) + }), + + getAll: vi.fn().mockImplementation((storeName: string) => { + const results: any[] = [] + for (const [key, value] of mockStorage.entries()) { + if (key.startsWith(`${storeName}:`)) { + results.push(value) + } + } + return Promise.resolve(results) + }), + + delete: vi.fn().mockImplementation((storeName: string, key: string) => { + mockStorage.delete(`${storeName}:${key}`) + return Promise.resolve() + }), + + clear: vi.fn().mockImplementation((storeName: string) => { + for (const key of mockStorage.keys()) { + if (key.startsWith(`${storeName}:`)) { + mockStorage.delete(key) + } + } + return Promise.resolve() + }), + + close: vi.fn().mockResolvedValue(undefined), + }) + }), +} + +// Helper function to clear mock storage between tests +export const clearMockStorage = () => { + mockStorage.clear() +} \ No newline at end of file diff --git a/src/test/mocks/tone-js.ts b/src/test/mocks/tone-js.ts new file mode 100644 index 0000000..4ca06a6 --- /dev/null +++ b/src/test/mocks/tone-js.ts @@ -0,0 +1,58 @@ +/** + * Mock implementation of Tone.js for integration tests + * Provides interface compatibility while avoiding actual audio operations + */ +import { vi } from 'vitest' + +// Mock Sampler class +export const mockSampler = { + triggerAttackRelease: vi.fn(), + triggerAttack: vi.fn(), + triggerRelease: vi.fn(), + dispose: vi.fn(), + loaded: true, + toDestination: vi.fn().mockReturnThis(), + connect: vi.fn().mockReturnThis(), + disconnect: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + get: vi.fn().mockReturnValue({}), +} + +// Mock Transport +export const mockTransport = { + start: vi.fn(), + stop: vi.fn(), + pause: vi.fn(), + position: '0:0:0', + bpm: { value: 120 }, + timeSignature: [4, 4], + state: 'stopped', + schedule: vi.fn(), + clear: vi.fn(), + cancel: vi.fn(), +} + +// Mock Tone namespace +export const mockTone = { + Sampler: vi.fn().mockImplementation(() => mockSampler), + Transport: mockTransport, + Buffer: vi.fn().mockImplementation(() => ({ + loaded: true, + duration: 1, + get: vi.fn(), + set: vi.fn(), + })), + ToneAudioBuffer: vi.fn().mockImplementation(() => ({ + loaded: true, + duration: 1, + })), + start: vi.fn(), + getContext: vi.fn().mockReturnValue({ + state: 'running', + resume: vi.fn().mockResolvedValue(undefined), + }), + context: { + state: 'running', + resume: vi.fn().mockResolvedValue(undefined), + }, +} \ No newline at end of file diff --git a/src/test/utils/setup-integration-tests.ts b/src/test/utils/setup-integration-tests.ts new file mode 100644 index 0000000..fd16a9b --- /dev/null +++ b/src/test/utils/setup-integration-tests.ts @@ -0,0 +1,92 @@ +/** + * Integration test setup utilities + * Common setup and teardown for integration tests + */ +import { beforeEach, afterEach, vi } from 'vitest' +import { mockAudioInterface } from '../mocks/audio-interface' +import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db' +import { mockTone } from '../mocks/tone-js' + +// Global setup for integration tests +beforeEach(() => { + // Clear all mocks + vi.clearAllMocks() + + // Clear mock storage + clearMockStorage() + + // Mock external dependencies while keeping internal components real + vi.doMock('../../audio/KGAudioInterface', () => ({ + KGAudioInterface: mockAudioInterface, + default: mockAudioInterface, + })) + + vi.doMock('idb', () => mockIndexedDB) + + vi.doMock('tone', () => mockTone) + + // Mock browser APIs that might be used + Object.defineProperty(window, 'AudioContext', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + state: 'running', + resume: vi.fn().mockResolvedValue(undefined), + })), + }) + + Object.defineProperty(window, 'webkitAudioContext', { + writable: true, + value: window.AudioContext, + }) +}) + +afterEach(() => { + // Clean up after each test + vi.restoreAllMocks() + clearMockStorage() +}) + +// Helper functions for integration tests +export const createMockProject = () => { + // Helper to create a basic project for testing + // This can be expanded as needed + return { + id: 'test-project', + name: 'Test Project', + bpm: 120, + timeSignature: { numerator: 4, denominator: 4 }, + keySignature: 'C major', + maxBars: 32, + tracks: [], + } +} + +export const createMockTrack = (name = 'Test Track') => { + return { + id: `track-${Date.now()}`, + name, + instrument: 'acoustic_grand_piano', + volume: 0.8, + regions: [], + } +} + +export const createMockRegion = (name = 'Test Region') => { + return { + id: `region-${Date.now()}`, + name, + startBeat: 0, + endBeat: 4, + notes: [], + } +} + +export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => { + return { + id: `note-${Date.now()}`, + pitch, + startBeat, + endBeat, + velocity: 100, + } +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index c38feab..c5631e2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,9 +11,10 @@ export default defineConfig({ // Global test setup setupFiles: ['./src/test/setup.ts'], - // Include unit test files co-located with source + // Include unit test files co-located with source AND integration tests include: [ - 'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}' + 'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', + 'src/test/integration/**/*.integration.test.ts' ], // Exclude directories @@ -21,8 +22,7 @@ export default defineConfig({ 'node_modules', 'dist', '.git', - '.cache', - 'tests/' // Exclude integration/e2e folder for now + '.cache' ], // Enable global test functions (describe, it, expect)