import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from '@testing-library/react'; import { KGTrack } from '../core/track/KGTrack'; import { KGMidiTrack } from '../core/track/KGMidiTrack'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGMidiNote } from '../core/midi/KGMidiNote'; import { createDefaultGlobalTracks } from '../core/global-track'; import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil'; const pianoRollStateMocks = vi.hoisted(() => ({ setSheetMusicViewEnabled: vi.fn(), setPianoRollZoom: vi.fn(), })); const audioStorageMocks = vi.hoisted(() => ({ loadAudioFile: vi.fn(), })); const toneMocks = vi.hoisted(() => { const decodeAudioData = vi.fn(); const toneBufferSet = vi.fn(); class MockToneAudioBuffer { public set = toneBufferSet; } return { decodeAudioData, toneBufferSet, ToneAudioBuffer: MockToneAudioBuffer, }; }); let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; let mockIsMetronomeEnabled = false; let mockShowGlobalTracks = false; let mockPlayheadPosition = 0; let mockSelectedItems: Array<{ getId: () => string; select: () => void; deselect: () => void; isSelected: () => boolean }> = []; let mockCopiedItems: Array<{ getId: () => string }> = []; const selectionChangedCallbacks: Array<() => void> = []; const mockProject = { getTimeSignature: () => ({ numerator: 4, denominator: 4 }), getMaxBars: () => 32, getBarWidthMultiplier: () => 1, getTracks: () => mockTracks, getGlobalTracks: () => createDefaultGlobalTracks(), getBpm: () => 120, getKeySignature: () => 'C major', getName: () => 'Test Project', getSelectedMode: () => 'major', getIsLooping: () => false, getIsMetronomeEnabled: () => mockIsMetronomeEnabled, setIsMetronomeEnabled: vi.fn((value: boolean) => { mockIsMetronomeEnabled = value; }), getShowGlobalTracks: () => mockShowGlobalTracks, setShowGlobalTracks: vi.fn((value: boolean) => { mockShowGlobalTracks = value; }), getPlayheadPosition: () => mockPlayheadPosition, setPlayheadPosition: vi.fn((position: number) => { mockPlayheadPosition = position; }), getLoopingRange: () => [0, 0] as [number, number], getPianoRollZoom: () => 1, }; let currentProject = mockProject; const mockAudioInterface = { getTransportPosition: vi.fn().mockReturnValue(8), startAudioRecording: vi.fn().mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }), stopAudioRecording: vi.fn().mockResolvedValue(null), cancelAudioRecording: vi.fn().mockResolvedValue(undefined), removeTrackSynth: vi.fn(), removeTrackAudioPlayerBus: vi.fn(), createTrackAudioPlayerBus: vi.fn().mockResolvedValue(undefined), hasTrackAudioPlayerBus: vi.fn().mockReturnValue(true), hasAudioBufferForTrack: vi.fn().mockReturnValue(false), getAudioBuffer: vi.fn(), loadAudioBufferForTrack: vi.fn(), createTrackSynth: vi.fn(), setTrackVolume: vi.fn(), setTrackMute: vi.fn(), setTrackSolo: vi.fn(), setMetronomeEnabled: vi.fn(), }; const configValues = new Map([ ['audio.input_device_id', 'default'], ]); const mockCore = { getCurrentProject: () => currentProject, setCurrentProject: vi.fn((project: typeof mockProject) => { currentProject = project; }), setPlayheadUpdateCallback: vi.fn(), setPlaybackStateChangeCallback: vi.fn(), setLoopBoundaryReachedCallback: vi.fn(), getSelectedItems: () => mockSelectedItems, getCopiedItems: () => mockCopiedItems, onSelectionChanged: vi.fn((callback: () => void) => { selectionChangedCallbacks.push(callback); }), canUndo: () => false, canRedo: () => false, getUndoDescription: () => '', getRedoDescription: () => '', setOnCommandHistoryChanged: vi.fn(), executeCommand: vi.fn(), undo: vi.fn(() => true), redo: vi.fn(() => true), clearSelectedItems: vi.fn(() => { mockSelectedItems = []; selectionChangedCallbacks.forEach(callback => callback()); }), addSelectedItems: vi.fn((items: Array<{ getId: () => string; select: () => void; deselect: () => void; isSelected: () => boolean }>) => { const incomingIds = new Set(items.map(item => item.getId())); mockSelectedItems = mockSelectedItems.filter(item => !incomingIds.has(item.getId())); mockSelectedItems.push(...items); selectionChangedCallbacks.forEach(callback => callback()); }), getStatus: () => 'Ready', setStatus: vi.fn(), getPlayheadPosition: () => mockPlayheadPosition, setPlayheadPosition: vi.fn((position: number) => { mockPlayheadPosition = position; }), getIsPlaying: () => false, startPlaying: vi.fn().mockResolvedValue(undefined), stopPlaying: vi.fn().mockResolvedValue(undefined), }; vi.mock('../core/KGCore', () => ({ KGCore: { instance: () => mockCore, }, })); vi.mock('../core/audio-interface/KGAudioInterface', () => ({ KGAudioInterface: { instance: () => mockAudioInterface, }, })); vi.mock('../core/io/KGAudioFileStorage', () => ({ KGAudioFileStorage: { loadAudioFile: audioStorageMocks.loadAudioFile, }, })); vi.mock('tone', () => ({ getContext: () => ({ rawContext: { decodeAudioData: toneMocks.decodeAudioData, }, }), ToneAudioBuffer: toneMocks.ToneAudioBuffer, })); vi.mock('../core/config/ConfigManager', () => ({ ConfigManager: { instance: () => ({ getIsInitialized: () => true, get: (key: string) => configValues.get(key), set: vi.fn(async (key: string, value: unknown) => { configValues.set(key, value); }), }), }, })); vi.mock('../core/state/KGPianoRollState', () => ({ KGPianoRollState: { instance: () => pianoRollStateMocks, }, })); describe('projectStore piano roll state', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); pianoRollStateMocks.setSheetMusicViewEnabled.mockReset(); pianoRollStateMocks.setPianoRollZoom.mockReset(); mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; currentProject = mockProject; mockPlayheadPosition = 0; mockCore.startPlaying.mockReset(); mockCore.startPlaying.mockResolvedValue(undefined); mockCore.stopPlaying.mockReset(); mockCore.stopPlaying.mockResolvedValue(undefined); mockCore.executeCommand.mockReset(); mockCore.setPlayheadPosition.mockClear(); mockCore.undo.mockReset(); mockCore.undo.mockReturnValue(true); mockCore.redo.mockReset(); mockCore.redo.mockReturnValue(true); mockCore.setLoopBoundaryReachedCallback.mockReset(); mockAudioInterface.startAudioRecording.mockReset(); mockAudioInterface.startAudioRecording.mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }); mockAudioInterface.stopAudioRecording.mockReset(); mockAudioInterface.stopAudioRecording.mockResolvedValue(null); mockAudioInterface.cancelAudioRecording.mockReset(); mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined); mockAudioInterface.getTransportPosition.mockReset(); mockAudioInterface.getTransportPosition.mockReturnValue(8); mockAudioInterface.hasTrackAudioPlayerBus.mockReset(); mockAudioInterface.hasTrackAudioPlayerBus.mockReturnValue(true); mockAudioInterface.hasAudioBufferForTrack.mockReset(); mockAudioInterface.hasAudioBufferForTrack.mockReturnValue(false); mockAudioInterface.getAudioBuffer.mockReset(); mockAudioInterface.createTrackAudioPlayerBus.mockReset(); mockAudioInterface.createTrackAudioPlayerBus.mockResolvedValue(undefined); mockAudioInterface.loadAudioBufferForTrack.mockReset(); mockAudioInterface.setMetronomeEnabled.mockReset(); audioStorageMocks.loadAudioFile.mockReset(); toneMocks.decodeAudioData.mockReset(); toneMocks.toneBufferSet.mockReset(); mockSelectedItems = []; mockCopiedItems = []; selectionChangedCallbacks.length = 0; mockCore.onSelectionChanged.mockClear(); mockCore.clearSelectedItems.mockClear(); mockCore.addSelectedItems.mockClear(); mockIsMetronomeEnabled = false; mockShowGlobalTracks = false; mockProject.setIsMetronomeEnabled.mockClear(); mockProject.setShowGlobalTracks.mockClear(); mockProject.setPlayheadPosition.mockClear(); configValues.set('audio.input_device_id', 'default'); }); it('clears hybrid state when opening a MIDI region', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().openHybridMode('midi-a', 'audio-a'); }); let state = useProjectStore.getState(); expect(state.pianoRollMode).toBe('hybrid'); expect(state.activeRegionId).toBe('midi-a'); expect(state.hybridAudioRegionId).toBe('audio-a'); act(() => { useProjectStore.getState().openMidiPianoRoll('midi-b'); }); state = useProjectStore.getState(); expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(false); expect(state.showPianoRoll).toBe(true); expect(state.pianoRollMode).toBe('midi-edit'); expect(state.activeRegionId).toBe('midi-b'); expect(state.hybridAudioRegionId).toBeNull(); expect(state.requestedSheetMusicViewEnabled).toBe(false); expect(state.pianoRollViewRequestVersion).toBe(1); }); it('opens a MIDI region in sheet music view when requested', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().openHybridMode('midi-a', 'audio-a'); }); act(() => { useProjectStore.getState().openMidiPianoRollWithSheetMusicView('midi-b', true); }); const state = useProjectStore.getState(); expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(true); expect(state.showPianoRoll).toBe(true); expect(state.pianoRollMode).toBe('midi-edit'); expect(state.activeRegionId).toBe('midi-b'); expect(state.hybridAudioRegionId).toBeNull(); expect(state.requestedSheetMusicViewEnabled).toBe(true); expect(state.pianoRollViewRequestVersion).toBe(1); }); it('opens an audio region in waveform view and clears hybrid state', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().openHybridMode('midi-a', 'audio-a'); }); act(() => { useProjectStore.getState().openAudioWaveformViewer('audio-b'); }); const state = useProjectStore.getState(); expect(state.showPianoRoll).toBe(true); expect(state.pianoRollMode).toBe('audio-waveform'); expect(state.activeRegionId).toBe('audio-b'); expect(state.hybridAudioRegionId).toBeNull(); expect(state.requestedSheetMusicViewEnabled).toBe(false); }); it('initializes persisted metronome and global-track visibility state from the project', async () => { mockIsMetronomeEnabled = true; mockShowGlobalTracks = true; const { useProjectStore } = await import('./projectStore'); const state = useProjectStore.getState(); expect(state.isMetronomeEnabled).toBe(true); expect(state.showGlobalTracks).toBe(true); }); it('persists metronome toggles to the project and audio interface', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().toggleMetronome(); }); const state = useProjectStore.getState(); expect(mockProject.setIsMetronomeEnabled).toHaveBeenCalledWith(true); expect(mockAudioInterface.setMetronomeEnabled).toHaveBeenCalledWith(true); expect(state.isMetronomeEnabled).toBe(true); }); it('persists global-track visibility changes to the project', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().setShowGlobalTracks(true); }); const state = useProjectStore.getState(); expect(mockProject.setShowGlobalTracks).toHaveBeenCalledWith(true); expect(state.showGlobalTracks).toBe(true); }); it('tracks playback preparation around startPlaying success', async () => { let resolveStart: (() => void) | null = null; mockCore.startPlaying.mockImplementationOnce(() => new Promise((resolve) => { resolveStart = resolve; })); const { useProjectStore } = await import('./projectStore'); const startPromise = useProjectStore.getState().startPlaying(); expect(useProjectStore.getState().isPreparingPlayback).toBe(true); await act(async () => { resolveStart?.(); await startPromise; }); const state = useProjectStore.getState(); expect(state.isPreparingPlayback).toBe(false); expect(state.isPlaying).toBe(true); }); it('clears playback preparation if startPlaying fails', async () => { mockCore.startPlaying.mockRejectedValueOnce(new Error('prepare failed')); const { useProjectStore } = await import('./projectStore'); await expect(useProjectStore.getState().startPlaying()).rejects.toThrow('prepare failed'); expect(useProjectStore.getState().isPreparingPlayback).toBe(false); }); it('ignores repeated startPlaying calls while already preparing', async () => { let resolveStart: (() => void) | null = null; mockCore.startPlaying.mockImplementationOnce(() => new Promise((resolve) => { resolveStart = resolve; })); const { useProjectStore } = await import('./projectStore'); const firstStart = useProjectStore.getState().startPlaying(); const secondStart = useProjectStore.getState().startPlaying(); expect(mockCore.startPlaying).toHaveBeenCalledTimes(1); await act(async () => { resolveStart?.(); await Promise.all([firstStart, secondStart]); }); }); it('clears playback preparation when stopTransport is called during prepare', async () => { let resolveStart: (() => void) | null = null; mockCore.startPlaying.mockImplementationOnce(() => new Promise((resolve) => { resolveStart = resolve; })); const { useProjectStore } = await import('./projectStore'); const startPromise = useProjectStore.getState().startPlaying(); expect(useProjectStore.getState().isPreparingPlayback).toBe(true); await act(async () => { await useProjectStore.getState().stopTransport(); }); expect(useProjectStore.getState().isPreparingPlayback).toBe(false); await act(async () => { resolveStart?.(); await startPromise; }); }); it('starts and stops audio-track recording without requiring a selected region', async () => { const { KGAudioTrack } = await import('../core/track/KGAudioTrack'); const audioTrack = new KGAudioTrack('Audio 1', 1); audioTrack.setTrackIndex(0); mockTracks = [audioTrack]; const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().setSelectedTrack('1'); useProjectStore.getState().setPlayheadPosition(8); }); await act(async () => { await useProjectStore.getState().startRecording(); }); await act(async () => { vi.advanceTimersByTime(2000); await Promise.resolve(); }); expect(mockCore.startPlaying).toHaveBeenCalledWith({ preserveLoopPreroll: false }); expect(mockAudioInterface.startAudioRecording).toHaveBeenCalled(); expect(useProjectStore.getState().recordingMode).toBe('audio'); expect(useProjectStore.getState().recordingCommitStartBeatAbsolute).toBe(8); await act(async () => { await useProjectStore.getState().stopTransport(); }); expect(mockAudioInterface.stopAudioRecording).toHaveBeenCalled(); expect(useProjectStore.getState().isRecording).toBe(false); expect(useProjectStore.getState().recordingMode).toBeNull(); expect(useProjectStore.getState().playheadPosition).toBe(8); }); it('bumps track automation redraw version on undo and redo', async () => { const { useProjectStore } = await import('./projectStore'); const initialVersion = useProjectStore.getState().trackAutomationRedrawVersion; act(() => { useProjectStore.getState().undo(); }); expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 1); act(() => { useProjectStore.getState().redo(); }); expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 2); }); it('rehydrates missing audio buffers during refreshProjectState', async () => { const audioTrack = new KGAudioTrack('Audio 1', 1); audioTrack.setTrackIndex(0); audioTrack.setRegions([ new KGAudioRegion('audio-region-1', '1', 0, 'clip.wav', 0, 4, 'audio-file-1.wav', 'clip.wav', 2.5), ]); mockTracks = [audioTrack]; const decodedBuffer = { duration: 2.5 } as AudioBuffer; audioStorageMocks.loadAudioFile.mockResolvedValue(new ArrayBuffer(16)); toneMocks.decodeAudioData.mockResolvedValue(decodedBuffer); const { useProjectStore } = await import('./projectStore'); await act(async () => { useProjectStore.getState().refreshProjectState(); await Promise.resolve(); await Promise.resolve(); }); expect(audioStorageMocks.loadAudioFile).toHaveBeenCalledWith('Test Project', 'audio-file-1.wav'); expect(toneMocks.decodeAudioData).toHaveBeenCalled(); expect(mockAudioInterface.loadAudioBufferForTrack).toHaveBeenCalledWith( '1', 'audio-file-1.wav', expect.any(toneMocks.ToneAudioBuffer), ); }); it('reloads a restored audio track buffer on undo', async () => { const restoredTrack = new KGAudioTrack('Audio 1', 1); restoredTrack.setTrackIndex(0); const restoredRegion = new KGAudioRegion( 'audio-region-1', '1', 0, 'clip.wav', 0, 4, 'audio-file-1.wav', 'clip.wav', 2.5, ); restoredTrack.setRegions([restoredRegion]); mockTracks = []; mockCore.undo.mockImplementationOnce(() => { mockTracks = [restoredTrack]; return true; }); const decodedBuffer = { duration: 2.5 } as AudioBuffer; audioStorageMocks.loadAudioFile.mockResolvedValue(new ArrayBuffer(16)); toneMocks.decodeAudioData.mockResolvedValue(decodedBuffer); const { useProjectStore } = await import('./projectStore'); const initialWaveformVersion = useProjectStore.getState().audioWaveformRedrawVersion; await act(async () => { useProjectStore.getState().undo(); await Promise.resolve(); await Promise.resolve(); }); const state = useProjectStore.getState(); expect(state.tracks).toHaveLength(1); const restoredTrackState = state.tracks[0] as KGAudioTrack; expect((restoredTrackState.getRegions()[0] as KGAudioRegion).getAudioFileId()).toBe('audio-file-1.wav'); expect(mockAudioInterface.loadAudioBufferForTrack).toHaveBeenCalledWith( '1', 'audio-file-1.wav', expect.any(toneMocks.ToneAudioBuffer), ); expect(state.audioWaveformRedrawVersion).toBeGreaterThan(initialWaveformVersion); }); it('does not attempt audio buffer hydration for MIDI-only undo', async () => { mockTracks = [new KGMidiTrack('Track 1', 1, 'acoustic_grand_piano')]; const { useProjectStore } = await import('./projectStore'); await act(async () => { useProjectStore.getState().undo(); await Promise.resolve(); }); expect(audioStorageMocks.loadAudioFile).not.toHaveBeenCalled(); expect(mockAudioInterface.loadAudioBufferForTrack).not.toHaveBeenCalled(); }); it('restores Chat after closing Settings when Chat was active on entry', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().toggleChatBox(); useProjectStore.getState().setShowSettings(true); }); let state = useProjectStore.getState(); expect(state.showSettings).toBe(true); expect(state.settingsReturnSidePanel).toBe('chat'); act(() => { useProjectStore.getState().setShowSettings(false); }); state = useProjectStore.getState(); expect(state.showSettings).toBe(false); expect(state.showChatBox).toBe(true); expect(state.showKGOnePanel).toBe(false); expect(state.showEventListPanel).toBe(false); }); it('restores K.G.One after closing Settings when K.G.One was active on entry', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().toggleKGOnePanel(); useProjectStore.getState().setShowSettings(true); useProjectStore.getState().setShowSettings(false); }); const state = useProjectStore.getState(); expect(state.showSettings).toBe(false); expect(state.showKGOnePanel).toBe(true); expect(state.showChatBox).toBe(false); expect(state.showEventListPanel).toBe(false); }); it('restores no side panel after closing Settings when none was active on entry', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().toggleChatBox(); useProjectStore.getState().toggleChatBox(); useProjectStore.getState().setShowSettings(true); useProjectStore.getState().setShowSettings(false); }); const state = useProjectStore.getState(); expect(state.showSettings).toBe(false); expect(state.showChatBox).toBe(false); expect(state.showKGOnePanel).toBe(false); expect(state.showEventListPanel).toBe(false); expect(state.settingsReturnSidePanel).toBeNull(); expect(state.lastActiveSidePanel).toBe('chat'); }); it('opens Event List and exits Settings when Event List is activated from Settings', async () => { const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().toggleChatBox(); useProjectStore.getState().setShowSettings(true); useProjectStore.getState().activateSidePanel('eventList'); }); const state = useProjectStore.getState(); expect(state.showSettings).toBe(false); expect(state.showEventListPanel).toBe(true); expect(state.showChatBox).toBe(false); expect(state.showKGOnePanel).toBe(false); expect(state.settingsReturnSidePanel).toBeNull(); expect(state.lastActiveSidePanel).toBe('eventList'); }); it('refreshes expanded max bars after BPM reduction', async () => { const { KGAudioTrack: TestAudioTrack } = await import('../core/track/KGAudioTrack'); const { KGAudioRegion: TestAudioRegion } = await import('../core/region/KGAudioRegion'); const audioTrack = new TestAudioTrack('Audio 1', 1); audioTrack.setTrackIndex(0); const audioRegion = new TestAudioRegion('audio-region-1', '1', 0, 'clip.wav', 124, 4, 'audio-file-1.wav', 'clip.wav', 8, 0); audioTrack.setRegions([audioRegion]); let projectName = 'Test Project'; let bpm = 120; let maxBars = 32; let currentBars = 0; let timeSignature = { numerator: 4, denominator: 4 }; let keySignature = 'C major'; let selectedMode = 'major'; const project = { getName: () => projectName, setName: (value: string) => { projectName = value; }, getMaxBars: () => maxBars, setMaxBars: (value: number) => { maxBars = value; }, getCurrentBars: () => currentBars, setCurrentBars: (value: number) => { currentBars = value; }, getTimeSignature: () => timeSignature, setTimeSignature: (value: { numerator: number; denominator: number }) => { timeSignature = value; }, getBarWidthMultiplier: () => 1, getTracks: () => [audioTrack], getGlobalTracks: () => createDefaultGlobalTracks(), getBpm: () => bpm, setBpm: (value: number) => { bpm = value; }, getKeySignature: () => keySignature, setKeySignature: (value: string) => { keySignature = value; }, getSelectedMode: () => selectedMode, setSelectedMode: (value: string) => { selectedMode = value; }, getIsLooping: () => false, getIsMetronomeEnabled: () => false, setIsMetronomeEnabled: vi.fn(), getShowGlobalTracks: () => false, setShowGlobalTracks: vi.fn(), getLoopingRange: () => [0, 0] as [number, number], getPianoRollZoom: () => 1, }; currentProject = project as unknown as typeof mockProject; mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute()); const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().setBpm(60); }); const state = useProjectStore.getState(); expect(bpm).toBe(60); expect(maxBars).toBe(33); expect(state.maxBars).toBe(33); expect(audioRegion.getLength()).toBeCloseTo(8); expect(getAudioRegionDisplayLengthBeats(project as any, audioRegion)).toBeCloseTo(8); }); it('does not auto-shrink max bars after BPM increase', async () => { const { KGAudioTrack: TestAudioTrack } = await import('../core/track/KGAudioTrack'); const { KGAudioRegion: TestAudioRegion } = await import('../core/region/KGAudioRegion'); const audioTrack = new TestAudioTrack('Audio 1', 1); audioTrack.setTrackIndex(0); audioTrack.setRegions([ new TestAudioRegion('audio-region-1', '1', 0, 'clip.wav', 124, 8, 'audio-file-1.wav', 'clip.wav', 4, 0), ]); let projectName = 'Test Project'; let bpm = 120; let maxBars = 40; let currentBars = 0; let timeSignature = { numerator: 4, denominator: 4 }; let keySignature = 'C major'; let selectedMode = 'major'; const project = { getName: () => projectName, setName: (value: string) => { projectName = value; }, getMaxBars: () => maxBars, setMaxBars: (value: number) => { maxBars = value; }, getCurrentBars: () => currentBars, setCurrentBars: (value: number) => { currentBars = value; }, getTimeSignature: () => timeSignature, setTimeSignature: (value: { numerator: number; denominator: number }) => { timeSignature = value; }, getBarWidthMultiplier: () => 1, getTracks: () => [audioTrack], getGlobalTracks: () => createDefaultGlobalTracks(), getBpm: () => bpm, setBpm: (value: number) => { bpm = value; }, getKeySignature: () => keySignature, setKeySignature: (value: string) => { keySignature = value; }, getSelectedMode: () => selectedMode, setSelectedMode: (value: string) => { selectedMode = value; }, getIsLooping: () => false, getIsMetronomeEnabled: () => false, setIsMetronomeEnabled: vi.fn(), getShowGlobalTracks: () => false, setShowGlobalTracks: vi.fn(), getLoopingRange: () => [0, 0] as [number, number], getPianoRollZoom: () => 1, }; currentProject = project as unknown as typeof mockProject; mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute()); const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().setBpm(180); }); const state = useProjectStore.getState(); expect(bpm).toBe(180); expect(maxBars).toBe(40); expect(state.maxBars).toBe(40); expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(12); }); it('moves the playhead to the song end when max bars shrink past it', async () => { const { KGProject } = await import('../core/KGProject'); mockPlayheadPosition = 124; currentProject = new KGProject('Test Project', 32, 0, 120) as unknown as typeof mockProject; mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute()); const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().setMaxBars(16); }); const state = useProjectStore.getState(); expect(currentProject.getMaxBars()).toBe(16); expect(mockPlayheadPosition).toBe(64); expect(mockCore.setPlayheadPosition).toHaveBeenCalledWith(64); expect(state.maxBars).toBe(16); expect(state.playheadPosition).toBe(64); }); it('selects only the newly pasted notes after pasting into the active MIDI region', async () => { const { KGMidiTrack: TestMidiTrack } = await import('../core/track/KGMidiTrack'); const { KGMidiRegion: TestMidiRegion } = await import('../core/region/KGMidiRegion'); const { KGMidiNote: TestMidiNote } = await import('../core/midi/KGMidiNote'); const track = new TestMidiTrack('Track 1', 1, 'acoustic_grand_piano'); track.setTrackIndex(0); const region = new TestMidiRegion('region-1', '1', 0, 'Region 1', 0, 16); const existingSelectedNote = new TestMidiNote('existing-note', 0, 1, 60, 100); existingSelectedNote.select(); region.addNote(existingSelectedNote); track.setRegions([region]); mockTracks = [track]; currentProject = { ...mockProject, getTracks: () => mockTracks, } as typeof mockProject; mockSelectedItems = [existingSelectedNote]; mockCopiedItems = [ new TestMidiNote('copied-a', 2, 3, 64, 110), new TestMidiNote('copied-b', 3, 4, 67, 120), ]; mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute()); const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().pasteNotesToActiveRegion(region.getId(), 8); }); const pastedNotes = region.getNotes().filter(note => note.getId() !== existingSelectedNote.getId()); const pastedNoteIds = pastedNotes.map(note => note.getId()); const state = useProjectStore.getState(); expect(mockCore.executeCommand).toHaveBeenCalledTimes(1); expect(existingSelectedNote.isSelected()).toBe(false); expect(pastedNotes).toHaveLength(2); expect(pastedNotes.every(note => note.isSelected())).toBe(true); expect(mockCore.clearSelectedItems).toHaveBeenCalledTimes(1); expect(mockCore.addSelectedItems).toHaveBeenCalledWith(pastedNotes); expect(state.selectedNoteIds).toEqual(pastedNoteIds); }); it('keeps selection unchanged when note paste has no clipboard notes', async () => { const { KGMidiTrack: TestMidiTrack } = await import('../core/track/KGMidiTrack'); const { KGMidiRegion: TestMidiRegion } = await import('../core/region/KGMidiRegion'); const { KGMidiNote: TestMidiNote } = await import('../core/midi/KGMidiNote'); const track = new TestMidiTrack('Track 1', 1, 'acoustic_grand_piano'); track.setTrackIndex(0); const region = new TestMidiRegion('region-1', '1', 0, 'Region 1', 0, 16); const existingSelectedNote = new TestMidiNote('existing-note', 0, 1, 60, 100); existingSelectedNote.select(); region.addNote(existingSelectedNote); track.setRegions([region]); mockTracks = [track]; currentProject = { ...mockProject, getTracks: () => mockTracks, } as typeof mockProject; mockSelectedItems = [existingSelectedNote]; mockCopiedItems = []; const { useProjectStore } = await import('./projectStore'); act(() => { useProjectStore.getState().pasteNotesToActiveRegion(region.getId(), 8); }); expect(mockCore.executeCommand).not.toHaveBeenCalled(); expect(mockCore.clearSelectedItems).not.toHaveBeenCalled(); expect(mockCore.addSelectedItems).not.toHaveBeenCalled(); expect(existingSelectedNote.isSelected()).toBe(true); }); });