+ Timing correction for MIDI recording (0-500ms). If recorded notes appear slightly late compared to where you intended to play them, increase this value to match your MIDI device's input latency. Each note's position is shifted back by this amount when committed.
+
+
+
diff --git a/src/constants/coreConstants.ts b/src/constants/coreConstants.ts
index 9c42339..f675279 100644
--- a/src/constants/coreConstants.ts
+++ b/src/constants/coreConstants.ts
@@ -63,7 +63,9 @@ export const KEY_SIGNATURE_MAP = {
export const AUDIO_INTERFACE_CONSTANTS = {
DEFAULT_MASTER_VOLUME: 0.8,
- DEFAULT_TRACK_VOLUME: 0.8,
+ DEFAULT_TRACK_VOLUME: 0, // 0 dB (unity gain)
+ MIN_TRACK_VOLUME_DB: -60, // practical floor; displayed as −∞
+ MAX_TRACK_VOLUME_DB: 12,
};
export const SAMPLER_CONSTANTS = {
diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts
index 83f8151..28647d7 100644
--- a/src/core/KGProject.ts
+++ b/src/core/KGProject.ts
@@ -52,7 +52,7 @@ export class KGProject {
@WithDefault(0)
private projectStructureVersion: number = 0;
- public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6;
+ public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 7;
@Expose()
@Type(() => KGTrack, {
diff --git a/src/core/audio-interface/KGAudioBus.ts b/src/core/audio-interface/KGAudioBus.ts
index e1a51db..af6679e 100644
--- a/src/core/audio-interface/KGAudioBus.ts
+++ b/src/core/audio-interface/KGAudioBus.ts
@@ -283,9 +283,8 @@ export class KGAudioBus {
*/
private updateSamplerVolume(): void {
try {
- const effectiveVolume = this.muted ? 0 : this.volume;
- const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
- this.sampler.volume.value = volumeDb;
+ const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
+ this.sampler.volume.value = isSilent ? -Infinity : this.volume;
} catch (error) {
console.error(`Error updating volume for ${this.instrument}:`, error);
}
@@ -297,14 +296,8 @@ export class KGAudioBus {
*/
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
try {
- let effectiveVolume = this.volume;
- if (this.muted) {
- effectiveVolume = 0;
- } else if (hasSoloedTracks && !this.solo) {
- effectiveVolume = 0;
- }
- const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
- this.sampler.volume.value = volumeDb;
+ const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
+ this.sampler.volume.value = isSilent ? -Infinity : this.volume;
} catch (error) {
console.error(`Error applying effective volume for ${this.instrument}:`, error);
}
diff --git a/src/core/audio-interface/KGAudioPlayerBus.ts b/src/core/audio-interface/KGAudioPlayerBus.ts
index f4f97ca..a39376d 100644
--- a/src/core/audio-interface/KGAudioPlayerBus.ts
+++ b/src/core/audio-interface/KGAudioPlayerBus.ts
@@ -200,14 +200,8 @@ export class KGAudioPlayerBus {
*/
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
try {
- let effectiveVolume = this.volume;
- if (this.muted) {
- effectiveVolume = 0;
- } else if (hasSoloedTracks && !this.solo) {
- effectiveVolume = 0;
- }
- const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
- this.gainNode.gain.value = Math.pow(10, volumeDb / 20);
+ const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
+ this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
} catch (error) {
console.error('Error applying effective volume for audio player bus:', error);
}
@@ -266,9 +260,8 @@ export class KGAudioPlayerBus {
private updateGainVolume(): void {
try {
- const effectiveVolume = this.muted ? 0 : this.volume;
- // Convert linear volume to gain value
- this.gainNode.gain.value = effectiveVolume;
+ const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
+ this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
} catch (error) {
console.error('Error updating gain volume:', error);
}
diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts
index 1ab940d..92100be 100644
--- a/src/core/project-upgrader/KGProjectUpgrader.ts
+++ b/src/core/project-upgrader/KGProjectUpgrader.ts
@@ -5,6 +5,7 @@ import { upgradeToV3 } from './upgradeToV3';
import { upgradeToV4 } from './upgradeToV4';
import { upgradeToV5 } from './upgradeToV5';
import { upgradeToV6 } from './upgradeToV6';
+import { upgradeToV7 } from './upgradeToV7';
/**
* Upgrade the given project to the latest structure version, one version at a time.
@@ -48,6 +49,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV6(workingProject);
break;
}
+ case 7: {
+ workingProject = upgradeToV7(workingProject);
+ break;
+ }
default: {
// If an upgrader is missing, throw to prevent loading incompatible structures
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
diff --git a/src/core/project-upgrader/upgradeToV7.test.ts b/src/core/project-upgrader/upgradeToV7.test.ts
new file mode 100644
index 0000000..6df72bd
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV7.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { KGProject } from '../KGProject';
+import { KGMidiTrack } from '../track/KGMidiTrack';
+import { upgradeToV7 } from './upgradeToV7';
+import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
+
+function makeProject(volumes: number[]): KGProject {
+ const tracks = volumes.map((v, i) => {
+ const track = new KGMidiTrack(`Track ${i}`, i);
+ // Bypass setVolume clamping to simulate old 0–1 linear values stored in JSON
+ (track as unknown as { volume: number }).volume = v;
+ return track;
+ });
+ const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, undefined, tracks, 6);
+ return project;
+}
+
+describe('upgradeToV7', () => {
+ it('converts 1.0 linear to 0 dB', () => {
+ const project = makeProject([1.0]);
+ upgradeToV7(project);
+ expect(project.getTracks()[0].getVolume()).toBeCloseTo(0, 5);
+ });
+
+ it('converts 0.8 linear to ~−1.94 dB', () => {
+ const project = makeProject([0.8]);
+ upgradeToV7(project);
+ expect(project.getTracks()[0].getVolume()).toBeCloseTo(20 * Math.log10(0.8), 3);
+ });
+
+ it('converts 0.5 linear to ~−6.02 dB', () => {
+ const project = makeProject([0.5]);
+ upgradeToV7(project);
+ expect(project.getTracks()[0].getVolume()).toBeCloseTo(-6.021, 2);
+ });
+
+ it('converts 0.0 linear to MIN_TRACK_VOLUME_DB', () => {
+ const project = makeProject([0.0]);
+ upgradeToV7(project);
+ expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB);
+ });
+
+ it('clamps values that exceed MAX_TRACK_VOLUME_DB', () => {
+ // linear > 1.0 would give positive dB; cap at +6
+ const project = makeProject([2.0]);
+ upgradeToV7(project);
+ expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB);
+ });
+
+ it('bumps project structure version to 7', () => {
+ const project = makeProject([1.0]);
+ upgradeToV7(project);
+ expect(project.getProjectStructureVersion()).toBe(7);
+ });
+
+ it('handles multiple tracks independently', () => {
+ const project = makeProject([1.0, 0.5, 0.0]);
+ upgradeToV7(project);
+ const tracks = project.getTracks();
+ expect(tracks[0].getVolume()).toBeCloseTo(0, 5);
+ expect(tracks[1].getVolume()).toBeCloseTo(-6.021, 2);
+ expect(tracks[2].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB);
+ });
+});
diff --git a/src/core/project-upgrader/upgradeToV7.ts b/src/core/project-upgrader/upgradeToV7.ts
new file mode 100644
index 0000000..9ccab3f
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV7.ts
@@ -0,0 +1,23 @@
+import { KGProject } from '../KGProject';
+import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
+
+export function upgradeToV7(project: KGProject): KGProject {
+ try {
+ // Convert track volumes from 0–1 linear scale to dB
+ for (const track of project.getTracks()) {
+ const linear = track.getVolume();
+ let db: number;
+ if (linear <= 0) {
+ db = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
+ } else {
+ db = 20 * Math.log10(linear);
+ db = Math.max(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
+ Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, db));
+ }
+ track.setVolume(db);
+ }
+ } finally {
+ project.setProjectStructureVersion(7);
+ }
+ return project;
+}
diff --git a/src/core/track/KGMidiTrack.test.ts b/src/core/track/KGMidiTrack.test.ts
index be30ef1..e7066bd 100644
--- a/src/core/track/KGMidiTrack.test.ts
+++ b/src/core/track/KGMidiTrack.test.ts
@@ -19,18 +19,18 @@ describe('KGMidiTrack', () => {
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.getVolume()).toBe(0) // DEFAULT_TRACK_VOLUME (0 dB)
expect(defaultTrack.getRegions()).toEqual([])
})
it('should create track with custom parameters', () => {
- const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', 0.6)
+ const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4)
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)
+ expect(customTrack.getVolume()).toBe(-4)
})
it('should set correct type identifier', () => {
@@ -193,33 +193,33 @@ describe('KGMidiTrack', () => {
describe('inheritance from KGTrack', () => {
it('should inherit all base track properties', () => {
- const customTrack = new KGMidiTrack('Test Track', 42, 'violin', 0.9)
+ const customTrack = new KGMidiTrack('Test Track', 42, 'violin', -1)
expect(customTrack.getName()).toBe('Test Track')
expect(customTrack.getId()).toBe(42)
expect(customTrack.getType()).toBe(TrackType.MIDI)
- expect(customTrack.getVolume()).toBe(0.9)
+ expect(customTrack.getVolume()).toBe(-1)
})
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.setVolume(-6)
+ expect(track.getVolume()).toBe(-6)
track.setTrackIndex(3)
expect(track.getTrackIndex()).toBe(3)
})
it('should inherit volume controls', () => {
- expect(track.getVolume()).toBe(0.8) // Default volume
+ expect(track.getVolume()).toBe(0) // Default volume (0 dB)
- track.setVolume(0.5)
- expect(track.getVolume()).toBe(0.5)
+ track.setVolume(-6)
+ expect(track.getVolume()).toBe(-6)
- track.setVolume(1.0)
- expect(track.getVolume()).toBe(1.0)
+ track.setVolume(0)
+ expect(track.getVolume()).toBe(0)
})
})
@@ -275,8 +275,8 @@ describe('KGMidiTrack', () => {
// Setup initial state
track.setName('Piano Track')
track.setInstrument('acoustic_grand_piano')
- track.setVolume(0.7)
-
+ track.setVolume(-3)
+
const regions = [
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
@@ -286,21 +286,21 @@ describe('KGMidiTrack', () => {
// Verify initial state
expect(track.getName()).toBe('Piano Track')
expect(track.getInstrument()).toBe('acoustic_grand_piano')
- expect(track.getVolume()).toBe(0.7)
+ expect(track.getVolume()).toBe(-3)
expect(track.getRegions()).toHaveLength(2)
// Modify state
track.setInstrument('electric_piano_1')
- track.addRegion(createMockMidiRegion({
- id: 'r3',
- trackId: '0',
- name: 'Chorus'
+ 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.getVolume()).toBe(-3)
expect(track.getRegions()).toHaveLength(3)
})
@@ -337,15 +337,21 @@ describe('KGMidiTrack', () => {
})
it('should handle volume boundaries', () => {
- track.setVolume(0.0)
- expect(track.getVolume()).toBe(0.0)
+ track.setVolume(-60)
+ expect(track.getVolume()).toBe(-60)
- track.setVolume(1.0)
- expect(track.getVolume()).toBe(1.0)
+ track.setVolume(0)
+ expect(track.getVolume()).toBe(0)
- // Volume outside normal range (should still work)
- track.setVolume(1.5)
- expect(track.getVolume()).toBe(1.5)
+ track.setVolume(6)
+ expect(track.getVolume()).toBe(6)
+
+ // Volume outside valid dB range is clamped
+ track.setVolume(10)
+ expect(track.getVolume()).toBe(6)
+
+ track.setVolume(-100)
+ expect(track.getVolume()).toBe(-60)
})
it('should handle large number of regions', () => {
diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts
index 11bbf3d..08c8495 100644
--- a/src/core/track/KGTrack.ts
+++ b/src/core/track/KGTrack.ts
@@ -104,7 +104,10 @@ export class KGTrack {
}
public setVolume(volume: number): void {
- this.volume = volume;
+ this.volume = Math.max(
+ AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
+ Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, volume)
+ );
}
public setRegions(regions: KGRegion[]): void {
From a474065450d7608f054576a6dd3502e163b197cc Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Fri, 1 May 2026 12:11:02 -0700
Subject: [PATCH 3/7] fix: pre-roll metronome ticks are missing
---
.../audio-interface/KGAudioInterface.test.ts | 99 +++++++++++++++++++
src/core/audio-interface/KGAudioInterface.ts | 55 ++++++++++-
src/core/audio-interface/KGMetronome.test.ts | 57 +++++++++++
src/core/audio-interface/KGMetronome.ts | 26 +++++
src/test/mocks/tone.ts | 21 +++-
5 files changed, 251 insertions(+), 7 deletions(-)
create mode 100644 src/core/audio-interface/KGAudioInterface.test.ts
create mode 100644 src/core/audio-interface/KGMetronome.test.ts
diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts
new file mode 100644
index 0000000..64b15af
--- /dev/null
+++ b/src/core/audio-interface/KGAudioInterface.test.ts
@@ -0,0 +1,99 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createMockProject } from '../../test/utils/mock-data'
+import { MockTransport } from '../../test/mocks/tone'
+
+vi.mock('tone', async () => {
+ const { ToneMock } = await import('../../test/mocks/tone')
+ return ToneMock
+})
+
+vi.mock('../KGCore', () => ({
+ KGCore: {
+ instance: vi.fn()
+ }
+}))
+
+vi.mock('../config/ConfigManager', () => ({
+ ConfigManager: {
+ instance: vi.fn()
+ }
+}))
+
+import { KGCore } from '../KGCore'
+import { ConfigManager } from '../config/ConfigManager'
+import { KGAudioInterface } from './KGAudioInterface'
+
+describe('KGAudioInterface preroll playback', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.clearAllMocks()
+
+ MockTransport.position = 0
+ MockTransport.start.mockClear()
+ MockTransport.stop.mockClear()
+ MockTransport.clear.mockClear()
+ MockTransport.schedule.mockClear()
+ MockTransport.bpm.value = 120
+
+ const project = createMockProject({
+ bpm: 120,
+ timeSignature: { numerator: 4, denominator: 4 },
+ tracks: [],
+ })
+
+ vi.mocked(KGCore.instance).mockReturnValue({
+ getCurrentProject: () => project,
+ } as unknown as KGCore)
+
+ vi.mocked(ConfigManager.instance).mockReturnValue({
+ get: (key: string) => {
+ if (key === 'audio.playback_delay') return 0.2
+ if (key === 'audio.lookahead_time') return 0.05
+ return null
+ },
+ } as unknown as ConfigManager)
+
+ ;(KGAudioInterface as unknown as { _instance: KGAudioInterface | null })._instance = null
+ })
+
+ it('uses virtual negative beats until the delayed transport start reaches beat 0', () => {
+ const project = KGCore.instance().getCurrentProject()
+ const audio = KGAudioInterface.instance()
+ ;(audio as unknown as { isInitialized: boolean }).isInitialized = true
+ ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true
+
+ const metronomeStart = vi.spyOn((audio as unknown as { metronome: { start: (...args: unknown[]) => void } }).metronome, 'start')
+ audio.setMetronomeEnabled(true)
+
+ audio.preparePlayback(project, -2)
+ audio.startPlayback()
+
+ expect(MockTransport.position).toBe(0)
+ expect(MockTransport.start).not.toHaveBeenCalled()
+ expect(metronomeStart).toHaveBeenCalledWith(-2, 4, 0.2)
+ expect(audio.getTransportPosition()).toBeCloseTo(-2, 2)
+
+ vi.advanceTimersByTime(500)
+ expect(audio.getTransportPosition()).toBeCloseTo(-1, 1)
+
+ vi.advanceTimersByTime(500)
+ expect(MockTransport.start).toHaveBeenCalledTimes(1)
+ expect(audio.getTransportPosition()).toBe(0)
+ })
+
+ it('cancels the delayed transport start when playback stops during preroll', () => {
+ const project = KGCore.instance().getCurrentProject()
+ const audio = KGAudioInterface.instance()
+ ;(audio as unknown as { isInitialized: boolean }).isInitialized = true
+ ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true
+
+ audio.preparePlayback(project, -2)
+ audio.startPlayback()
+ audio.stopPlayback()
+ vi.runAllTimers()
+
+ expect(MockTransport.start).not.toHaveBeenCalled()
+ expect(MockTransport.stop).toHaveBeenCalledTimes(1)
+ expect(audio.getTransportPosition()).toBe(0)
+ })
+})
diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts
index 1c8f600..ae97c2d 100644
--- a/src/core/audio-interface/KGAudioInterface.ts
+++ b/src/core/audio-interface/KGAudioInterface.ts
@@ -42,6 +42,10 @@ export class KGAudioInterface {
private isPlaying: boolean = false;
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
private scheduledEvents: Set = new Set(); // Tone event IDs
+ private delayedTransportStartTimeoutId: number | null = null;
+ private delayedTransportStartMs: number = 0;
+ private virtualPrerollStartBeat: number | null = null;
+ private virtualPrerollStartTimeMs: number | null = null;
// Master volume control
private masterGain: Tone.Gain | null = null;
@@ -390,6 +394,7 @@ export class KGAudioInterface {
public preparePlayback(project: KGProject, startPosition: number): void {
// Clear any existing scheduled events
this.clearScheduledEvents();
+ this.clearDelayedTransportStart();
console.log("Preparing playback");
@@ -440,8 +445,18 @@ export class KGAudioInterface {
console.log("Loop mode disabled");
}
+ if (startPosition < 0) {
+ this.delayedTransportStartMs = Math.abs(startPosition) * secondsPerBeat * 1000;
+ this.virtualPrerollStartBeat = startPosition;
+ this.virtualPrerollStartTimeMs = null;
+ } else {
+ this.delayedTransportStartMs = 0;
+ this.virtualPrerollStartBeat = null;
+ this.virtualPrerollStartTimeMs = null;
+ }
+
// Set transport position (convert beats to Tone.js format)
- this.setTransportPosition(startPosition);
+ this.setTransportPosition(Math.max(0, startPosition));
// Start metronome if enabled
if (this.isMetronomeEnabled) {
@@ -646,8 +661,19 @@ export class KGAudioInterface {
if (!this.isAudioContextStarted) {
throw new Error('Audio context not started');
}
-
- Tone.Transport.start();
+
+ if (this.delayedTransportStartMs > 0 && this.virtualPrerollStartBeat !== null) {
+ this.virtualPrerollStartTimeMs = performance.now();
+ this.delayedTransportStartTimeoutId = window.setTimeout(() => {
+ this.delayedTransportStartTimeoutId = null;
+ this.virtualPrerollStartBeat = null;
+ this.virtualPrerollStartTimeMs = null;
+ Tone.Transport.start();
+ }, this.delayedTransportStartMs);
+ } else {
+ Tone.Transport.start();
+ }
+
this.isPlaying = true;
console.log('Audio playback started');
@@ -662,6 +688,7 @@ export class KGAudioInterface {
*/
public stopPlayback(): void {
try {
+ this.clearDelayedTransportStart();
Tone.Transport.stop();
this.metronome.stop();
@@ -789,7 +816,8 @@ export class KGAudioInterface {
public setTransportPosition(position: number): void {
try {
// Convert beats to Tone.js time format
- const toneTime = this.beatsToToneTime(position);
+ const safePosition = Math.max(0, position);
+ const toneTime = this.beatsToToneTime(safePosition);
Tone.Transport.position = toneTime;
console.log(`Set transport position to ${position} beats (${toneTime})`);
} catch (error) {
@@ -802,6 +830,14 @@ export class KGAudioInterface {
*/
public getTransportPosition(): number {
try {
+ if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartTimeMs !== null) {
+ const project = KGCore.instance().getCurrentProject();
+ const secondsPerBeat = 60 / project.getBpm();
+ const elapsedSeconds = (performance.now() - this.virtualPrerollStartTimeMs) / 1000;
+ const elapsedBeats = elapsedSeconds / secondsPerBeat;
+ return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
+ }
+
const position = Tone.Transport.position;
return this.toneTimeToBeats(position);
} catch (error) {
@@ -973,6 +1009,17 @@ export class KGAudioInterface {
return this.captureStream;
}
+ private clearDelayedTransportStart(): void {
+ if (this.delayedTransportStartTimeoutId !== null) {
+ window.clearTimeout(this.delayedTransportStartTimeoutId);
+ this.delayedTransportStartTimeoutId = null;
+ }
+
+ this.delayedTransportStartMs = 0;
+ this.virtualPrerollStartBeat = null;
+ this.virtualPrerollStartTimeMs = null;
+ }
+
// ===== PRIVATE UTILITY METHODS =====
/**
diff --git a/src/core/audio-interface/KGMetronome.test.ts b/src/core/audio-interface/KGMetronome.test.ts
new file mode 100644
index 0000000..4369c9e
--- /dev/null
+++ b/src/core/audio-interface/KGMetronome.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { MockLoop, MockTransport, ToneMock } from '../../test/mocks/tone'
+
+vi.mock('tone', async () => {
+ const { ToneMock: toneMock } = await import('../../test/mocks/tone')
+ return toneMock
+})
+
+import { KGMetronome } from './KGMetronome'
+
+describe('KGMetronome', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.clearAllMocks()
+ MockTransport.bpm.value = 120
+ MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ)
+ vi.mocked(ToneMock.now).mockReturnValue(42)
+ })
+
+ it('schedules audible preroll clicks before beat 0 and keeps the beat 0 accent', () => {
+ const metronome = new KGMetronome()
+ const triggerAttackRelease = vi.fn()
+ ;(metronome as unknown as { sampler: unknown }).sampler = {
+ loaded: true,
+ triggerAttackRelease,
+ }
+
+ metronome.start(-4, 4, 0.2)
+
+ vi.advanceTimersByTime(200)
+ expect(triggerAttackRelease).toHaveBeenNthCalledWith(1, 'C5', '16n', 42)
+
+ vi.advanceTimersByTime(1000)
+ expect(triggerAttackRelease).toHaveBeenNthCalledWith(2, 'C4', '16n', 42)
+
+ const transportLoop = MockLoop.mock.results[0]?.value
+ expect(transportLoop).toBeDefined()
+
+ transportLoop.callback(0)
+ expect(triggerAttackRelease).toHaveBeenLastCalledWith('C5', '16n', 0.2)
+ })
+
+ it('cancels pending preroll clicks when stopped', () => {
+ const metronome = new KGMetronome()
+ const triggerAttackRelease = vi.fn()
+ ;(metronome as unknown as { sampler: unknown }).sampler = {
+ loaded: true,
+ triggerAttackRelease,
+ }
+
+ metronome.start(-2, 4, 0.2)
+ metronome.stop()
+ vi.runAllTimers()
+
+ expect(triggerAttackRelease).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/core/audio-interface/KGMetronome.ts b/src/core/audio-interface/KGMetronome.ts
index f8025af..2bce458 100644
--- a/src/core/audio-interface/KGMetronome.ts
+++ b/src/core/audio-interface/KGMetronome.ts
@@ -8,6 +8,7 @@ import { KGToneSamplerFactory } from './KGToneSamplerFactory';
export class KGMetronome {
private loop: Tone.Loop | null = null;
private sampler: Tone.Sampler | null = null;
+ private prerollTimeoutIds: number[] = [];
/**
* Load the woodblock sampler. Called once from KGAudioInterface.initialize() —
@@ -34,6 +35,8 @@ export class KGMetronome {
const ppq = Tone.Transport.PPQ;
+ this.schedulePrerollClicks(startPositionBeats, beatsPerBar, playbackDelay);
+
this.loop = new Tone.Loop((time) => {
if (this.sampler?.loaded) {
// Derive bar position from the exact Transport tick count at the
@@ -52,6 +55,9 @@ export class KGMetronome {
/** Stop and dispose the loop only — sampler is kept alive for reuse. */
stop(): void {
+ this.prerollTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId));
+ this.prerollTimeoutIds = [];
+
if (this.loop) {
this.loop.dispose();
this.loop = null;
@@ -65,4 +71,24 @@ export class KGMetronome {
this.sampler = null;
}
}
+
+ private schedulePrerollClicks(startPositionBeats: number, beatsPerBar: number, playbackDelay: number): void {
+ if (startPositionBeats >= 0 || !this.sampler?.loaded) {
+ return;
+ }
+
+ const secondsPerBeat = 60 / Tone.Transport.bpm.value;
+ const firstBeat = Math.ceil(startPositionBeats);
+
+ for (let beat = firstBeat; beat < 0; beat += 1) {
+ const waitMs = Math.max(0, ((beat - startPositionBeats) * secondsPerBeat + playbackDelay) * 1000);
+ const note = beat % beatsPerBar === 0 ? 'C5' : 'C4';
+ const timeoutId = window.setTimeout(() => {
+ if (this.sampler?.loaded) {
+ this.sampler.triggerAttackRelease(note, '16n', Tone.now());
+ }
+ }, waitMs);
+ this.prerollTimeoutIds.push(timeoutId);
+ }
+ }
}
diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts
index b0f3fb4..38bca09 100644
--- a/src/test/mocks/tone.ts
+++ b/src/test/mocks/tone.ts
@@ -25,7 +25,7 @@ export const MockTransport = {
start: vi.fn(),
stop: vi.fn(),
pause: vi.fn(),
- position: '0:0:0',
+ position: 0,
bpm: {
value: 120,
rampTo: vi.fn()
@@ -34,10 +34,23 @@ export const MockTransport = {
state: 'stopped',
scheduleOnce: vi.fn(),
scheduleRepeat: vi.fn(),
+ schedule: vi.fn().mockReturnValue(1),
cancel: vi.fn(),
clear: vi.fn()
+ ,
+ setLoopPoints: vi.fn(),
+ loop: false,
+ PPQ: 192,
+ getTicksAtTime: vi.fn().mockImplementation((time: number) => time * 192)
}
+export const MockLoop = vi.fn().mockImplementation((callback: (time: number) => void, interval: string) => ({
+ callback,
+ interval,
+ start: vi.fn(),
+ dispose: vi.fn()
+}))
+
// Mock Destination
export const MockDestination = {
volume: {
@@ -81,6 +94,7 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
// Complete Tone.js mock
export const ToneMock = {
Sampler: MockSampler,
+ Loop: MockLoop,
Transport: MockTransport,
Destination: MockDestination,
ToneAudioBuffer: MockToneAudioBuffer,
@@ -106,10 +120,11 @@ export const ToneMock = {
Frequency: vi.fn().mockImplementation((freq) => ({
toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440),
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
- }))
+ })),
+ now: vi.fn().mockReturnValue(0)
}
// Setup the global mock
export const setupToneMocks = () => {
vi.doMock('tone', () => ToneMock)
-}
\ No newline at end of file
+}
From adb2345693bf9fe5022b1852f7774e53e9f6bff5 Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Fri, 1 May 2026 12:16:57 -0700
Subject: [PATCH 4/7] =?UTF-8?q?fix:=20wwitched=20the=20preroll=20metronome?=
=?UTF-8?q?=20scheduling=20off=20`window.setTimeout`=20and=20onto=20Tone?=
=?UTF-8?q?=E2=80=99s=20audio-context=20clock?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../audio-interface/KGAudioInterface.test.ts | 1 +
src/core/audio-interface/KGAudioInterface.ts | 32 +++++++++----------
src/core/audio-interface/KGMetronome.test.ts | 12 ++++---
src/core/audio-interface/KGMetronome.ts | 10 +++---
src/test/mocks/tone.ts | 11 +++++--
5 files changed, 40 insertions(+), 26 deletions(-)
diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts
index 64b15af..44b468e 100644
--- a/src/core/audio-interface/KGAudioInterface.test.ts
+++ b/src/core/audio-interface/KGAudioInterface.test.ts
@@ -26,6 +26,7 @@ import { KGAudioInterface } from './KGAudioInterface'
describe('KGAudioInterface preroll playback', () => {
beforeEach(() => {
vi.useFakeTimers()
+ vi.setSystemTime(0)
vi.clearAllMocks()
MockTransport.position = 0
diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts
index ae97c2d..845d0cc 100644
--- a/src/core/audio-interface/KGAudioInterface.ts
+++ b/src/core/audio-interface/KGAudioInterface.ts
@@ -43,9 +43,9 @@ export class KGAudioInterface {
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
private scheduledEvents: Set = new Set(); // Tone event IDs
private delayedTransportStartTimeoutId: number | null = null;
- private delayedTransportStartMs: number = 0;
+ private delayedTransportStartSeconds: number = 0;
private virtualPrerollStartBeat: number | null = null;
- private virtualPrerollStartTimeMs: number | null = null;
+ private virtualPrerollStartAudioTime: number | null = null;
// Master volume control
private masterGain: Tone.Gain | null = null;
@@ -446,13 +446,13 @@ export class KGAudioInterface {
}
if (startPosition < 0) {
- this.delayedTransportStartMs = Math.abs(startPosition) * secondsPerBeat * 1000;
+ this.delayedTransportStartSeconds = Math.abs(startPosition) * secondsPerBeat;
this.virtualPrerollStartBeat = startPosition;
- this.virtualPrerollStartTimeMs = null;
+ this.virtualPrerollStartAudioTime = null;
} else {
- this.delayedTransportStartMs = 0;
+ this.delayedTransportStartSeconds = 0;
this.virtualPrerollStartBeat = null;
- this.virtualPrerollStartTimeMs = null;
+ this.virtualPrerollStartAudioTime = null;
}
// Set transport position (convert beats to Tone.js format)
@@ -662,14 +662,14 @@ export class KGAudioInterface {
throw new Error('Audio context not started');
}
- if (this.delayedTransportStartMs > 0 && this.virtualPrerollStartBeat !== null) {
- this.virtualPrerollStartTimeMs = performance.now();
- this.delayedTransportStartTimeoutId = window.setTimeout(() => {
+ if (this.delayedTransportStartSeconds > 0 && this.virtualPrerollStartBeat !== null) {
+ this.virtualPrerollStartAudioTime = Tone.now();
+ this.delayedTransportStartTimeoutId = Tone.getContext().setTimeout(() => {
this.delayedTransportStartTimeoutId = null;
this.virtualPrerollStartBeat = null;
- this.virtualPrerollStartTimeMs = null;
+ this.virtualPrerollStartAudioTime = null;
Tone.Transport.start();
- }, this.delayedTransportStartMs);
+ }, this.delayedTransportStartSeconds);
} else {
Tone.Transport.start();
}
@@ -830,10 +830,10 @@ export class KGAudioInterface {
*/
public getTransportPosition(): number {
try {
- if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartTimeMs !== null) {
+ if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartAudioTime !== null) {
const project = KGCore.instance().getCurrentProject();
const secondsPerBeat = 60 / project.getBpm();
- const elapsedSeconds = (performance.now() - this.virtualPrerollStartTimeMs) / 1000;
+ const elapsedSeconds = Math.max(0, Tone.now() - this.virtualPrerollStartAudioTime);
const elapsedBeats = elapsedSeconds / secondsPerBeat;
return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
}
@@ -1011,13 +1011,13 @@ export class KGAudioInterface {
private clearDelayedTransportStart(): void {
if (this.delayedTransportStartTimeoutId !== null) {
- window.clearTimeout(this.delayedTransportStartTimeoutId);
+ Tone.getContext().clearTimeout(this.delayedTransportStartTimeoutId);
this.delayedTransportStartTimeoutId = null;
}
- this.delayedTransportStartMs = 0;
+ this.delayedTransportStartSeconds = 0;
this.virtualPrerollStartBeat = null;
- this.virtualPrerollStartTimeMs = null;
+ this.virtualPrerollStartAudioTime = null;
}
// ===== PRIVATE UTILITY METHODS =====
diff --git a/src/core/audio-interface/KGMetronome.test.ts b/src/core/audio-interface/KGMetronome.test.ts
index 4369c9e..ebf1f43 100644
--- a/src/core/audio-interface/KGMetronome.test.ts
+++ b/src/core/audio-interface/KGMetronome.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { MockLoop, MockTransport, ToneMock } from '../../test/mocks/tone'
+import { MockLoop, MockTransport } from '../../test/mocks/tone'
vi.mock('tone', async () => {
const { ToneMock: toneMock } = await import('../../test/mocks/tone')
@@ -11,10 +11,10 @@ import { KGMetronome } from './KGMetronome'
describe('KGMetronome', () => {
beforeEach(() => {
vi.useFakeTimers()
+ vi.setSystemTime(0)
vi.clearAllMocks()
MockTransport.bpm.value = 120
MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ)
- vi.mocked(ToneMock.now).mockReturnValue(42)
})
it('schedules audible preroll clicks before beat 0 and keeps the beat 0 accent', () => {
@@ -28,10 +28,14 @@ describe('KGMetronome', () => {
metronome.start(-4, 4, 0.2)
vi.advanceTimersByTime(200)
- expect(triggerAttackRelease).toHaveBeenNthCalledWith(1, 'C5', '16n', 42)
+ expect(triggerAttackRelease.mock.calls[0]?.[0]).toBe('C5')
+ expect(triggerAttackRelease.mock.calls[0]?.[1]).toBe('16n')
+ expect(triggerAttackRelease.mock.calls[0]?.[2]).toBeCloseTo(0.2, 5)
vi.advanceTimersByTime(1000)
- expect(triggerAttackRelease).toHaveBeenNthCalledWith(2, 'C4', '16n', 42)
+ expect(triggerAttackRelease.mock.calls[1]?.[0]).toBe('C4')
+ expect(triggerAttackRelease.mock.calls[1]?.[1]).toBe('16n')
+ expect(triggerAttackRelease.mock.calls[1]?.[2]).toBeCloseTo(0.7, 5)
const transportLoop = MockLoop.mock.results[0]?.value
expect(transportLoop).toBeDefined()
diff --git a/src/core/audio-interface/KGMetronome.ts b/src/core/audio-interface/KGMetronome.ts
index 2bce458..eac151d 100644
--- a/src/core/audio-interface/KGMetronome.ts
+++ b/src/core/audio-interface/KGMetronome.ts
@@ -55,7 +55,8 @@ export class KGMetronome {
/** Stop and dispose the loop only — sampler is kept alive for reuse. */
stop(): void {
- this.prerollTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId));
+ const context = Tone.getContext();
+ this.prerollTimeoutIds.forEach(timeoutId => context.clearTimeout(timeoutId));
this.prerollTimeoutIds = [];
if (this.loop) {
@@ -78,16 +79,17 @@ export class KGMetronome {
}
const secondsPerBeat = 60 / Tone.Transport.bpm.value;
+ const context = Tone.getContext();
const firstBeat = Math.ceil(startPositionBeats);
for (let beat = firstBeat; beat < 0; beat += 1) {
- const waitMs = Math.max(0, ((beat - startPositionBeats) * secondsPerBeat + playbackDelay) * 1000);
+ const waitSeconds = Math.max(0, (beat - startPositionBeats) * secondsPerBeat + playbackDelay);
const note = beat % beatsPerBar === 0 ? 'C5' : 'C4';
- const timeoutId = window.setTimeout(() => {
+ const timeoutId = context.setTimeout(() => {
if (this.sampler?.loaded) {
this.sampler.triggerAttackRelease(note, '16n', Tone.now());
}
- }, waitMs);
+ }, waitSeconds);
this.prerollTimeoutIds.push(timeoutId);
}
}
diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts
index 38bca09..53a7e27 100644
--- a/src/test/mocks/tone.ts
+++ b/src/test/mocks/tone.ts
@@ -107,7 +107,14 @@ export const ToneMock = {
state: 'running',
resume: vi.fn().mockResolvedValue(undefined),
suspend: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined)
+ close: vi.fn().mockResolvedValue(undefined),
+ lookAhead: 0.05,
+ setTimeout: vi.fn().mockImplementation((fn: () => void, timeoutSeconds: number) => {
+ return window.setTimeout(fn, timeoutSeconds * 1000)
+ }),
+ clearTimeout: vi.fn().mockImplementation((id: number) => {
+ window.clearTimeout(id)
+ })
}),
// Time utilities
@@ -121,7 +128,7 @@ export const ToneMock = {
toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440),
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
})),
- now: vi.fn().mockReturnValue(0)
+ now: vi.fn().mockImplementation(() => Date.now() / 1000)
}
// Setup the global mock
From 043e90b1d7a63df431c0cc395ad8abf99de34642 Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Fri, 1 May 2026 14:17:29 -0700
Subject: [PATCH 5/7] fix: missing count-in when recording with loop mode on
---
src/core/KGCore.ts | 20 +++++++---
.../audio-interface/KGAudioInterface.test.ts | 15 ++++++++
src/core/audio-interface/KGAudioInterface.ts | 8 +++-
src/stores/projectStore.ts | 17 +++++++--
.../project-store-sync.integration.test.ts | 38 ++++++++++++++++++-
5 files changed, 85 insertions(+), 13 deletions(-)
diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts
index 6ebdffd..6864a7b 100644
--- a/src/core/KGCore.ts
+++ b/src/core/KGCore.ts
@@ -10,6 +10,10 @@ import { KGRegion } from './region/KGRegion';
import { generateUniqueId } from '../util/miscUtil';
import { KGCommand, KGCommandHistory } from './commands';
+interface PlaybackStartOptions {
+ preserveLoopPreroll?: boolean;
+}
+
/**
* KGCore - Main application class for the DAW
* Implements the singleton pattern for global access
@@ -231,7 +235,7 @@ export class KGCore {
return this.isPlaying;
}
- public async preparePlay(): Promise {
+ public async preparePlay(options?: PlaybackStartOptions): Promise {
try {
const audioInterface = KGAudioInterface.instance();
@@ -239,7 +243,9 @@ export class KGCore {
await audioInterface.startAudioContext();
// Prepare playback with current project and playhead position
- audioInterface.preparePlayback(this.currentProject, this.playheadPosition);
+ audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
+ allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
+ });
// Sync BPM and transport settings
audioInterface.setBpm(this.currentProject.getBpm());
@@ -301,7 +307,7 @@ export class KGCore {
}
// High-level playback control methods
- public async startPlaying(): Promise {
+ public async startPlaying(options?: PlaybackStartOptions): Promise {
// Handle loop mode initialization
if (this.currentProject.getIsLooping()) {
const [startBar, endBar] = this.currentProject.getLoopingRange();
@@ -319,11 +325,13 @@ export class KGCore {
const updatedRange = this.currentProject.getLoopingRange();
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
const loopStartBeats = updatedRange[0] * beatsPerBar;
- this.setPlayheadPosition(loopStartBeats);
+ if (!options?.preserveLoopPreroll) {
+ this.setPlayheadPosition(loopStartBeats);
+ }
}
// Prepare playback first
- await this.preparePlay();
+ await this.preparePlay(options);
// Start playing (non-blocking)
this.play(); // Don't await this
@@ -645,4 +653,4 @@ export class KGCore {
public getCommandHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } {
return this.commandHistory.getHistoryStats();
}
-}
\ No newline at end of file
+}
diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts
index 44b468e..068f923 100644
--- a/src/core/audio-interface/KGAudioInterface.test.ts
+++ b/src/core/audio-interface/KGAudioInterface.test.ts
@@ -97,4 +97,19 @@ describe('KGAudioInterface preroll playback', () => {
expect(MockTransport.stop).toHaveBeenCalledTimes(1)
expect(audio.getTransportPosition()).toBe(0)
})
+
+ it('allows a first-pass start before the loop start when explicitly requested', () => {
+ const project = createMockProject({
+ bpm: 120,
+ timeSignature: { numerator: 4, denominator: 4 },
+ tracks: [],
+ })
+ project.setIsLooping(true)
+ project.setLoopingRange([4, 7])
+
+ const audio = KGAudioInterface.instance()
+ audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true })
+
+ expect(MockTransport.position).toBe(6)
+ })
})
diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts
index 845d0cc..f556cba 100644
--- a/src/core/audio-interface/KGAudioInterface.ts
+++ b/src/core/audio-interface/KGAudioInterface.ts
@@ -12,6 +12,10 @@ import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager';
import { KGMetronome } from './KGMetronome';
+interface PreparePlaybackOptions {
+ allowStartBeforeLoopStart?: boolean;
+}
+
/**
* KGAudioInterface - Audio engine interface for the DAW
* Implements the singleton pattern for global audio management
@@ -391,7 +395,7 @@ export class KGAudioInterface {
/**
* Prepare playback by scheduling all MIDI events
*/
- public preparePlayback(project: KGProject, startPosition: number): void {
+ public preparePlayback(project: KGProject, startPosition: number, options?: PreparePlaybackOptions): void {
// Clear any existing scheduled events
this.clearScheduledEvents();
this.clearDelayedTransportStart();
@@ -437,7 +441,7 @@ export class KGAudioInterface {
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
// Adjust start position to loop start if before loop range
- if (startPosition < scheduleStartBeat) {
+ if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) {
startPosition = scheduleStartBeat;
}
} else {
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index 65ba16d..b97461d 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -797,7 +797,7 @@ export const useProjectStore = create((set, get) => {
},
startRecording: async () => {
- const { activeRegionId, timeSignature, playheadPosition, startPlaying, setPlayheadPosition } = get();
+ const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get();
const project = KGCore.instance().getCurrentProject();
let targetRegion: KGMidiRegion | null = null;
@@ -842,8 +842,18 @@ export const useProjectStore = create((set, get) => {
}
);
- setPlayheadPosition(playheadPosition - timeSignature.numerator);
- await startPlaying();
+ const projectLooping = project.getIsLooping();
+ const [loopStartBar] = project.getLoopingRange();
+ const loopStartBeat = loopStartBar * timeSignature.numerator;
+ const recordingStartBeat = projectLooping
+ ? loopStartBeat - timeSignature.numerator
+ : playheadPosition - timeSignature.numerator;
+
+ setPlayheadPosition(recordingStartBeat);
+ await KGCore.instance().startPlaying({
+ preserveLoopPreroll: projectLooping,
+ });
+ set({ isPlaying: true, autoScrollEnabled: true });
},
stopRecording: async () => {
@@ -1196,4 +1206,3 @@ export const useProjectStore = create((set, get) => {
-
diff --git a/src/test/integration/store/project-store-sync.integration.test.ts b/src/test/integration/store/project-store-sync.integration.test.ts
index 425e836..ac3113c 100644
--- a/src/test/integration/store/project-store-sync.integration.test.ts
+++ b/src/test/integration/store/project-store-sync.integration.test.ts
@@ -11,6 +11,7 @@ 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 { KGMidiInput } from '../../../core/midi-input/KGMidiInput'
// Import store
import { useProjectStore } from '../../../stores/projectStore'
@@ -279,6 +280,41 @@ describe('Project Store Synchronization Integration Tests', () => {
// Verify audio interface was called
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled()
})
+
+ it('should start looped recording one bar before the loop start on the first pass', async () => {
+ const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano')
+ const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16)
+ testTrack.addRegion(testRegion)
+ testProject.setTracks([testTrack])
+ testProject.setIsLooping(true)
+ testProject.setLoopingRange([4, 7])
+
+ await act(async () => {
+ await useProjectStore.getState().loadProject(testProject)
+ })
+
+ const core = KGCore.instance()
+ const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined)
+ const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks')
+ const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState()
+
+ act(() => {
+ setActiveRegionId(testRegion.getId())
+ setPlayheadPosition(18)
+ })
+
+ await act(async () => {
+ await startRecording()
+ })
+
+ const storeState = useProjectStore.getState()
+ expect(storeState.playheadPosition).toBe(12)
+ expect(storeState.recordingOriginalPlayhead).toBe(18)
+ expect(storeState.isRecording).toBe(true)
+ expect(storeState.isPlaying).toBe(true)
+ expect(recordingCallbacksSpy).toHaveBeenCalled()
+ expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true })
+ })
})
describe('Selection State Synchronization', () => {
@@ -443,4 +479,4 @@ describe('Project Store Synchronization Integration Tests', () => {
expect(maxBarsCSS.trim()).toBe('48')
})
})
-})
\ No newline at end of file
+})
From 9be96cd9570e424c76f363075edb61dd3efa4ad6 Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Fri, 1 May 2026 14:29:27 -0700
Subject: [PATCH 6/7] feat: add `R` as shortcut of recording button; fixed
linter errors.
---
public/config.json | 1 +
src/components/KGOnePanel.tsx | 10 +-
src/core/KGDebugger.ts | 174 +++---
.../audio-interface/KGAudioInterface.test.ts | 122 ++--
src/core/audio-interface/KGMetronome.test.ts | 72 +--
.../commands/note/CreateNoteCommand.test.ts | 166 ++---
src/core/midi/KGMidiNote.test.ts | 178 +++---
src/core/region/KGMidiRegion.test.ts | 420 ++++++-------
src/core/track/KGMidiTrack.test.ts | 400 ++++++------
src/hooks/useGlobalKeyboardHandler.ts | 44 +-
src/stores/projectStore.ts | 2 +-
.../command-execution.integration.test.ts | 362 +++++------
.../project-store-sync.integration.test.ts | 512 +++++++--------
.../utils/abc-notation.integration.test.ts | 246 ++++----
src/test/mocks/audio-interface.ts | 6 +-
src/test/mocks/indexed-db.ts | 34 +-
src/test/mocks/tone-js.ts | 8 +-
src/test/mocks/tone.ts | 26 +-
src/test/setup.ts | 36 +-
src/test/utils/mock-data.ts | 54 +-
src/test/utils/setup-integration-tests.ts | 62 +-
src/test/utils/test-utils.tsx | 12 +-
src/util/midiUtil.test.ts | 226 +++----
src/util/scaleUtil.test.ts | 584 +++++++++---------
src/util/timeUtil.test.ts | 324 +++++-----
src/util/xmlUtil.test.ts | 408 ++++++------
src/vite-env.d.ts | 2 +-
27 files changed, 2266 insertions(+), 2225 deletions(-)
diff --git a/public/config.json b/public/config.json
index 9b0619f..71c018f 100644
--- a/public/config.json
+++ b/public/config.json
@@ -39,6 +39,7 @@
"hold_to_create_region": "ctrl",
"play": "space",
"loop": "c",
+ "record": "r",
"undo": "ctrl+z",
"redo": "ctrl+shift+z",
"select_all": "ctrl+a",
diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx
index 78b6102..ffecc44 100644
--- a/src/components/KGOnePanel.tsx
+++ b/src/components/KGOnePanel.tsx
@@ -300,7 +300,7 @@ const ClipTab: React.FC = ({ bpm, keySignature }) => {
setGenStatus('polling');
setGenHint('Generating clip...');
- // eslint-disable-next-line no-constant-condition
+
while (true) {
if (signal.aborted) return;
@@ -626,7 +626,7 @@ const FullSongTab: React.FC = () => {
type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
- // eslint-disable-next-line no-constant-condition
+
while (true) {
if (signal.aborted) return;
@@ -997,7 +997,7 @@ const SeparatorTab: React.FC = () => {
let files: string[] = [];
- // eslint-disable-next-line no-constant-condition
+
while (true) {
if (signal.aborted) return;
@@ -1414,7 +1414,7 @@ const RemixTab: React.FC = () => {
type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
- // eslint-disable-next-line no-constant-condition
+
while (true) {
if (signal.aborted) return;
@@ -1932,7 +1932,7 @@ const RepaintTab: React.FC = () => {
type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
- // eslint-disable-next-line no-constant-condition
+
while (true) {
if (signal.aborted) return;
diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts
index 55cf488..a80ee13 100644
--- a/src/core/KGDebugger.ts
+++ b/src/core/KGDebugger.ts
@@ -673,212 +673,212 @@ export class KGDebugger {
* await KGDebugger.opfs('cat project.json')
*/
public async opfs(command: string): Promise {
- const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
- const cmd = parts[0]
+ const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
+ const cmd = parts[0];
// Strip surrounding quotes from arguments
- const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ')
+ const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ');
try {
switch (cmd) {
case 'pwd':
- console.log('/' + this.opfsCwd.join('/'))
- break
+ console.log('/' + this.opfsCwd.join('/'));
+ break;
case 'ls':
- await this.opfsLs()
- break
+ await this.opfsLs();
+ break;
case 'cd':
- await this.opfsCd(arg)
- break
+ await this.opfsCd(arg);
+ break;
case 'cat':
- await this.opfsCat(arg)
- break
+ await this.opfsCat(arg);
+ break;
case 'dl':
- await this.opfsDl(arg)
- break
+ await this.opfsDl(arg);
+ break;
case 'rm':
- await this.opfsRm(arg)
- break
+ await this.opfsRm(arg);
+ break;
default:
- console.log(`opfs: command not found: ${cmd}`)
- console.log('Available commands: pwd, ls, cd , cat , dl , rm ')
+ console.log(`opfs: command not found: ${cmd}`);
+ console.log('Available commands: pwd, ls, cd , cat , dl , rm ');
}
} catch (error) {
- console.error(`opfs: ${error}`)
+ console.error(`opfs: ${error}`);
}
}
private async opfsResolveCwd(): Promise {
- let dir = await navigator.storage.getDirectory()
+ let dir = await navigator.storage.getDirectory();
for (const segment of this.opfsCwd) {
- dir = await dir.getDirectoryHandle(segment)
+ dir = await dir.getDirectoryHandle(segment);
}
- return dir
+ return dir;
}
private async opfsLs(): Promise {
- const dir = await this.opfsResolveCwd()
- const entries: Array<{ kind: string; name: string; size: number; modified: string }> = []
+ const dir = await this.opfsResolveCwd();
+ const entries: Array<{ kind: string; name: string; size: number; modified: string }> = [];
for await (const entry of dir.values()) {
if (entry.kind === 'file') {
- const fileHandle = entry as FileSystemFileHandle
- const file = await fileHandle.getFile()
+ const fileHandle = entry as FileSystemFileHandle;
+ const file = await fileHandle.getFile();
entries.push({
kind: 'file',
name: entry.name,
size: file.size,
modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19),
- })
+ });
} else {
entries.push({
kind: 'dir',
name: entry.name + '/',
size: 0,
modified: '-',
- })
+ });
}
}
// Sort: directories first, then files, alphabetically within each group
entries.sort((a, b) => {
- if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
- return a.name.localeCompare(b.name)
- })
+ if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
if (entries.length === 0) {
- console.log('(empty directory)')
- return
+ console.log('(empty directory)');
+ return;
}
// Print header
- const cwdPath = '/' + this.opfsCwd.join('/')
- console.log(`total ${entries.length} (${cwdPath})`)
+ const cwdPath = '/' + this.opfsCwd.join('/');
+ console.log(`total ${entries.length} (${cwdPath})`);
// Format like ls -lla
- const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4)
+ const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4);
for (const e of entries) {
- const typeChar = e.kind === 'dir' ? 'd' : '-'
- const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'
- const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen)
- console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`)
+ const typeChar = e.kind === 'dir' ? 'd' : '-';
+ const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--';
+ const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen);
+ console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`);
}
}
private async opfsCd(path: string): Promise {
if (!path || path === '') {
// cd with no args goes to root
- this.opfsCwd = []
- return
+ this.opfsCwd = [];
+ return;
}
- let segments: string[]
+ let segments: string[];
if (path === '/') {
- this.opfsCwd = []
- return
+ this.opfsCwd = [];
+ return;
} else if (path.startsWith('/')) {
// Absolute path
- segments = path.split('/').filter(Boolean)
+ segments = path.split('/').filter(Boolean);
} else {
// Relative path
- segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]
+ segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)];
}
// Resolve . and ..
- const resolved: string[] = []
+ const resolved: string[] = [];
for (const seg of segments) {
- if (seg === '.') continue
+ if (seg === '.') continue;
if (seg === '..') {
- resolved.pop()
+ resolved.pop();
} else {
- resolved.push(seg)
+ resolved.push(seg);
}
}
// Verify the path exists
- let dir = await navigator.storage.getDirectory()
+ let dir = await navigator.storage.getDirectory();
for (const seg of resolved) {
try {
- dir = await dir.getDirectoryHandle(seg)
+ dir = await dir.getDirectoryHandle(seg);
} catch {
- console.error(`opfs: cd: no such directory: ${path}`)
- return
+ console.error(`opfs: cd: no such directory: ${path}`);
+ return;
}
}
- this.opfsCwd = resolved
+ this.opfsCwd = resolved;
}
private async opfsCat(fileName: string): Promise {
if (!fileName) {
- console.error('opfs: cat: missing file name')
- return
+ console.error('opfs: cat: missing file name');
+ return;
}
- const dir = await this.opfsResolveCwd()
+ const dir = await this.opfsResolveCwd();
try {
- const fileHandle = await dir.getFileHandle(fileName)
- const file = await fileHandle.getFile()
- const text = await file.text()
+ const fileHandle = await dir.getFileHandle(fileName);
+ const file = await fileHandle.getFile();
+ const text = await file.text();
// Pretty-print JSON files
if (fileName.endsWith('.json')) {
try {
- const parsed = JSON.parse(text)
- console.log(JSON.stringify(parsed, null, 2))
+ const parsed = JSON.parse(text);
+ console.log(JSON.stringify(parsed, null, 2));
} catch {
- console.log(text)
+ console.log(text);
}
} else {
- console.log(text)
+ console.log(text);
}
} catch {
- console.error(`opfs: cat: ${fileName}: No such file`)
+ console.error(`opfs: cat: ${fileName}: No such file`);
}
}
private async opfsDl(fileName: string): Promise {
if (!fileName) {
- console.error('opfs: dl: missing file name')
- return
+ console.error('opfs: dl: missing file name');
+ return;
}
- const dir = await this.opfsResolveCwd()
+ const dir = await this.opfsResolveCwd();
try {
- const fileHandle = await dir.getFileHandle(fileName)
- const file = await fileHandle.getFile()
- const url = URL.createObjectURL(file)
- const a = document.createElement('a')
- a.href = url
- a.download = fileName
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- console.log(`downloaded: ${fileName} (${file.size} bytes)`)
+ const fileHandle = await dir.getFileHandle(fileName);
+ const file = await fileHandle.getFile();
+ const url = URL.createObjectURL(file);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ console.log(`downloaded: ${fileName} (${file.size} bytes)`);
} catch {
- console.error(`opfs: dl: ${fileName}: No such file`)
+ console.error(`opfs: dl: ${fileName}: No such file`);
}
}
private async opfsRm(name: string): Promise {
if (!name) {
- console.error('opfs: rm: missing file or directory name')
- return
+ console.error('opfs: rm: missing file or directory name');
+ return;
}
- const dir = await this.opfsResolveCwd()
+ const dir = await this.opfsResolveCwd();
try {
- await dir.removeEntry(name, { recursive: true })
- console.log(`removed: ${name}`)
+ await dir.removeEntry(name, { recursive: true });
+ console.log(`removed: ${name}`);
} catch {
- console.error(`opfs: rm: ${name}: No such file or directory`)
+ console.error(`opfs: rm: ${name}: No such file or directory`);
}
}
}
\ No newline at end of file
diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts
index 068f923..d087fcb 100644
--- a/src/core/audio-interface/KGAudioInterface.test.ts
+++ b/src/core/audio-interface/KGAudioInterface.test.ts
@@ -1,115 +1,115 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { createMockProject } from '../../test/utils/mock-data'
-import { MockTransport } from '../../test/mocks/tone'
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createMockProject } from '../../test/utils/mock-data';
+import { MockTransport } from '../../test/mocks/tone';
vi.mock('tone', async () => {
- const { ToneMock } = await import('../../test/mocks/tone')
- return ToneMock
-})
+ const { ToneMock } = await import('../../test/mocks/tone');
+ return ToneMock;
+});
vi.mock('../KGCore', () => ({
KGCore: {
instance: vi.fn()
}
-}))
+}));
vi.mock('../config/ConfigManager', () => ({
ConfigManager: {
instance: vi.fn()
}
-}))
+}));
-import { KGCore } from '../KGCore'
-import { ConfigManager } from '../config/ConfigManager'
-import { KGAudioInterface } from './KGAudioInterface'
+import { KGCore } from '../KGCore';
+import { ConfigManager } from '../config/ConfigManager';
+import { KGAudioInterface } from './KGAudioInterface';
describe('KGAudioInterface preroll playback', () => {
beforeEach(() => {
- vi.useFakeTimers()
- vi.setSystemTime(0)
- vi.clearAllMocks()
+ vi.useFakeTimers();
+ vi.setSystemTime(0);
+ vi.clearAllMocks();
- MockTransport.position = 0
- MockTransport.start.mockClear()
- MockTransport.stop.mockClear()
- MockTransport.clear.mockClear()
- MockTransport.schedule.mockClear()
- MockTransport.bpm.value = 120
+ MockTransport.position = 0;
+ MockTransport.start.mockClear();
+ MockTransport.stop.mockClear();
+ MockTransport.clear.mockClear();
+ MockTransport.schedule.mockClear();
+ MockTransport.bpm.value = 120;
const project = createMockProject({
bpm: 120,
timeSignature: { numerator: 4, denominator: 4 },
tracks: [],
- })
+ });
vi.mocked(KGCore.instance).mockReturnValue({
getCurrentProject: () => project,
- } as unknown as KGCore)
+ } as unknown as KGCore);
vi.mocked(ConfigManager.instance).mockReturnValue({
get: (key: string) => {
- if (key === 'audio.playback_delay') return 0.2
- if (key === 'audio.lookahead_time') return 0.05
- return null
+ if (key === 'audio.playback_delay') return 0.2;
+ if (key === 'audio.lookahead_time') return 0.05;
+ return null;
},
} as unknown as ConfigManager)
- ;(KGAudioInterface as unknown as { _instance: KGAudioInterface | null })._instance = null
- })
+ ;(KGAudioInterface as unknown as { _instance: KGAudioInterface | null })._instance = null;
+ });
it('uses virtual negative beats until the delayed transport start reaches beat 0', () => {
- const project = KGCore.instance().getCurrentProject()
+ const project = KGCore.instance().getCurrentProject();
const audio = KGAudioInterface.instance()
;(audio as unknown as { isInitialized: boolean }).isInitialized = true
- ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true
+ ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true;
- const metronomeStart = vi.spyOn((audio as unknown as { metronome: { start: (...args: unknown[]) => void } }).metronome, 'start')
- audio.setMetronomeEnabled(true)
+ const metronomeStart = vi.spyOn((audio as unknown as { metronome: { start: (...args: unknown[]) => void } }).metronome, 'start');
+ audio.setMetronomeEnabled(true);
- audio.preparePlayback(project, -2)
- audio.startPlayback()
+ audio.preparePlayback(project, -2);
+ audio.startPlayback();
- expect(MockTransport.position).toBe(0)
- expect(MockTransport.start).not.toHaveBeenCalled()
- expect(metronomeStart).toHaveBeenCalledWith(-2, 4, 0.2)
- expect(audio.getTransportPosition()).toBeCloseTo(-2, 2)
+ expect(MockTransport.position).toBe(0);
+ expect(MockTransport.start).not.toHaveBeenCalled();
+ expect(metronomeStart).toHaveBeenCalledWith(-2, 4, 0.2);
+ expect(audio.getTransportPosition()).toBeCloseTo(-2, 2);
- vi.advanceTimersByTime(500)
- expect(audio.getTransportPosition()).toBeCloseTo(-1, 1)
+ vi.advanceTimersByTime(500);
+ expect(audio.getTransportPosition()).toBeCloseTo(-1, 1);
- vi.advanceTimersByTime(500)
- expect(MockTransport.start).toHaveBeenCalledTimes(1)
- expect(audio.getTransportPosition()).toBe(0)
- })
+ vi.advanceTimersByTime(500);
+ expect(MockTransport.start).toHaveBeenCalledTimes(1);
+ expect(audio.getTransportPosition()).toBe(0);
+ });
it('cancels the delayed transport start when playback stops during preroll', () => {
- const project = KGCore.instance().getCurrentProject()
+ const project = KGCore.instance().getCurrentProject();
const audio = KGAudioInterface.instance()
;(audio as unknown as { isInitialized: boolean }).isInitialized = true
- ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true
+ ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true;
- audio.preparePlayback(project, -2)
- audio.startPlayback()
- audio.stopPlayback()
- vi.runAllTimers()
+ audio.preparePlayback(project, -2);
+ audio.startPlayback();
+ audio.stopPlayback();
+ vi.runAllTimers();
- expect(MockTransport.start).not.toHaveBeenCalled()
- expect(MockTransport.stop).toHaveBeenCalledTimes(1)
- expect(audio.getTransportPosition()).toBe(0)
- })
+ expect(MockTransport.start).not.toHaveBeenCalled();
+ expect(MockTransport.stop).toHaveBeenCalledTimes(1);
+ expect(audio.getTransportPosition()).toBe(0);
+ });
it('allows a first-pass start before the loop start when explicitly requested', () => {
const project = createMockProject({
bpm: 120,
timeSignature: { numerator: 4, denominator: 4 },
tracks: [],
- })
- project.setIsLooping(true)
- project.setLoopingRange([4, 7])
+ });
+ project.setIsLooping(true);
+ project.setLoopingRange([4, 7]);
- const audio = KGAudioInterface.instance()
- audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true })
+ const audio = KGAudioInterface.instance();
+ audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true });
- expect(MockTransport.position).toBe(6)
- })
-})
+ expect(MockTransport.position).toBe(6);
+ });
+});
diff --git a/src/core/audio-interface/KGMetronome.test.ts b/src/core/audio-interface/KGMetronome.test.ts
index ebf1f43..a92ceff 100644
--- a/src/core/audio-interface/KGMetronome.test.ts
+++ b/src/core/audio-interface/KGMetronome.test.ts
@@ -1,61 +1,61 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { MockLoop, MockTransport } from '../../test/mocks/tone'
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { MockLoop, MockTransport } from '../../test/mocks/tone';
vi.mock('tone', async () => {
- const { ToneMock: toneMock } = await import('../../test/mocks/tone')
- return toneMock
-})
+ const { ToneMock: toneMock } = await import('../../test/mocks/tone');
+ return toneMock;
+});
-import { KGMetronome } from './KGMetronome'
+import { KGMetronome } from './KGMetronome';
describe('KGMetronome', () => {
beforeEach(() => {
- vi.useFakeTimers()
- vi.setSystemTime(0)
- vi.clearAllMocks()
- MockTransport.bpm.value = 120
- MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ)
- })
+ vi.useFakeTimers();
+ vi.setSystemTime(0);
+ vi.clearAllMocks();
+ MockTransport.bpm.value = 120;
+ MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ);
+ });
it('schedules audible preroll clicks before beat 0 and keeps the beat 0 accent', () => {
- const metronome = new KGMetronome()
+ const metronome = new KGMetronome();
const triggerAttackRelease = vi.fn()
;(metronome as unknown as { sampler: unknown }).sampler = {
loaded: true,
triggerAttackRelease,
- }
+ };
- metronome.start(-4, 4, 0.2)
+ metronome.start(-4, 4, 0.2);
- vi.advanceTimersByTime(200)
- expect(triggerAttackRelease.mock.calls[0]?.[0]).toBe('C5')
- expect(triggerAttackRelease.mock.calls[0]?.[1]).toBe('16n')
- expect(triggerAttackRelease.mock.calls[0]?.[2]).toBeCloseTo(0.2, 5)
+ vi.advanceTimersByTime(200);
+ expect(triggerAttackRelease.mock.calls[0]?.[0]).toBe('C5');
+ expect(triggerAttackRelease.mock.calls[0]?.[1]).toBe('16n');
+ expect(triggerAttackRelease.mock.calls[0]?.[2]).toBeCloseTo(0.2, 5);
- vi.advanceTimersByTime(1000)
- expect(triggerAttackRelease.mock.calls[1]?.[0]).toBe('C4')
- expect(triggerAttackRelease.mock.calls[1]?.[1]).toBe('16n')
- expect(triggerAttackRelease.mock.calls[1]?.[2]).toBeCloseTo(0.7, 5)
+ vi.advanceTimersByTime(1000);
+ expect(triggerAttackRelease.mock.calls[1]?.[0]).toBe('C4');
+ expect(triggerAttackRelease.mock.calls[1]?.[1]).toBe('16n');
+ expect(triggerAttackRelease.mock.calls[1]?.[2]).toBeCloseTo(0.7, 5);
- const transportLoop = MockLoop.mock.results[0]?.value
- expect(transportLoop).toBeDefined()
+ const transportLoop = MockLoop.mock.results[0]?.value;
+ expect(transportLoop).toBeDefined();
- transportLoop.callback(0)
- expect(triggerAttackRelease).toHaveBeenLastCalledWith('C5', '16n', 0.2)
- })
+ transportLoop.callback(0);
+ expect(triggerAttackRelease).toHaveBeenLastCalledWith('C5', '16n', 0.2);
+ });
it('cancels pending preroll clicks when stopped', () => {
- const metronome = new KGMetronome()
+ const metronome = new KGMetronome();
const triggerAttackRelease = vi.fn()
;(metronome as unknown as { sampler: unknown }).sampler = {
loaded: true,
triggerAttackRelease,
- }
+ };
- metronome.start(-2, 4, 0.2)
- metronome.stop()
- vi.runAllTimers()
+ metronome.start(-2, 4, 0.2);
+ metronome.stop();
+ vi.runAllTimers();
- expect(triggerAttackRelease).not.toHaveBeenCalled()
- })
-})
+ expect(triggerAttackRelease).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/core/commands/note/CreateNoteCommand.test.ts b/src/core/commands/note/CreateNoteCommand.test.ts
index 6ce2f0c..e7ab386 100644
--- a/src/core/commands/note/CreateNoteCommand.test.ts
+++ b/src/core/commands/note/CreateNoteCommand.test.ts
@@ -1,34 +1,34 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { CreateNoteCommand } from './CreateNoteCommand'
-import { KGCore } from '../../KGCore'
-import { KGMidiNote } from '../../midi/KGMidiNote'
-import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data'
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { CreateNoteCommand } from './CreateNoteCommand';
+import { KGCore } from '../../KGCore';
+import { KGMidiNote } from '../../midi/KGMidiNote';
+import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data';
// Mock the KGCore singleton
vi.mock('../../KGCore', () => ({
KGCore: {
instance: vi.fn()
}
-}))
+}));
// Mock generateUniqueId utility
vi.mock('../../../util/miscUtil', () => ({
generateUniqueId: vi.fn().mockReturnValue('mock-note-id')
-}))
+}));
// Import the mocked function properly
-const { generateUniqueId } = await import('../../../util/miscUtil')
+const { generateUniqueId } = await import('../../../util/miscUtil');
interface MockCore {
getCurrentProject: ReturnType
}
describe('CreateNoteCommand', () => {
- let mockCore: MockCore
- let mockProject: ReturnType
- let mockTrack: ReturnType
- let mockRegion: ReturnType
- let command: CreateNoteCommand
+ let mockCore: MockCore;
+ let mockProject: ReturnType;
+ let mockTrack: ReturnType;
+ let mockRegion: ReturnType;
+ let command: CreateNoteCommand;
beforeEach(() => {
// Create test data
@@ -36,25 +36,25 @@ describe('CreateNoteCommand', () => {
id: 'test-region',
trackId: 'test-track',
name: 'Test Region'
- })
+ });
mockTrack = createMockMidiTrack({
id: 1,
name: 'Test Track',
regions: [mockRegion]
- })
+ });
mockProject = createMockProject({
name: 'Test Project',
tracks: [mockTrack]
- })
+ });
// Mock KGCore methods
mockCore = {
getCurrentProject: vi.fn().mockReturnValue(mockProject)
- }
+ };
- vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore)
+ vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
// Create command
command = new CreateNoteCommand(
@@ -63,135 +63,135 @@ describe('CreateNoteCommand', () => {
1, // endBeat
60, // pitch (middle C)
80 // velocity
- )
- })
+ );
+ });
describe('constructor', () => {
it('should create command with correct parameters', () => {
- const cmd = new CreateNoteCommand('region-1', 2, 4, 72, 100, 'custom-id')
+ const cmd = new CreateNoteCommand('region-1', 2, 4, 72, 100, 'custom-id');
- expect(cmd).toBeInstanceOf(CreateNoteCommand)
+ expect(cmd).toBeInstanceOf(CreateNoteCommand);
// We can't directly test private properties, but we can test execution
- })
+ });
it('should generate unique ID when not provided', () => {
- new CreateNoteCommand('region-1', 0, 1, 60, 80)
+ new CreateNoteCommand('region-1', 0, 1, 60, 80);
// The generateUniqueId mock should have been called
- expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote')
- })
+ expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote');
+ });
it('should use provided ID when given', () => {
// Clear previous calls
- vi.clearAllMocks()
+ vi.clearAllMocks();
- new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id')
+ new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id');
// Should not call generateUniqueId when ID is provided
- expect(generateUniqueId).not.toHaveBeenCalled()
- })
- })
+ expect(generateUniqueId).not.toHaveBeenCalled();
+ });
+ });
describe('execute', () => {
it('should create a note in the target region', () => {
// Mock the addNote method
- const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
+ const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
// Execute the command
- command.execute()
+ command.execute();
// Verify note was added
- expect(addNoteSpy).toHaveBeenCalledTimes(1)
+ expect(addNoteSpy).toHaveBeenCalledTimes(1);
// Verify the note has correct properties
- const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote
- expect(addedNote).toBeInstanceOf(KGMidiNote)
- expect(addedNote.getStartBeat()).toBe(0)
- expect(addedNote.getEndBeat()).toBe(1)
- expect(addedNote.getPitch()).toBe(60)
- expect(addedNote.getVelocity()).toBe(80)
- })
+ const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote;
+ expect(addedNote).toBeInstanceOf(KGMidiNote);
+ expect(addedNote.getStartBeat()).toBe(0);
+ expect(addedNote.getEndBeat()).toBe(1);
+ expect(addedNote.getPitch()).toBe(60);
+ expect(addedNote.getVelocity()).toBe(80);
+ });
it('should throw error for non-existent region', () => {
// Create command for non-existent region
- const badCommand = new CreateNoteCommand('non-existent-region', 0, 1, 60, 80)
+ const badCommand = new CreateNoteCommand('non-existent-region', 0, 1, 60, 80);
// Should throw error for non-existent region
- expect(() => badCommand.execute()).toThrow('MIDI region with ID non-existent-region not found')
- })
+ expect(() => badCommand.execute()).toThrow('MIDI region with ID non-existent-region not found');
+ });
it('should store created note for undo operation', () => {
- const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
+ const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
- command.execute()
+ command.execute();
// Note should be created and stored internally for undo
- expect(addNoteSpy).toHaveBeenCalledTimes(1)
- })
- })
+ expect(addNoteSpy).toHaveBeenCalledTimes(1);
+ });
+ });
describe('undo', () => {
it('should remove the created note', () => {
// Execute first to create the note
- command.execute()
+ command.execute();
// Mock removeNote method
- const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote')
+ const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote');
// Undo the command
- command.undo()
+ command.undo();
// Verify note was removed
- expect(removeNoteSpy).toHaveBeenCalledTimes(1)
- })
+ expect(removeNoteSpy).toHaveBeenCalledTimes(1);
+ });
it('should throw error when undoing without execute', () => {
// Try to undo without executing first
- expect(() => command.undo()).toThrow('Cannot undo: no note was created')
- })
- })
+ expect(() => command.undo()).toThrow('Cannot undo: no note was created');
+ });
+ });
describe('re-execute (redo pattern)', () => {
it('should re-add the note after undo using execute', () => {
// Execute, undo, then execute again (redo pattern)
- command.execute()
- command.undo()
+ command.execute();
+ command.undo();
- const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
- command.execute() // Commands are re-executed for redo
+ const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
+ command.execute(); // Commands are re-executed for redo
// Note should be added again
- expect(addNoteSpy).toHaveBeenCalledTimes(1)
- })
- })
+ expect(addNoteSpy).toHaveBeenCalledTimes(1);
+ });
+ });
describe('getDescription', () => {
it('should return descriptive text', () => {
- const description = command.getDescription()
+ const description = command.getDescription();
- expect(description).toBeDefined()
- expect(typeof description).toBe('string')
- expect(description.length).toBeGreaterThan(0)
- })
- })
+ expect(description).toBeDefined();
+ expect(typeof description).toBe('string');
+ expect(description.length).toBeGreaterThan(0);
+ });
+ });
describe('command lifecycle', () => {
it('should support multiple execute/undo cycles', () => {
- const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
- const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote')
+ const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
+ const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote');
// Execute -> Undo -> Execute -> Undo
- command.execute()
- expect(addNoteSpy).toHaveBeenCalledTimes(1)
+ command.execute();
+ expect(addNoteSpy).toHaveBeenCalledTimes(1);
- command.undo()
- expect(removeNoteSpy).toHaveBeenCalledTimes(1)
+ command.undo();
+ expect(removeNoteSpy).toHaveBeenCalledTimes(1);
- command.execute() // Re-execute for redo
- expect(addNoteSpy).toHaveBeenCalledTimes(2)
+ command.execute(); // Re-execute for redo
+ expect(addNoteSpy).toHaveBeenCalledTimes(2);
- command.undo()
- expect(removeNoteSpy).toHaveBeenCalledTimes(2)
- })
- })
-})
\ No newline at end of file
+ command.undo();
+ expect(removeNoteSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/core/midi/KGMidiNote.test.ts b/src/core/midi/KGMidiNote.test.ts
index 4b86fdf..ea3d72f 100644
--- a/src/core/midi/KGMidiNote.test.ts
+++ b/src/core/midi/KGMidiNote.test.ts
@@ -1,151 +1,151 @@
-import { describe, it, expect, beforeEach } from 'vitest'
-import { KGMidiNote } from './KGMidiNote'
+import { describe, it, expect, beforeEach } from 'vitest';
+import { KGMidiNote } from './KGMidiNote';
describe('KGMidiNote', () => {
- let note: KGMidiNote
+ let note: KGMidiNote;
beforeEach(() => {
- note = new KGMidiNote('test-note', 0, 1, 60, 80)
- })
+ note = new KGMidiNote('test-note', 0, 1, 60, 80);
+ });
describe('constructor', () => {
it('should create a note with correct properties', () => {
- const testNote = new KGMidiNote('note-1', 2, 4, 72, 100)
+ const testNote = new KGMidiNote('note-1', 2, 4, 72, 100);
- expect(testNote.getId()).toBe('note-1')
- expect(testNote.getStartBeat()).toBe(2)
- expect(testNote.getEndBeat()).toBe(4)
- expect(testNote.getPitch()).toBe(72)
- expect(testNote.getVelocity()).toBe(100)
- })
+ expect(testNote.getId()).toBe('note-1');
+ expect(testNote.getStartBeat()).toBe(2);
+ expect(testNote.getEndBeat()).toBe(4);
+ expect(testNote.getPitch()).toBe(72);
+ expect(testNote.getVelocity()).toBe(100);
+ });
it('should use default values when not provided', () => {
- const defaultNote = new KGMidiNote('default-note')
+ const defaultNote = new KGMidiNote('default-note');
- expect(defaultNote.getId()).toBe('default-note')
- expect(defaultNote.getStartBeat()).toBe(0)
- expect(defaultNote.getEndBeat()).toBe(0)
- expect(defaultNote.getPitch()).toBe(0)
- expect(defaultNote.getVelocity()).toBe(127)
- })
- })
+ expect(defaultNote.getId()).toBe('default-note');
+ expect(defaultNote.getStartBeat()).toBe(0);
+ expect(defaultNote.getEndBeat()).toBe(0);
+ expect(defaultNote.getPitch()).toBe(0);
+ expect(defaultNote.getVelocity()).toBe(127);
+ });
+ });
describe('getters and setters', () => {
it('should get and set start beat', () => {
- expect(note.getStartBeat()).toBe(0)
+ expect(note.getStartBeat()).toBe(0);
- note.setStartBeat(1.5)
- expect(note.getStartBeat()).toBe(1.5)
- })
+ note.setStartBeat(1.5);
+ expect(note.getStartBeat()).toBe(1.5);
+ });
it('should get and set end beat', () => {
- expect(note.getEndBeat()).toBe(1)
+ expect(note.getEndBeat()).toBe(1);
- note.setEndBeat(3.5)
- expect(note.getEndBeat()).toBe(3.5)
- })
+ note.setEndBeat(3.5);
+ expect(note.getEndBeat()).toBe(3.5);
+ });
it('should get and set pitch', () => {
- expect(note.getPitch()).toBe(60)
+ expect(note.getPitch()).toBe(60);
- note.setPitch(72)
- expect(note.getPitch()).toBe(72)
- })
+ note.setPitch(72);
+ expect(note.getPitch()).toBe(72);
+ });
it('should get and set velocity', () => {
- expect(note.getVelocity()).toBe(80)
+ expect(note.getVelocity()).toBe(80);
- note.setVelocity(100)
- expect(note.getVelocity()).toBe(100)
- })
+ note.setVelocity(100);
+ expect(note.getVelocity()).toBe(100);
+ });
it('should get and set ID', () => {
- expect(note.getId()).toBe('test-note')
+ expect(note.getId()).toBe('test-note');
- note.setId('new-id')
- expect(note.getId()).toBe('new-id')
- })
- })
+ note.setId('new-id');
+ expect(note.getId()).toBe('new-id');
+ });
+ });
describe('selection', () => {
it('should start unselected', () => {
- expect(note.isSelected()).toBe(false)
- })
+ expect(note.isSelected()).toBe(false);
+ });
it('should select and deselect', () => {
- note.select()
- expect(note.isSelected()).toBe(true)
+ note.select();
+ expect(note.isSelected()).toBe(true);
- note.deselect()
- expect(note.isSelected()).toBe(false)
- })
- })
+ note.deselect();
+ expect(note.isSelected()).toBe(false);
+ });
+ });
describe('note duration', () => {
it('should calculate duration correctly', () => {
- const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80)
- expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2)
- })
+ const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80);
+ expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2);
+ });
it('should handle zero duration', () => {
- const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80)
- expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0)
- })
- })
+ const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80);
+ expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0);
+ });
+ });
describe('pitch validation', () => {
it('should accept valid MIDI pitch range', () => {
// MIDI pitch range is typically 0-127
- note.setPitch(0)
- expect(note.getPitch()).toBe(0)
+ note.setPitch(0);
+ expect(note.getPitch()).toBe(0);
- note.setPitch(127)
- expect(note.getPitch()).toBe(127)
+ note.setPitch(127);
+ expect(note.getPitch()).toBe(127);
- note.setPitch(60) // Middle C
- expect(note.getPitch()).toBe(60)
- })
- })
+ note.setPitch(60); // Middle C
+ expect(note.getPitch()).toBe(60);
+ });
+ });
describe('velocity validation', () => {
it('should accept valid MIDI velocity range', () => {
// MIDI velocity range is typically 0-127
- note.setVelocity(0)
- expect(note.getVelocity()).toBe(0)
+ note.setVelocity(0);
+ expect(note.getVelocity()).toBe(0);
- note.setVelocity(127)
- expect(note.getVelocity()).toBe(127)
+ note.setVelocity(127);
+ expect(note.getVelocity()).toBe(127);
- note.setVelocity(64) // Mid velocity
- expect(note.getVelocity()).toBe(64)
- })
- })
+ note.setVelocity(64); // Mid velocity
+ expect(note.getVelocity()).toBe(64);
+ });
+ });
describe('clone and comparison', () => {
it('should create independent instances', () => {
- const note1 = new KGMidiNote('note-1', 0, 1, 60, 80)
- const note2 = new KGMidiNote('note-2', 0, 1, 60, 80)
+ const note1 = new KGMidiNote('note-1', 0, 1, 60, 80);
+ const note2 = new KGMidiNote('note-2', 0, 1, 60, 80);
- expect(note1.getId()).not.toBe(note2.getId())
+ expect(note1.getId()).not.toBe(note2.getId());
- note1.setPitch(72)
- expect(note2.getPitch()).toBe(60) // Should remain unchanged
- })
- })
+ note1.setPitch(72);
+ expect(note2.getPitch()).toBe(60); // Should remain unchanged
+ });
+ });
describe('edge cases', () => {
it('should handle negative start beat', () => {
- note.setStartBeat(-1)
- expect(note.getStartBeat()).toBe(-1)
- })
+ note.setStartBeat(-1);
+ expect(note.getStartBeat()).toBe(-1);
+ });
it('should handle start beat after end beat', () => {
- note.setStartBeat(5)
- note.setEndBeat(2)
+ note.setStartBeat(5);
+ note.setEndBeat(2);
- expect(note.getStartBeat()).toBe(5)
- expect(note.getEndBeat()).toBe(2)
+ expect(note.getStartBeat()).toBe(5);
+ expect(note.getEndBeat()).toBe(2);
// Note: The class might need validation logic to prevent this
- })
- })
-})
\ No newline at end of file
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/core/region/KGMidiRegion.test.ts b/src/core/region/KGMidiRegion.test.ts
index d47c6b6..4131c4e 100644
--- a/src/core/region/KGMidiRegion.test.ts
+++ b/src/core/region/KGMidiRegion.test.ts
@@ -1,11 +1,11 @@
-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'
+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
+ let region: KGMidiRegion;
beforeEach(() => {
region = new KGMidiRegion(
@@ -15,8 +15,8 @@ describe('KGMidiRegion', () => {
'Test Region',
0,
4
- )
- })
+ );
+ });
describe('constructor', () => {
it('should create a MIDI region with correct properties', () => {
@@ -27,327 +27,327 @@ describe('KGMidiRegion', () => {
'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([])
- })
+ 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')
+ 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([])
- })
+ 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')
- })
- })
+ expect(region.getCurrentType()).toBe('KGMidiRegion');
+ });
+ });
describe('note management', () => {
- let note1: KGMidiNote
- let note2: KGMidiNote
- let note3: KGMidiNote
+ 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 })
- })
+ 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)
+ region.addNote(note1);
- const notes = region.getNotes()
- expect(notes).toHaveLength(1)
- expect(notes[0]).toBe(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)
+ 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)
- })
+ 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)
+ 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)
- })
+ 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)
+ region.addNote(note1);
+ region.addNote(note1);
- const notes = region.getNotes()
- expect(notes).toHaveLength(2)
- expect(notes[0]).toBe(note1)
- expect(notes[1]).toBe(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)
- })
+ region.addNote(note1);
+ region.addNote(note2);
+ region.addNote(note3);
+ });
it('should remove note by ID', () => {
- region.removeNote('note-2')
+ 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)
- })
+ 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
+ const initialLength = region.getNotes().length;
- region.removeNote('non-existent-note')
+ region.removeNote('non-existent-note');
- expect(region.getNotes()).toHaveLength(initialLength)
- })
+ 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)
+ const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 });
+ region.addNote(duplicateNote);
- expect(region.getNotes()).toHaveLength(4)
+ expect(region.getNotes()).toHaveLength(4);
- region.removeNote('note-1')
+ 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)
- })
+ 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')
+ const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region');
- expect(() => emptyRegion.removeNote('note-1')).not.toThrow()
- expect(emptyRegion.getNotes()).toHaveLength(0)
- })
- })
+ 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)
- })
+ const notes = region.getNotes();
+ expect(notes).toEqual([]);
+ expect(notes).toHaveLength(0);
+ });
it('should return all notes in region', () => {
- region.addNote(note1)
- region.addNote(note2)
+ region.addNote(note1);
+ region.addNote(note2);
- const notes = region.getNotes()
- expect(notes).toHaveLength(2)
- expect(notes).toEqual([note1, 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()
+ region.addNote(note1);
+ const notes1 = region.getNotes();
+ const notes2 = region.getNotes();
- expect(notes1).toBe(notes2) // Same reference
- })
- })
+ expect(notes1).toBe(notes2); // Same reference
+ });
+ });
describe('setNotes', () => {
it('should replace all notes with new array', () => {
- region.addNote(note1)
- region.addNote(note2)
+ region.addNote(note1);
+ region.addNote(note2);
- expect(region.getNotes()).toHaveLength(2)
+ expect(region.getNotes()).toHaveLength(2);
- region.setNotes([note3])
+ region.setNotes([note3]);
- const notes = region.getNotes()
- expect(notes).toHaveLength(1)
- expect(notes[0]).toBe(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.addNote(note1);
+ region.addNote(note2);
- region.setNotes([])
+ region.setNotes([]);
- expect(region.getNotes()).toHaveLength(0)
- })
+ expect(region.getNotes()).toHaveLength(0);
+ });
it('should accept notes array with multiple notes', () => {
- const newNotes = [note1, note2, note3]
- region.setNotes(newNotes)
+ const newNotes = [note1, note2, note3];
+ region.setNotes(newNotes);
- const retrievedNotes = region.getNotes()
- expect(retrievedNotes).toHaveLength(3)
- expect(retrievedNotes).toEqual(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)
- })
+ 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)
+ expect(region.isSelected()).toBe(false);
- region.select()
- expect(region.isSelected()).toBe(true)
+ region.select();
+ expect(region.isSelected()).toBe(true);
- region.deselect()
- expect(region.isSelected()).toBe(false)
- })
+ 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.setName('Updated Region');
+ expect(region.getName()).toBe('Updated Region');
- region.setStartFromBeat(8)
- expect(region.getStartFromBeat()).toBe(8)
+ region.setStartFromBeat(8);
+ expect(region.getStartFromBeat()).toBe(8);
- region.setLength(12)
- expect(region.getLength()).toBe(12)
- })
- })
+ region.setLength(12);
+ expect(region.getLength()).toBe(12);
+ });
+ });
describe('type identification', () => {
it('should return correct current type', () => {
- expect(region.getCurrentType()).toBe('KGMidiRegion')
- })
+ expect(region.getCurrentType()).toBe('KGMidiRegion');
+ });
it('should return correct root type', () => {
- expect(region.getRootType()).toBe('KGRegion')
- })
+ expect(region.getRootType()).toBe('KGRegion');
+ });
it('should be instanceof both KGMidiRegion and KGRegion', () => {
- expect(region).toBeInstanceOf(KGMidiRegion)
- expect(region).toBeInstanceOf(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 })
+ 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)
+ region.addNote(overlappingNote1);
+ region.addNote(overlappingNote2);
- const notes = region.getNotes()
- expect(notes).toHaveLength(2)
- expect(notes).toContain(overlappingNote1)
- expect(notes).toContain(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 })
+ 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)
+ region.addNote(sameNote1);
+ region.addNote(sameNote2);
- expect(region.getNotes()).toHaveLength(2)
- })
+ 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 })
+ const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 });
- region.addNote(outsideNote)
+ region.addNote(outsideNote);
- const notes = region.getNotes()
- expect(notes).toHaveLength(1)
- expect(notes[0]).toBe(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 })
+ 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)
+ zeroRegion.addNote(note);
- expect(zeroRegion.getNotes()).toHaveLength(1)
- expect(zeroRegion.getLength()).toBe(0)
- })
- })
+ 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 })
+ const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 });
- region.addNote(originalNote)
- const retrievedNote = region.getNotes()[0]
+ region.addNote(originalNote);
+ const retrievedNote = region.getNotes()[0];
- expect(retrievedNote).toBe(originalNote) // Same reference
+ expect(retrievedNote).toBe(originalNote); // Same reference
// Modify original note
- originalNote.setPitch(64)
- expect(retrievedNote.getPitch()).toBe(64) // Should reflect change
- })
+ 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)
+ notes.forEach(note => region.addNote(note));
+ expect(region.getNotes()).toHaveLength(3);
// Remove middle note
- region.removeNote('concurrent-2')
- expect(region.getNotes()).toHaveLength(2)
+ 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)
+ 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
+ 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
index e7066bd..73092a3 100644
--- a/src/core/track/KGMidiTrack.test.ts
+++ b/src/core/track/KGMidiTrack.test.ts
@@ -1,70 +1,70 @@
-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'
+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
+ let track: KGMidiTrack;
beforeEach(() => {
- track = new KGMidiTrack()
- })
+ track = new KGMidiTrack();
+ });
describe('constructor', () => {
it('should create track with default values', () => {
- const defaultTrack = new KGMidiTrack()
+ 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) // DEFAULT_TRACK_VOLUME (0 dB)
- expect(defaultTrack.getRegions()).toEqual([])
- })
+ 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); // DEFAULT_TRACK_VOLUME (0 dB)
+ expect(defaultTrack.getRegions()).toEqual([]);
+ });
it('should create track with custom parameters', () => {
- const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4)
+ const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4);
- 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(-4)
- })
+ 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(-4);
+ });
it('should set correct type identifier', () => {
- expect(track.getCurrentType()).toBe('KGMidiTrack')
- })
+ expect(track.getCurrentType()).toBe('KGMidiTrack');
+ });
it('should inherit from KGTrack', () => {
- expect(track).toBeInstanceOf(KGMidiTrack)
- expect(track).toBeInstanceOf(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')
- })
+ 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')
- })
+ 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')
- })
- })
+ expect(track.getInstrument()).toBe('acoustic_grand_piano');
+ });
+ });
describe('setInstrument', () => {
it('should update instrument', () => {
- track.setInstrument('violin')
- expect(track.getInstrument()).toBe('violin')
- })
+ track.setInstrument('violin');
+ expect(track.getInstrument()).toBe('violin');
+ });
it('should handle different instrument types', () => {
const instruments: InstrumentType[] = [
@@ -75,28 +75,28 @@ describe('KGMidiTrack', () => {
'violin',
'trumpet',
'flute'
- ]
+ ];
instruments.forEach(instrument => {
- track.setInstrument(instrument)
- expect(track.getInstrument()).toBe(instrument)
- })
- })
+ track.setInstrument(instrument);
+ expect(track.getInstrument()).toBe(instrument);
+ });
+ });
it('should handle rapid instrument changes', () => {
- track.setInstrument('piano')
- track.setInstrument('guitar')
- track.setInstrument('violin')
+ track.setInstrument('piano');
+ track.setInstrument('guitar');
+ track.setInstrument('violin');
- expect(track.getInstrument()).toBe('violin')
- })
- })
- })
+ expect(track.getInstrument()).toBe('violin');
+ });
+ });
+ });
describe('region management', () => {
- let region1: KGMidiRegion
- let region2: KGMidiRegion
- let region3: KGMidiRegion
+ let region1: KGMidiRegion;
+ let region2: KGMidiRegion;
+ let region3: KGMidiRegion;
beforeEach(() => {
region1 = createMockMidiRegion({
@@ -105,137 +105,137 @@ describe('KGMidiTrack', () => {
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)
+ const regions = [region1, region2];
+ track.setRegions(regions);
- expect(track.getRegions()).toHaveLength(2)
- expect(track.getRegions()).toEqual(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([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)
- })
+ 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([])
+ track.setRegions([region1, region2]);
+ track.setRegions([]);
- expect(track.getRegions()).toHaveLength(0)
- })
+ expect(track.getRegions()).toHaveLength(0);
+ });
it('should enforce KGMidiRegion type', () => {
- const regions: KGMidiRegion[] = [region1, region2]
- track.setRegions(regions)
+ const regions: KGMidiRegion[] = [region1, region2];
+ track.setRegions(regions);
- const retrievedRegions = track.getRegions()
+ const retrievedRegions = track.getRegions();
retrievedRegions.forEach(region => {
- expect(region).toBeInstanceOf(KGMidiRegion)
- })
- })
- })
+ expect(region).toBeInstanceOf(KGMidiRegion);
+ });
+ });
+ });
describe('inherited region methods', () => {
beforeEach(() => {
- track.setRegions([region1, region2])
- })
+ track.setRegions([region1, region2]);
+ });
it('should inherit addRegion method', () => {
- track.addRegion(region3)
+ track.addRegion(region3);
- const regions = track.getRegions()
- expect(regions).toHaveLength(3)
- expect(regions).toContain(region3)
- })
+ const regions = track.getRegions();
+ expect(regions).toHaveLength(3);
+ expect(regions).toContain(region3);
+ });
it('should inherit removeRegion method', () => {
- track.removeRegion('region-1')
+ track.removeRegion('region-1');
- const regions = track.getRegions()
- expect(regions).toHaveLength(1)
- expect(regions).not.toContain(region1)
- expect(regions).toContain(region2)
- })
+ 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)
- })
- })
- })
+ 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', -1)
+ const customTrack = new KGMidiTrack('Test Track', 42, 'violin', -1);
- expect(customTrack.getName()).toBe('Test Track')
- expect(customTrack.getId()).toBe(42)
- expect(customTrack.getType()).toBe(TrackType.MIDI)
- expect(customTrack.getVolume()).toBe(-1)
- })
+ expect(customTrack.getName()).toBe('Test Track');
+ expect(customTrack.getId()).toBe(42);
+ expect(customTrack.getType()).toBe(TrackType.MIDI);
+ expect(customTrack.getVolume()).toBe(-1);
+ });
it('should inherit base track setters', () => {
- track.setName('Updated Track')
- expect(track.getName()).toBe('Updated Track')
+ track.setName('Updated Track');
+ expect(track.getName()).toBe('Updated Track');
- track.setVolume(-6)
- expect(track.getVolume()).toBe(-6)
+ track.setVolume(-6);
+ expect(track.getVolume()).toBe(-6);
- track.setTrackIndex(3)
- expect(track.getTrackIndex()).toBe(3)
- })
+ track.setTrackIndex(3);
+ expect(track.getTrackIndex()).toBe(3);
+ });
it('should inherit volume controls', () => {
- expect(track.getVolume()).toBe(0) // Default volume (0 dB)
+ expect(track.getVolume()).toBe(0); // Default volume (0 dB)
- track.setVolume(-6)
- expect(track.getVolume()).toBe(-6)
+ track.setVolume(-6);
+ expect(track.getVolume()).toBe(-6);
- track.setVolume(0)
- expect(track.getVolume()).toBe(0)
- })
- })
+ track.setVolume(0);
+ expect(track.getVolume()).toBe(0);
+ });
+ });
describe('type identification', () => {
it('should return correct current type', () => {
- expect(track.getCurrentType()).toBe('KGMidiTrack')
- })
+ expect(track.getCurrentType()).toBe('KGMidiTrack');
+ });
it('should return correct root type', () => {
- expect(track.getRootType()).toBe('KGTrack')
- })
+ expect(track.getRootType()).toBe('KGTrack');
+ });
it('should have MIDI track type', () => {
- expect(track.getType()).toBe(TrackType.MIDI)
- })
- })
+ expect(track.getType()).toBe(TrackType.MIDI);
+ });
+ });
describe('instrument type validation', () => {
it('should handle all valid General MIDI instruments', () => {
@@ -259,100 +259,100 @@ describe('KGMidiTrack', () => {
'clarinet',
'soprano_sax',
'alto_sax'
- ]
+ ];
validInstruments.forEach(instrument => {
expect(() => {
- track.setInstrument(instrument)
- expect(track.getInstrument()).toBe(instrument)
- }).not.toThrow()
- })
- })
- })
+ 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(-3)
+ track.setName('Piano Track');
+ track.setInstrument('acoustic_grand_piano');
+ track.setVolume(-3);
const regions = [
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
- ]
- track.setRegions(regions)
+ ];
+ track.setRegions(regions);
// Verify initial state
- expect(track.getName()).toBe('Piano Track')
- expect(track.getInstrument()).toBe('acoustic_grand_piano')
- expect(track.getVolume()).toBe(-3)
- expect(track.getRegions()).toHaveLength(2)
+ expect(track.getName()).toBe('Piano Track');
+ expect(track.getInstrument()).toBe('acoustic_grand_piano');
+ expect(track.getVolume()).toBe(-3);
+ expect(track.getRegions()).toHaveLength(2);
// Modify state
- track.setInstrument('electric_piano_1')
+ 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(-3)
- expect(track.getRegions()).toHaveLength(3)
- })
+ expect(track.getName()).toBe('Piano Track');
+ expect(track.getInstrument()).toBe('electric_piano_1');
+ expect(track.getVolume()).toBe(-3);
+ 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)
+ track.addRegion(region);
// Verify region is in track
- expect(track.getRegions()).toContain(region)
+ 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)
+ const foundRegion = track.getRegions().find(r => r.getId() === 'test-region');
+ expect(foundRegion).toBe(region);
// Verify region properties
- expect(region.getTrackId()).toBe(track.getId().toString())
- })
- })
+ 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('')
- })
+ 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)
- })
+ const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano');
+ expect(negativeIdTrack.getId()).toBe(-1);
+ });
it('should handle volume boundaries', () => {
- track.setVolume(-60)
- expect(track.getVolume()).toBe(-60)
+ track.setVolume(-60);
+ expect(track.getVolume()).toBe(-60);
- track.setVolume(0)
- expect(track.getVolume()).toBe(0)
+ track.setVolume(0);
+ expect(track.getVolume()).toBe(0);
- track.setVolume(6)
- expect(track.getVolume()).toBe(6)
+ track.setVolume(6);
+ expect(track.getVolume()).toBe(6);
// Volume outside valid dB range is clamped
- track.setVolume(10)
- expect(track.getVolume()).toBe(6)
+ track.setVolume(10);
+ expect(track.getVolume()).toBe(6);
- track.setVolume(-100)
- expect(track.getVolume()).toBe(-60)
- })
+ track.setVolume(-100);
+ expect(track.getVolume()).toBe(-60);
+ });
it('should handle large number of regions', () => {
const manyRegions = Array.from({ length: 100 }, (_, i) =>
@@ -361,44 +361,44 @@ describe('KGMidiTrack', () => {
trackId: track.getId().toString(),
name: `Region ${i}`
})
- )
+ );
- track.setRegions(manyRegions)
- expect(track.getRegions()).toHaveLength(100)
+ 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')
+ 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()
- })
- })
+ 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')
+ expect(track.getCurrentType()).toBe('KGMidiTrack');
// Test that default instrument fallback works
- const instrument = track.getInstrument()
- expect(instrument).toBe('acoustic_grand_piano')
- })
+ 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)
+ track.setRegions(regions);
// Simulate what happens during serialization/deserialization
- const retrievedRegions = track.getRegions()
- expect(retrievedRegions).toHaveLength(2)
+ const retrievedRegions = track.getRegions();
+ expect(retrievedRegions).toHaveLength(2);
retrievedRegions.forEach(region => {
- expect(region).toBeInstanceOf(KGMidiRegion)
- })
- })
- })
-})
\ No newline at end of file
+ expect(region).toBeInstanceOf(KGMidiRegion);
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts
index 3d0d916..916f397 100644
--- a/src/hooks/useGlobalKeyboardHandler.ts
+++ b/src/hooks/useGlobalKeyboardHandler.ts
@@ -5,13 +5,16 @@ import { saveProject } from '../util/saveUtil';
import { ConfigManager } from '../core/config/ConfigManager';
import { useProjectStore } from '../stores/projectStore';
import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
+import { KGCore } from '../core/KGCore';
+import { KGMidiInput } from '../core/midi-input/KGMidiInput';
+import { KGMidiRegion } from '../core/region/KGMidiRegion';
/**
* Global keyboard handler for copy/paste, undo/redo, play/pause, and save operations
* Handles keyboard shortcuts defined in the configuration
*/
export const useGlobalKeyboardHandler = () => {
- const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName } = useProjectStore();
+ const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll } = useProjectStore();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -64,6 +67,7 @@ export const useGlobalKeyboardHandler = () => {
const playShortcut = configManager.get('hotkeys.main.play') as string;
const loopShortcut = configManager.get('hotkeys.main.loop') as string;
const saveShortcut = configManager.get('hotkeys.main.save') as string;
+ const recordShortcut = configManager.get('hotkeys.main.record') as string;
// Check for undo shortcut
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
@@ -153,6 +157,42 @@ export const useGlobalKeyboardHandler = () => {
return;
}
+ // Check for record toggle shortcut
+ if (recordShortcut && matchesKeyboardShortcut(event, recordShortcut)) {
+ event.preventDefault();
+ if (isRecording) {
+ stopRecording();
+ setStatus('Recording stopped — notes committed');
+ return;
+ }
+ const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null);
+ if (!candidateId) {
+ setStatus('Select a MIDI region before recording');
+ return;
+ }
+ const tracks = KGCore.instance().getCurrentProject().getTracks();
+ let isMidi = false;
+ for (const track of tracks) {
+ const region = track.getRegions().find(r => r.getId() === candidateId);
+ if (region) { isMidi = region instanceof KGMidiRegion; break; }
+ }
+ if (!isMidi) {
+ setStatus('Select a MIDI region before recording');
+ return;
+ }
+ if (KGMidiInput.instance().getConnectedInputCount() === 0) {
+ setStatus('No MIDI device detected');
+ return;
+ }
+ if (!activeRegionId) {
+ setActiveRegionId(candidateId);
+ setShowPianoRoll(true);
+ }
+ startRecording();
+ setStatus('Recording started...');
+ return;
+ }
+
// Check for save shortcut
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
event.preventDefault();
@@ -176,5 +216,5 @@ export const useGlobalKeyboardHandler = () => {
return () => {
document.removeEventListener('keydown', handleKeyDown, { capture: true });
};
- }, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName]); // Include dependencies for store actions
+ }, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll]); // Include dependencies for store actions
};
\ No newline at end of file
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index b97461d..9d1f1a1 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -1197,7 +1197,7 @@ export const useProjectStore = create((set, get) => {
set({ showChatBox: defaultChatBoxOpen });
}
}
- }
+ };
});
diff --git a/src/test/integration/store/command-execution.integration.test.ts b/src/test/integration/store/command-execution.integration.test.ts
index 122adf6..f3d224b 100644
--- a/src/test/integration/store/command-execution.integration.test.ts
+++ b/src/test/integration/store/command-execution.integration.test.ts
@@ -2,334 +2,334 @@
* 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 { 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 { 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 { 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 { useProjectStore } from '../../../stores/projectStore';
// Import test utilities
-import '../../utils/setup-integration-tests'
+import '../../utils/setup-integration-tests';
describe('Command Execution Integration Tests', () => {
- let testProject: KGProject
- let testTrack: KGMidiTrack
- let testRegion: KGMidiRegion
+ 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)
+ 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', 0, 'acoustic_grand_piano')
- testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16)
+ testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
+ testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16);
// Set up the project hierarchy
- testTrack.addRegion(testRegion)
- testProject.setTracks([testTrack])
+ testTrack.addRegion(testRegion);
+ testProject.setTracks([testTrack]);
// Initialize KGCore with test project
- const core = KGCore.instance()
- await core.initialize()
- core.setCurrentProject(testProject)
+ const core = KGCore.instance();
+ await core.initialize();
+ core.setCurrentProject(testProject);
// Clear command history
- KGCommandHistory.instance().clear()
+ KGCommandHistory.instance().clear();
// Initialize store with project
- const { loadProject } = useProjectStore.getState()
- await loadProject(testProject)
- })
+ 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
+ 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()
+ 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)
- })
+ commandHistory.executeCommand(createCommand);
+ });
// Verify core model was updated
- const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
- expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1)
+ 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)
+ 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')
+ 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)
- })
+ 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
+ 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()
+ const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120); // E4
+ const commandHistory = KGCommandHistory.instance();
act(() => {
- commandHistory.executeCommand(createCommand)
- })
+ commandHistory.executeCommand(createCommand);
+ });
// Verify note was created
- const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
- expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1)
+ 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)
- })
+ 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)
+ 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)
+ 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')
- })
+ 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
+ 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()
+ const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110); // G4
+ const commandHistory = KGCommandHistory.instance();
act(() => {
- commandHistory.executeCommand(createCommand)
- commandHistory.undo()
- })
+ commandHistory.executeCommand(createCommand);
+ commandHistory.undo();
+ });
// Verify we're back to initial state
- expect(testRegion.getNotes()).toHaveLength(initialNoteCount)
+ expect(testRegion.getNotes()).toHaveLength(initialNoteCount);
// Execute redo
act(() => {
- const redoSuccess = commandHistory.redo()
- expect(redoSuccess).toBe(true)
- })
+ 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)
+ 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)
+ 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)
- })
- })
+ 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()
+ 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
+ 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)
- })
+ 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)
+ 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')
+ 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
- })
+ 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
+ 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')
- })
+ 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
+ const commandHistory = KGCommandHistory.instance();
+ const initialTrackCount = testProject.getTracks().length;
// Execute track addition command
- const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass')
+ const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass');
act(() => {
- commandHistory.executeCommand(addTrackCommand)
- })
+ 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')
+ 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
+ const createNoteCommand = new CreateNoteCommand(testRegion.getId(), 0, 1, 48, 100); // C3
act(() => {
- commandHistory.executeCommand(createNoteCommand)
- })
+ commandHistory.executeCommand(createNoteCommand);
+ });
// Verify both commands are in history
- expect(commandHistory.canUndo()).toBe(true)
- expect(commandHistory.getUndoDescription()).toBe('Create note C3')
+ 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
- })
+ 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)
- })
- })
+ 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()
+ const commandHistory = KGCommandHistory.instance();
// Try to create note in non-existent region
- const invalidCommand = new CreateNoteCommand('invalid-region-id', 0, 1, 60, 100)
+ 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)
- })
+ commandHistory.executeCommand(invalidCommand);
+ });
// Verify command was not added to history due to execution failure
- expect(commandHistory.canUndo()).toBe(false)
- expect(commandHistory.getUndoDescription()).toBeNull()
+ 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)
- })
+ 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()
+ const regionId = testRegion.getId();
+ const commandHistory = KGCommandHistory.instance();
// Create a command that will succeed initially
- const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100)
+ const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
act(() => {
- commandHistory.executeCommand(createCommand)
- })
+ commandHistory.executeCommand(createCommand);
+ });
// Manually remove the region to cause undo to fail
- testTrack.removeRegion(testRegion.getId())
+ testTrack.removeRegion(testRegion.getId());
// Try to undo - should fail gracefully
act(() => {
- const undoSuccess = commandHistory.undo()
- expect(undoSuccess).toBe(false)
- })
+ const undoSuccess = commandHistory.undo();
+ expect(undoSuccess).toBe(false);
+ });
// Verify command is still in undo stack after failed undo
- expect(commandHistory.canUndo()).toBe(true)
- })
- })
+ 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 { syncUndoRedoState } = useProjectStore.getState()
+ const regionId = testRegion.getId();
+ const commandHistory = KGCommandHistory.instance();
+ const { syncUndoRedoState } = useProjectStore.getState();
// Initial state
- expect(useProjectStore.getState().canUndo).toBe(false)
- expect(useProjectStore.getState().canRedo).toBe(false)
+ expect(useProjectStore.getState().canUndo).toBe(false);
+ expect(useProjectStore.getState().canRedo).toBe(false);
// Execute command
- const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100)
+ const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
act(() => {
- commandHistory.executeCommand(createCommand)
- syncUndoRedoState() // Simulate store sync
- })
+ commandHistory.executeCommand(createCommand);
+ syncUndoRedoState(); // 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()
+ 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()
- syncUndoRedoState() // Simulate store sync
- })
+ commandHistory.undo();
+ syncUndoRedoState(); // 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
+ 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
index ac3113c..bec552c 100644
--- a/src/test/integration/store/project-store-sync.integration.test.ts
+++ b/src/test/integration/store/project-store-sync.integration.test.ts
@@ -2,481 +2,481 @@
* 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 { 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 { KGMidiInput } from '../../../core/midi-input/KGMidiInput'
+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 { KGMidiInput } from '../../../core/midi-input/KGMidiInput';
// Import store
-import { useProjectStore } from '../../../stores/projectStore'
+import { useProjectStore } from '../../../stores/projectStore';
// Import test utilities and mocks
-import '../../utils/setup-integration-tests'
-import { mockAudioInterface } from '../../mocks/audio-interface'
+import '../../utils/setup-integration-tests';
+import { mockAudioInterface } from '../../mocks/audio-interface';
describe('Project Store Synchronization Integration Tests', () => {
- let testProject: KGProject
+ 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)
+ 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.initialize()
- core.setCurrentProject(testProject)
+ const core = KGCore.instance();
+ await core.initialize();
+ core.setCurrentProject(testProject);
// Reset store state
- const store = useProjectStore.getState()
- await store.loadProject(testProject)
- })
+ const store = useProjectStore.getState();
+ await store.loadProject(testProject);
+ });
afterEach(() => {
- vi.clearAllMocks()
- })
+ vi.clearAllMocks();
+ });
describe('Project Properties Synchronization', () => {
it('should sync BPM changes between store and core model', async () => {
- const { setBpm } = useProjectStore.getState()
- const newBpm = 140
+ const { setBpm } = useProjectStore.getState();
+ const newBpm = 140;
// Execute store action
act(() => {
- setBpm(newBpm)
- })
+ setBpm(newBpm);
+ });
// Verify core model was updated
- expect(testProject.getBpm()).toBe(newBpm)
+ expect(testProject.getBpm()).toBe(newBpm);
// Verify store state reflects change
- const storeState = useProjectStore.getState()
- expect(storeState.bpm).toBe(newBpm)
+ 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
- })
+ 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 }
+ const { setTimeSignature } = useProjectStore.getState();
+ const newTimeSignature = { numerator: 3, denominator: 4 };
act(() => {
- setTimeSignature(newTimeSignature)
- })
+ setTimeSignature(newTimeSignature);
+ });
// Verify core model was updated
- expect(testProject.getTimeSignature()).toEqual(newTimeSignature)
+ expect(testProject.getTimeSignature()).toEqual(newTimeSignature);
// Verify store state reflects change
- const storeState = useProjectStore.getState()
- expect(storeState.timeSignature).toEqual(newTimeSignature)
+ 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')
- })
+ 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
+ const { setMaxBars } = useProjectStore.getState();
+ const newMaxBars = 64;
act(() => {
- setMaxBars(newMaxBars)
- })
+ setMaxBars(newMaxBars);
+ });
// Verify core model was updated
- expect(testProject.getMaxBars()).toBe(newMaxBars)
+ expect(testProject.getMaxBars()).toBe(newMaxBars);
// Verify store state reflects change
- const storeState = useProjectStore.getState()
- expect(storeState.maxBars).toBe(newMaxBars)
+ 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')
- })
+ 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'
+ const { setKeySignature } = useProjectStore.getState();
+ const newKeySignature = 'G major';
act(() => {
- setKeySignature(newKeySignature)
- })
+ setKeySignature(newKeySignature);
+ });
// Verify core model was updated
- expect(testProject.getKeySignature()).toBe(newKeySignature)
+ expect(testProject.getKeySignature()).toBe(newKeySignature);
// Verify store state reflects change
- const storeState = useProjectStore.getState()
- expect(storeState.keySignature).toBe(newKeySignature)
- })
- })
+ 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
+ const { addTrack } = useProjectStore.getState();
+ const initialTrackCount = testProject.getTracks().length;
// Execute store action
await act(async () => {
- await addTrack()
- })
+ 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')
+ 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)
+ const storeState = useProjectStore.getState();
+ expect(storeState.tracks).toHaveLength(initialTrackCount + 1);
// Verify audio interface was notified
- expect(mockAudioInterface.createTrackBus).toHaveBeenCalled()
- })
+ 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()
+ const { addTrack, removeTrack } = useProjectStore.getState();
await act(async () => {
- await addTrack()
- })
+ await addTrack();
+ });
- const trackCountAfterAdd = testProject.getTracks().length
- const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1]
+ const trackCountAfterAdd = testProject.getTracks().length;
+ const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1];
// Remove the track
await act(async () => {
- await removeTrack(trackCountAfterAdd - 1) // Remove last track
- })
+ await removeTrack(trackCountAfterAdd - 1); // Remove last track
+ });
// Verify core model was updated
- expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1)
+ expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1);
// Verify store state reflects change
- const storeState = useProjectStore.getState()
- expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1)
+ const storeState = useProjectStore.getState();
+ expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1);
// Verify audio interface was notified
- expect(mockAudioInterface.removeTrackBus).toHaveBeenCalledWith(trackToRemove.getId())
- })
+ 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()
+ const { addTrack, setTrackInstrument } = useProjectStore.getState();
await act(async () => {
- await addTrack()
- })
+ await addTrack();
+ });
- const trackIndex = testProject.getTracks().length - 1
- const track = testProject.getTracks()[trackIndex] as KGMidiTrack
- const newInstrument: InstrumentType = 'electric_bass'
+ 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)
- })
+ await setTrackInstrument(trackIndex, newInstrument);
+ });
// Verify core model was updated
- expect(track.getInstrument()).toBe(newInstrument)
+ 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)
+ 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)
- })
+ expect(mockAudioInterface.setTrackInstrument).toHaveBeenCalledWith(track.getId(), newInstrument);
+ });
it('should sync track reordering with core model', async () => {
- const { addTrack, reorderTracks } = useProjectStore.getState()
+ const { addTrack, reorderTracks } = useProjectStore.getState();
// Add two tracks
await act(async () => {
- await addTrack() // Track at index 0
- await addTrack() // Track at index 1
- })
+ await addTrack(); // Track at index 0
+ await addTrack(); // Track at index 1
+ });
- const track0Before = testProject.getTracks()[0]
- const track1Before = testProject.getTracks()[1]
+ const track0Before = testProject.getTracks()[0];
+ const track1Before = testProject.getTracks()[1];
// Reorder tracks (move track 0 to position 1)
act(() => {
- reorderTracks(0, 1)
- })
+ reorderTracks(0, 1);
+ });
// Verify core model track order changed
- const track0After = testProject.getTracks()[0]
- const track1After = testProject.getTracks()[1]
+ const track0After = testProject.getTracks()[0];
+ const track1After = testProject.getTracks()[1];
- expect(track0After.getId()).toBe(track1Before.getId())
- expect(track1After.getId()).toBe(track0Before.getId())
+ 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())
- })
- })
+ 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
+ const { setPlayheadPosition } = useProjectStore.getState();
+ const newPosition = 8.5; // beats
act(() => {
- setPlayheadPosition(newPosition)
- })
+ setPlayheadPosition(newPosition);
+ });
// Verify store state updated
- const storeState = useProjectStore.getState()
- expect(storeState.playheadPosition).toBe(newPosition)
+ 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
- })
+ 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()
+ const { startPlaying, stopPlaying } = useProjectStore.getState();
// Start playing
await act(async () => {
- await startPlaying()
- })
+ await startPlaying();
+ });
// Verify store state updated
- let storeState = useProjectStore.getState()
- expect(storeState.isPlaying).toBe(true)
+ let storeState = useProjectStore.getState();
+ expect(storeState.isPlaying).toBe(true);
// Verify audio interface was called
- expect(mockAudioInterface.startPlayback).toHaveBeenCalled()
+ expect(mockAudioInterface.startPlayback).toHaveBeenCalled();
// Stop playing
await act(async () => {
- await stopPlaying()
- })
+ await stopPlaying();
+ });
// Verify store state updated
- storeState = useProjectStore.getState()
- expect(storeState.isPlaying).toBe(false)
+ storeState = useProjectStore.getState();
+ expect(storeState.isPlaying).toBe(false);
// Verify audio interface was called
- expect(mockAudioInterface.stopPlayback).toHaveBeenCalled()
- })
+ expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
+ });
it('should start looped recording one bar before the loop start on the first pass', async () => {
- const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano')
- const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16)
- testTrack.addRegion(testRegion)
- testProject.setTracks([testTrack])
- testProject.setIsLooping(true)
- testProject.setLoopingRange([4, 7])
+ const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
+ const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
+ testTrack.addRegion(testRegion);
+ testProject.setTracks([testTrack]);
+ testProject.setIsLooping(true);
+ testProject.setLoopingRange([4, 7]);
await act(async () => {
- await useProjectStore.getState().loadProject(testProject)
- })
+ await useProjectStore.getState().loadProject(testProject);
+ });
- const core = KGCore.instance()
- const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined)
- const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks')
- const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState()
+ const core = KGCore.instance();
+ const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined);
+ const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks');
+ const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState();
act(() => {
- setActiveRegionId(testRegion.getId())
- setPlayheadPosition(18)
- })
+ setActiveRegionId(testRegion.getId());
+ setPlayheadPosition(18);
+ });
await act(async () => {
- await startRecording()
- })
+ await startRecording();
+ });
- const storeState = useProjectStore.getState()
- expect(storeState.playheadPosition).toBe(12)
- expect(storeState.recordingOriginalPlayhead).toBe(18)
- expect(storeState.isRecording).toBe(true)
- expect(storeState.isPlaying).toBe(true)
- expect(recordingCallbacksSpy).toHaveBeenCalled()
- expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true })
- })
- })
+ const storeState = useProjectStore.getState();
+ expect(storeState.playheadPosition).toBe(12);
+ expect(storeState.recordingOriginalPlayhead).toBe(18);
+ expect(storeState.isRecording).toBe(true);
+ expect(storeState.isPlaying).toBe(true);
+ expect(recordingCallbacksSpy).toHaveBeenCalled();
+ expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true });
+ });
+ });
describe('Selection State Synchronization', () => {
it('should sync selection state with core piano roll state', async () => {
- const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState()
+ const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState();
// Add a track and region for testing
- const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano')
- const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16)
- testTrack.addRegion(testRegion)
- testProject.setTracks([...testProject.getTracks(), testTrack])
+ const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
+ const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16);
+ testTrack.addRegion(testRegion);
+ testProject.setTracks([...testProject.getTracks(), testTrack]);
// Set active region
act(() => {
- setActiveRegionId(testRegion.getId())
- })
+ setActiveRegionId(testRegion.getId());
+ });
// Verify store state updated
- let storeState = useProjectStore.getState()
- expect(storeState.activeRegionId).toBe(testRegion.getId())
+ let storeState = useProjectStore.getState();
+ expect(storeState.activeRegionId).toBe(testRegion.getId());
// Simulate core selection changes and sync
act(() => {
- syncSelectionFromCore()
- })
+ syncSelectionFromCore();
+ });
// Verify store selection state is synchronized
- storeState = useProjectStore.getState()
- expect(storeState.selectedNoteIds).toBeDefined()
- expect(storeState.selectedRegionIds).toBeDefined()
- })
+ 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()
+ const { clearAllSelections, setSelectedTrack } = useProjectStore.getState();
// Set some initial selection state
act(() => {
- setSelectedTrack('test-track-id')
- })
+ setSelectedTrack('test-track-id');
+ });
// Verify selection was set
- let storeState = useProjectStore.getState()
- expect(storeState.selectedTrackId).toBe('test-track-id')
+ let storeState = useProjectStore.getState();
+ expect(storeState.selectedTrackId).toBe('test-track-id');
// Clear all selections
act(() => {
- clearAllSelections()
- })
+ clearAllSelections();
+ });
// Verify all selections were cleared
- storeState = useProjectStore.getState()
- expect(storeState.selectedTrackId).toBeNull()
- expect(storeState.selectedNoteIds).toEqual([])
- expect(storeState.selectedRegionIds).toEqual([])
- })
- })
+ 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()
+ const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
// Add test region
- const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano')
- const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16)
- testTrack.addRegion(testRegion)
- testProject.setTracks([...testProject.getTracks(), testTrack])
+ const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
+ const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16);
+ testTrack.addRegion(testRegion);
+ testProject.setTracks([...testProject.getTracks(), testTrack]);
// Show piano roll with active region
act(() => {
- setActiveRegionId(testRegion.getId())
- setShowPianoRoll(true)
- })
+ setActiveRegionId(testRegion.getId());
+ setShowPianoRoll(true);
+ });
// Verify store state updated
- const storeState = useProjectStore.getState()
- expect(storeState.showPianoRoll).toBe(true)
- expect(storeState.activeRegionId).toBe(testRegion.getId())
- })
- })
+ 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
+ const { removeTrack } = useProjectStore.getState();
+ const initialTrackCount = testProject.getTracks().length;
// Try to remove non-existent track
await act(async () => {
- await removeTrack(999) // Invalid index
- })
+ await removeTrack(999); // Invalid index
+ });
// Verify project state unchanged
- expect(testProject.getTracks()).toHaveLength(initialTrackCount)
+ expect(testProject.getTracks()).toHaveLength(initialTrackCount);
// Verify store state unchanged
- const storeState = useProjectStore.getState()
- expect(storeState.tracks).toHaveLength(initialTrackCount)
- })
+ const storeState = useProjectStore.getState();
+ expect(storeState.tracks).toHaveLength(initialTrackCount);
+ });
it('should handle concurrent state updates correctly', async () => {
- const { setBpm, setMaxBars } = useProjectStore.getState()
+ const { setBpm, setMaxBars } = useProjectStore.getState();
// Execute multiple state updates concurrently
await act(async () => {
- setBpm(140)
- setMaxBars(64)
- })
+ setBpm(140);
+ setMaxBars(64);
+ });
// Verify both updates were applied to core model
- expect(testProject.getBpm()).toBe(140)
- expect(testProject.getMaxBars()).toBe(64)
+ 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)
- })
- })
+ 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()
+ 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)
+ 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', 0, 'violin')
- const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8)
- const note = new KGMidiNote('test-note', 0, 1, 64, 100)
+ const track = new KGMidiTrack('Loaded Track', 0, 'violin');
+ const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8);
+ const note = new KGMidiNote('test-note', 0, 1, 64, 100);
- region.addNote(note)
- track.addRegion(region)
- newProject.setTracks([track])
+ region.addNote(note);
+ track.addRegion(region);
+ newProject.setTracks([track]);
// Load the new project
await act(async () => {
- await loadProject(newProject)
- })
+ 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)
+ 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')
+ 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 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')
- })
- })
-})
+ const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars');
+ expect(maxBarsCSS.trim()).toBe('48');
+ });
+ });
+});
diff --git a/src/test/integration/utils/abc-notation.integration.test.ts b/src/test/integration/utils/abc-notation.integration.test.ts
index 4525500..c20ec54 100644
--- a/src/test/integration/utils/abc-notation.integration.test.ts
+++ b/src/test/integration/utils/abc-notation.integration.test.ts
@@ -1,62 +1,62 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { plainToInstance } from 'class-transformer'
-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 { describe, it, expect, beforeEach, vi } from 'vitest';
+import { plainToInstance } from 'class-transformer';
+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 '../../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 {
// Use the exact same deserialization process as the UI (Toolbar.tsx handleKGStudioJSONImport)
- const deserializedResult = plainToInstance(KGProject, projectData)
+ 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
+ : deserializedResult;
if (!deserializedProject) {
- throw new Error("Failed to deserialize project data")
+ throw new Error("Failed to deserialize project data");
}
- return deserializedProject
+ return deserializedProject;
}
describe('abcNotationUtil - Integration Tests with Real Project Data', () => {
- let joyProject: KGProject
+ 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)
+ const fileContent = JSON.stringify(joyProjectData);
+ const projectData = JSON.parse(fileContent);
// Then deserialize using class-transformer (same as UI)
- joyProject = loadProjectFromJSON(projectData)
+ 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)
- })
+ } 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 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
+ 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)
+ const result = convertRegionToABCNotation(melodyRegion, 0, 32);
// Expected ABC notation output (exactly as provided)
const expectedABCNotation = `X:1
@@ -65,161 +65,161 @@ 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 |`
+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)
- })
+ 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 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
+ 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)
+ 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
- })
+ 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)
+ 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)
+ 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
- })
+ 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()
+ 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[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)
+ 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)
+ 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
- })
+ 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)
+ 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 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)
+ 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 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)
+ 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')
- })
+ 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)
+ expect(joyProject).toBeInstanceOf(KGProject);
- const tracks = joyProject.getTracks()
+ const tracks = joyProject.getTracks();
tracks.forEach(track => {
- expect(track).toBeInstanceOf(KGMidiTrack)
+ expect(track).toBeInstanceOf(KGMidiTrack);
- const regions = track.getRegions()
+ const regions = track.getRegions();
regions.forEach(region => {
- expect(region).toBeInstanceOf(KGMidiRegion)
+ expect(region).toBeInstanceOf(KGMidiRegion);
- const notes = (region as KGMidiRegion).getNotes()
+ 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')
- })
- })
- })
+ 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')
- })
- })
- })
+ 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
+ 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)
+ const result = convertRegionToABCNotation(melodyRegion, 0, 8);
- expect(result).toBeDefined()
- expect(result).toContain('T:Melody Region 1')
+ 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
- })
+ 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
+ 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)
+ const result = convertRegionToABCNotation(melodyRegion, 16, 24);
- expect(result).toBeDefined()
- expect(result).toContain('T:Melody Region 1')
+ 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
+ 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
diff --git a/src/test/mocks/audio-interface.ts b/src/test/mocks/audio-interface.ts
index e780d81..29f822d 100644
--- a/src/test/mocks/audio-interface.ts
+++ b/src/test/mocks/audio-interface.ts
@@ -2,7 +2,7 @@
* Mock implementation of KGAudioInterface for integration tests
* Provides interface compatibility while avoiding actual audio operations
*/
-import { vi } from 'vitest'
+import { vi } from 'vitest';
export const mockAudioInterface = {
// Audio context management
@@ -34,7 +34,7 @@ export const mockAudioInterface = {
// Singleton pattern
getInstance: vi.fn().mockReturnThis(),
-}
+};
// Mock the class constructor
-export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface)
\ No newline at end of file
+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
index dafdec1..606a102 100644
--- a/src/test/mocks/indexed-db.ts
+++ b/src/test/mocks/indexed-db.ts
@@ -2,54 +2,54 @@
* Mock implementation of IndexedDB for integration tests
* Provides in-memory storage that mimics IndexedDB interface
*/
-import { vi } from 'vitest'
+import { vi } from 'vitest';
// In-memory storage for tests
-const mockStorage = new Map()
+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)
+ 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}`))
+ return Promise.resolve(mockStorage.get(`${storeName}:${key}`));
}),
getAll: vi.fn().mockImplementation((storeName: string) => {
- const results: any[] = []
+ const results: any[] = [];
for (const [key, value] of mockStorage.entries()) {
if (key.startsWith(`${storeName}:`)) {
- results.push(value)
+ results.push(value);
}
}
- return Promise.resolve(results)
+ return Promise.resolve(results);
}),
delete: vi.fn().mockImplementation((storeName: string, key: string) => {
- mockStorage.delete(`${storeName}:${key}`)
- return Promise.resolve()
+ 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)
+ mockStorage.delete(key);
}
}
- return Promise.resolve()
+ 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
+ mockStorage.clear();
+};
\ No newline at end of file
diff --git a/src/test/mocks/tone-js.ts b/src/test/mocks/tone-js.ts
index 4ca06a6..13f50b5 100644
--- a/src/test/mocks/tone-js.ts
+++ b/src/test/mocks/tone-js.ts
@@ -2,7 +2,7 @@
* Mock implementation of Tone.js for integration tests
* Provides interface compatibility while avoiding actual audio operations
*/
-import { vi } from 'vitest'
+import { vi } from 'vitest';
// Mock Sampler class
export const mockSampler = {
@@ -16,7 +16,7 @@ export const mockSampler = {
disconnect: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
get: vi.fn().mockReturnValue({}),
-}
+};
// Mock Transport
export const mockTransport = {
@@ -30,7 +30,7 @@ export const mockTransport = {
schedule: vi.fn(),
clear: vi.fn(),
cancel: vi.fn(),
-}
+};
// Mock Tone namespace
export const mockTone = {
@@ -55,4 +55,4 @@ export const mockTone = {
state: 'running',
resume: vi.fn().mockResolvedValue(undefined),
},
-}
\ No newline at end of file
+};
\ No newline at end of file
diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts
index 53a7e27..94fd3b2 100644
--- a/src/test/mocks/tone.ts
+++ b/src/test/mocks/tone.ts
@@ -1,4 +1,4 @@
-import { vi } from 'vitest'
+import { vi } from 'vitest';
/**
* Mock implementation of Tone.js for testing
@@ -18,7 +18,7 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
connect: vi.fn(),
disconnect: vi.fn(),
toDestination: vi.fn()
-}))
+}));
// Mock Transport object
export const MockTransport = {
@@ -42,14 +42,14 @@ export const MockTransport = {
loop: false,
PPQ: 192,
getTicksAtTime: vi.fn().mockImplementation((time: number) => time * 192)
-}
+};
export const MockLoop = vi.fn().mockImplementation((callback: (time: number) => void, interval: string) => ({
callback,
interval,
start: vi.fn(),
dispose: vi.fn()
-}))
+}));
// Mock Destination
export const MockDestination = {
@@ -59,7 +59,7 @@ export const MockDestination = {
mute: false,
connect: vi.fn(),
disconnect: vi.fn()
-}
+};
// Mock ToneAudioBuffer
export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
@@ -68,7 +68,7 @@ export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
get: vi.fn(),
set: vi.fn(),
load: vi.fn().mockResolvedValue(undefined)
-}))
+}));
// Mock Gain node
export const MockGain = vi.fn().mockImplementation(() => ({
@@ -81,7 +81,7 @@ export const MockGain = vi.fn().mockImplementation(() => ({
connect: vi.fn(),
disconnect: vi.fn(),
dispose: vi.fn()
-}))
+}));
// Mock Meter
export const MockMeter = vi.fn().mockImplementation(() => ({
@@ -89,7 +89,7 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
connect: vi.fn(),
disconnect: vi.fn(),
dispose: vi.fn()
-}))
+}));
// Complete Tone.js mock
export const ToneMock = {
@@ -110,10 +110,10 @@ export const ToneMock = {
close: vi.fn().mockResolvedValue(undefined),
lookAhead: 0.05,
setTimeout: vi.fn().mockImplementation((fn: () => void, timeoutSeconds: number) => {
- return window.setTimeout(fn, timeoutSeconds * 1000)
+ return window.setTimeout(fn, timeoutSeconds * 1000);
}),
clearTimeout: vi.fn().mockImplementation((id: number) => {
- window.clearTimeout(id)
+ window.clearTimeout(id);
})
}),
@@ -129,9 +129,9 @@ export const ToneMock = {
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
})),
now: vi.fn().mockImplementation(() => Date.now() / 1000)
-}
+};
// Setup the global mock
export const setupToneMocks = () => {
- vi.doMock('tone', () => ToneMock)
-}
+ vi.doMock('tone', () => ToneMock);
+};
diff --git a/src/test/setup.ts b/src/test/setup.ts
index d46bc83..d1aba87 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1,13 +1,13 @@
-import '@testing-library/jest-dom'
-import 'reflect-metadata' // Required for class-transformer decorators
-import { beforeAll, afterEach, vi } from 'vitest'
-import { cleanup } from '@testing-library/react'
+import '@testing-library/jest-dom';
+import 'reflect-metadata'; // Required for class-transformer decorators
+import { beforeAll, afterEach, vi } from 'vitest';
+import { cleanup } from '@testing-library/react';
// Import our custom Tone.js mocks
-import { setupToneMocks } from './mocks/tone'
+import { setupToneMocks } from './mocks/tone';
// Setup global mocks
-setupToneMocks()
+setupToneMocks();
// Mock KGCore globally to prevent store initialization issues
vi.mock('../core/KGCore', () => ({
@@ -24,22 +24,22 @@ vi.mock('../core/KGCore', () => ({
executeCommand: vi.fn()
})
}
-}))
+}));
// Global test setup for all unit tests
// Clean up after each test
afterEach(() => {
- cleanup()
- vi.clearAllMocks()
-})
+ cleanup();
+ vi.clearAllMocks();
+});
// Setup before all tests
beforeAll(() => {
// Mock console methods to reduce noise in tests
- vi.spyOn(console, 'log').mockImplementation(() => {})
- vi.spyOn(console, 'warn').mockImplementation(() => {})
- vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock window.matchMedia (needed for some UI components)
Object.defineProperty(window, 'matchMedia', {
@@ -54,16 +54,16 @@ beforeAll(() => {
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
- })
+ });
// Mock ResizeObserver (might be needed for some components)
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
- }))
+ }));
// Mock URL.createObjectURL (might be needed for file operations)
- global.URL.createObjectURL = vi.fn(() => 'mocked-url')
- global.URL.revokeObjectURL = vi.fn()
-})
\ No newline at end of file
+ global.URL.createObjectURL = vi.fn(() => 'mocked-url');
+ global.URL.revokeObjectURL = vi.fn();
+});
\ No newline at end of file
diff --git a/src/test/utils/mock-data.ts b/src/test/utils/mock-data.ts
index 4b0adcc..eedfcf5 100644
--- a/src/test/utils/mock-data.ts
+++ b/src/test/utils/mock-data.ts
@@ -1,7 +1,7 @@
-import { KGMidiNote } from '../../core/midi/KGMidiNote'
-import { KGProject } from '../../core/KGProject'
-import { KGMidiTrack } from '../../core/track/KGMidiTrack'
-import { KGMidiRegion } from '../../core/region/KGMidiRegion'
+import { KGMidiNote } from '../../core/midi/KGMidiNote';
+import { KGProject } from '../../core/KGProject';
+import { KGMidiTrack } from '../../core/track/KGMidiTrack';
+import { KGMidiRegion } from '../../core/region/KGMidiRegion';
/**
* Test data factories for creating mock objects
@@ -22,7 +22,7 @@ export const createMockMidiNote = (overrides: Partial<{
pitch: 60, // Middle C
velocity: 80,
...overrides
- }
+ };
return new KGMidiNote(
defaults.id,
@@ -30,8 +30,8 @@ export const createMockMidiNote = (overrides: Partial<{
defaults.endBeat,
defaults.pitch,
defaults.velocity
- )
-}
+ );
+};
export const createMockMidiRegion = (overrides: Partial<{
id: string
@@ -50,7 +50,7 @@ export const createMockMidiRegion = (overrides: Partial<{
startFromBeat: 0,
length: 4,
...overrides
- }
+ };
const region = new KGMidiRegion(
defaults.id,
@@ -59,15 +59,15 @@ export const createMockMidiRegion = (overrides: Partial<{
defaults.name,
defaults.startFromBeat,
defaults.length
- )
+ );
// Add notes if provided
if (overrides.notes) {
- overrides.notes.forEach(note => region.addNote(note))
+ overrides.notes.forEach(note => region.addNote(note));
}
- return region
-}
+ return region;
+};
export const createMockMidiTrack = (overrides: Partial<{
name: string
@@ -82,22 +82,22 @@ export const createMockMidiTrack = (overrides: Partial<{
instrument: 'acoustic_grand_piano' as const,
volume: 0.8,
...overrides
- }
+ };
const track = new KGMidiTrack(
defaults.name,
defaults.id,
defaults.instrument as keyof typeof import('../../constants/generalMidiConstants').FLUIDR3_INSTRUMENT_MAP,
defaults.volume
- )
+ );
// Add regions if provided
if (overrides.regions) {
- track.setRegions(overrides.regions)
+ track.setRegions(overrides.regions);
}
- return track
-}
+ return track;
+};
export const createMockProject = (overrides: Partial<{
name: string
@@ -111,7 +111,7 @@ export const createMockProject = (overrides: Partial<{
timeSignature: { numerator: 4, denominator: 4 },
tracks: [],
...overrides
- }
+ };
const project = new KGProject(
defaults.name,
@@ -126,34 +126,34 @@ export const createMockProject = (overrides: Partial<{
1, // barWidthMultiplier
defaults.tracks, // tracks
5 // projectStructureVersion
- )
+ );
- return project
-}
+ return project;
+};
// Common test scenarios
export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMidiTrack; region: KGMidiRegion } => {
const notes = [
createMockMidiNote({ pitch: 60, startBeat: 0, endBeat: 1 }),
createMockMidiNote({ pitch: 64, startBeat: 1, endBeat: 2 }),
- ]
+ ];
const region = createMockMidiRegion({
id: 'region-1',
trackId: 'track-1',
notes
- })
+ });
const track = createMockMidiTrack({
id: 1,
name: 'Track 1',
regions: [region]
- })
+ });
const project = createMockProject({
name: 'Basic Test Project',
tracks: [track]
- })
+ });
- return { project, track, region }
-}
\ No newline at end of file
+ return { project, track, region };
+};
\ No newline at end of file
diff --git a/src/test/utils/setup-integration-tests.ts b/src/test/utils/setup-integration-tests.ts
index dac8018..5a60f65 100644
--- a/src/test/utils/setup-integration-tests.ts
+++ b/src/test/utils/setup-integration-tests.ts
@@ -2,43 +2,43 @@
* 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'
-import { KGCore } from '../../core/KGCore'
-import { KGProject } from '../../core/KGProject'
+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';
+import { KGCore } from '../../core/KGCore';
+import { KGProject } from '../../core/KGProject';
// Initialize KGCore with a default project at module load time
// This prevents errors when projectStore module initializes and tries to access currentProject
// This runs synchronously when the module is imported, before any tests
const initializeKGCore = async () => {
- const defaultProject = new KGProject('Test Setup Project')
- const core = KGCore.instance()
- await core.initialize()
- core.setCurrentProject(defaultProject)
-}
+ const defaultProject = new KGProject('Test Setup Project');
+ const core = KGCore.instance();
+ await core.initialize();
+ core.setCurrentProject(defaultProject);
+};
// Run initialization immediately at module load
-await initializeKGCore()
+await initializeKGCore();
// Global setup for integration tests
beforeEach(() => {
// Clear all mocks
- vi.clearAllMocks()
+ vi.clearAllMocks();
// Clear mock storage
- clearMockStorage()
+ clearMockStorage();
// Mock external dependencies while keeping internal components real
vi.doMock('../../audio/KGAudioInterface', () => ({
KGAudioInterface: mockAudioInterface,
default: mockAudioInterface,
- }))
+ }));
- vi.doMock('idb', () => mockIndexedDB)
+ vi.doMock('idb', () => mockIndexedDB);
- vi.doMock('tone', () => mockTone)
+ vi.doMock('tone', () => mockTone);
// Mock browser APIs that might be used
Object.defineProperty(window, 'AudioContext', {
@@ -47,19 +47,19 @@ beforeEach(() => {
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()
-})
+ vi.restoreAllMocks();
+ clearMockStorage();
+});
// Helper functions for integration tests
export const createMockProject = () => {
@@ -73,8 +73,8 @@ export const createMockProject = () => {
keySignature: 'C major',
maxBars: 32,
tracks: [],
- }
-}
+ };
+};
export const createMockTrack = (name = 'Test Track') => {
return {
@@ -83,8 +83,8 @@ export const createMockTrack = (name = 'Test Track') => {
instrument: 'acoustic_grand_piano',
volume: 0.8,
regions: [],
- }
-}
+ };
+};
export const createMockRegion = (name = 'Test Region') => {
return {
@@ -93,8 +93,8 @@ export const createMockRegion = (name = 'Test Region') => {
startBeat: 0,
endBeat: 4,
notes: [],
- }
-}
+ };
+};
export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
return {
@@ -103,5 +103,5 @@ export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
startBeat,
endBeat,
velocity: 100,
- }
-}
\ No newline at end of file
+ };
+};
\ No newline at end of file
diff --git a/src/test/utils/test-utils.tsx b/src/test/utils/test-utils.tsx
index a1d5880..572d192 100644
--- a/src/test/utils/test-utils.tsx
+++ b/src/test/utils/test-utils.tsx
@@ -1,5 +1,5 @@
-import type { ReactElement } from 'react'
-import { render, type RenderOptions } from '@testing-library/react'
+import type { ReactElement } from 'react';
+import { render, type RenderOptions } from '@testing-library/react';
// Custom render function that includes any providers your app needs
// This can be extended later with Zustand store providers, etc.
@@ -13,10 +13,10 @@ const customRender = (
// If you need to wrap components with providers (like Zustand store),
// you can create an AllTheProviders wrapper here
- return render(ui, options)
-}
+ return render(ui, options);
+};
// Re-export everything from testing-library/react
// eslint-disable-next-line react-refresh/only-export-components
-export * from '@testing-library/react'
-export { customRender as render }
\ No newline at end of file
+export * from '@testing-library/react';
+export { customRender as render };
\ No newline at end of file
diff --git a/src/util/midiUtil.test.ts b/src/util/midiUtil.test.ts
index 820b3c5..30139ea 100644
--- a/src/util/midiUtil.test.ts
+++ b/src/util/midiUtil.test.ts
@@ -1,186 +1,186 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect } from 'vitest';
import {
beatsToBar,
pitchToNoteNameString,
pitchToNoteName,
pianoRollIndexToPitch,
noteNameToPitch
-} from './midiUtil'
+} from './midiUtil';
describe('midiUtil', () => {
describe('beatsToBar', () => {
it('should convert beats to bar position object', () => {
- const result1 = beatsToBar(0, { numerator: 4, denominator: 4 })
- expect(result1.bar).toBe(0)
- expect(result1.beatInBar).toBe(0)
+ const result1 = beatsToBar(0, { numerator: 4, denominator: 4 });
+ expect(result1.bar).toBe(0);
+ expect(result1.beatInBar).toBe(0);
- const result2 = beatsToBar(4, { numerator: 4, denominator: 4 })
- expect(result2.bar).toBe(1)
- expect(result2.beatInBar).toBe(0)
+ const result2 = beatsToBar(4, { numerator: 4, denominator: 4 });
+ expect(result2.bar).toBe(1);
+ expect(result2.beatInBar).toBe(0);
- const result3 = beatsToBar(8, { numerator: 4, denominator: 4 })
- expect(result3.bar).toBe(2)
- expect(result3.beatInBar).toBe(0)
- })
+ const result3 = beatsToBar(8, { numerator: 4, denominator: 4 });
+ expect(result3.bar).toBe(2);
+ expect(result3.beatInBar).toBe(0);
+ });
it('should handle different time signatures', () => {
- const result1 = beatsToBar(0, { numerator: 3, denominator: 4 })
- expect(result1.bar).toBe(0)
- expect(result1.beatInBar).toBe(0)
+ const result1 = beatsToBar(0, { numerator: 3, denominator: 4 });
+ expect(result1.bar).toBe(0);
+ expect(result1.beatInBar).toBe(0);
- const result2 = beatsToBar(3, { numerator: 3, denominator: 4 })
- expect(result2.bar).toBe(1)
- expect(result2.beatInBar).toBe(0)
- })
+ const result2 = beatsToBar(3, { numerator: 3, denominator: 4 });
+ expect(result2.bar).toBe(1);
+ expect(result2.beatInBar).toBe(0);
+ });
it('should handle fractional beats', () => {
- const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 })
- expect(result1.bar).toBe(0)
- expect(result1.beatInBar).toBe(2.5)
+ const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 });
+ expect(result1.bar).toBe(0);
+ expect(result1.beatInBar).toBe(2.5);
- const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 })
- expect(result2.bar).toBe(1)
- expect(result2.beatInBar).toBe(0.5)
- })
- })
+ const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 });
+ expect(result2.bar).toBe(1);
+ expect(result2.beatInBar).toBe(0.5);
+ });
+ });
describe('pitchToNoteNameString', () => {
it('should convert MIDI pitch to note name with octave', () => {
- expect(pitchToNoteNameString(60)).toBe('C4') // Middle C
- expect(pitchToNoteNameString(61)).toBe('C#4') // C# above middle C
- expect(pitchToNoteNameString(59)).toBe('B3') // B below middle C
- expect(pitchToNoteNameString(72)).toBe('C5') // C one octave above middle C
- expect(pitchToNoteNameString(48)).toBe('C3') // C one octave below middle C
- })
+ expect(pitchToNoteNameString(60)).toBe('C4'); // Middle C
+ expect(pitchToNoteNameString(61)).toBe('C#4'); // C# above middle C
+ expect(pitchToNoteNameString(59)).toBe('B3'); // B below middle C
+ expect(pitchToNoteNameString(72)).toBe('C5'); // C one octave above middle C
+ expect(pitchToNoteNameString(48)).toBe('C3'); // C one octave below middle C
+ });
it('should handle edge cases', () => {
- expect(pitchToNoteNameString(0)).toBe('C-1') // Lowest MIDI note
- expect(pitchToNoteNameString(127)).toBe('G9') // Highest MIDI note
- })
+ expect(pitchToNoteNameString(0)).toBe('C-1'); // Lowest MIDI note
+ expect(pitchToNoteNameString(127)).toBe('G9'); // Highest MIDI note
+ });
it('should handle all chromatic notes', () => {
- const expectedNotes = ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4']
+ const expectedNotes = ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4'];
for (let i = 0; i < 12; i++) {
- expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i])
+ expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i]);
}
- })
- })
+ });
+ });
describe('pitchToNoteName', () => {
it('should convert pitch to note name object', () => {
- const result60 = pitchToNoteName(60) // Middle C
- expect(result60.note).toBe('C')
- expect(result60.octave).toBe(4)
+ const result60 = pitchToNoteName(60); // Middle C
+ expect(result60.note).toBe('C');
+ expect(result60.octave).toBe(4);
- const result61 = pitchToNoteName(61) // C#
- expect(result61.note).toBe('C#')
- expect(result61.octave).toBe(4)
- })
+ const result61 = pitchToNoteName(61); // C#
+ expect(result61.note).toBe('C#');
+ expect(result61.octave).toBe(4);
+ });
it('should wrap around for different octaves', () => {
- const result60 = pitchToNoteName(60)
- const result72 = pitchToNoteName(72)
- const result84 = pitchToNoteName(84)
+ const result60 = pitchToNoteName(60);
+ const result72 = pitchToNoteName(72);
+ const result84 = pitchToNoteName(84);
- expect(result60.note).toBe('C')
- expect(result72.note).toBe('C')
- expect(result84.note).toBe('C')
+ expect(result60.note).toBe('C');
+ expect(result72.note).toBe('C');
+ expect(result84.note).toBe('C');
- expect(result60.octave).toBe(4)
- expect(result72.octave).toBe(5)
- expect(result84.octave).toBe(6)
- })
- })
+ expect(result60.octave).toBe(4);
+ expect(result72.octave).toBe(5);
+ expect(result84.octave).toBe(6);
+ });
+ });
describe('pianoRollIndexToPitch', () => {
it('should convert piano roll row index to MIDI pitch', () => {
// This function likely maps visual rows to MIDI pitches
// The exact mapping depends on your implementation
- const result = pianoRollIndexToPitch(10)
- expect(typeof result).toBe('number')
- expect(result).toBeGreaterThanOrEqual(0)
- expect(result).toBeLessThanOrEqual(127)
- })
+ const result = pianoRollIndexToPitch(10);
+ expect(typeof result).toBe('number');
+ expect(result).toBeGreaterThanOrEqual(0);
+ expect(result).toBeLessThanOrEqual(127);
+ });
it('should return different pitches for different indices', () => {
- const pitch1 = pianoRollIndexToPitch(0)
- const pitch2 = pianoRollIndexToPitch(1)
- expect(pitch1).not.toBe(pitch2)
- })
- })
+ const pitch1 = pianoRollIndexToPitch(0);
+ const pitch2 = pianoRollIndexToPitch(1);
+ expect(pitch1).not.toBe(pitch2);
+ });
+ });
describe('noteNameToPitch', () => {
it('should convert note names to MIDI pitch', () => {
- expect(noteNameToPitch('C4')).toBe(60) // Middle C
- expect(noteNameToPitch('C#4')).toBe(61) // C# above middle C
- expect(noteNameToPitch('D4')).toBe(62) // D above middle C
- })
+ expect(noteNameToPitch('C4')).toBe(60); // Middle C
+ expect(noteNameToPitch('C#4')).toBe(61); // C# above middle C
+ expect(noteNameToPitch('D4')).toBe(62); // D above middle C
+ });
it('should handle different octaves', () => {
- expect(noteNameToPitch('C3')).toBe(48) // C below middle C
- expect(noteNameToPitch('C5')).toBe(72) // C above middle C
- })
+ expect(noteNameToPitch('C3')).toBe(48); // C below middle C
+ expect(noteNameToPitch('C5')).toBe(72); // C above middle C
+ });
it('should handle sharps', () => {
- expect(noteNameToPitch('C#4')).toBe(61)
- expect(noteNameToPitch('F#4')).toBe(66)
- expect(noteNameToPitch('G#4')).toBe(68)
- })
+ expect(noteNameToPitch('C#4')).toBe(61);
+ expect(noteNameToPitch('F#4')).toBe(66);
+ expect(noteNameToPitch('G#4')).toBe(68);
+ });
it('should handle invalid note names', () => {
- expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4') // Flats not supported
- expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4') // Invalid note
- expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C') // Missing octave
- })
- })
+ expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4'); // Flats not supported
+ expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4'); // Invalid note
+ expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C'); // Missing octave
+ });
+ });
describe('edge cases and error handling', () => {
it('should handle negative values gracefully', () => {
- expect(() => pitchToNoteNameString(-1)).not.toThrow()
- expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow()
- })
+ expect(() => pitchToNoteNameString(-1)).not.toThrow();
+ expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow();
+ });
it('should handle very large values', () => {
- expect(() => pitchToNoteNameString(200)).not.toThrow()
- expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow()
- })
+ expect(() => pitchToNoteNameString(200)).not.toThrow();
+ expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow();
+ });
it('should handle zero values', () => {
- expect(pitchToNoteNameString(0)).toBeDefined()
- expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined()
- })
- })
+ expect(pitchToNoteNameString(0)).toBeDefined();
+ expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined();
+ });
+ });
describe('mathematical consistency', () => {
it('should maintain pitch relationships', () => {
// One octave = 12 semitones - note names should be the same
- const baseNote = pitchToNoteName(60)
- const octaveNote = pitchToNoteName(72)
- expect(baseNote.note).toBe(octaveNote.note) // Both should be 'C'
- expect(octaveNote.octave).toBe(baseNote.octave + 1) // Octave should be one higher
- })
+ const baseNote = pitchToNoteName(60);
+ const octaveNote = pitchToNoteName(72);
+ expect(baseNote.note).toBe(octaveNote.note); // Both should be 'C'
+ expect(octaveNote.octave).toBe(baseNote.octave + 1); // Octave should be one higher
+ });
it('should maintain beat-to-bar relationships', () => {
- const timeSignature = { numerator: 4, denominator: 4 }
+ const timeSignature = { numerator: 4, denominator: 4 };
// Should increment bar by 1 for each complete measure
for (let beat = 0; beat < 20; beat += 4) {
- const expectedBar = Math.floor(beat / 4)
- const result = beatsToBar(beat, timeSignature)
- expect(result.bar).toBe(expectedBar)
- expect(result.beatInBar).toBe(0) // Should be at start of bar
+ const expectedBar = Math.floor(beat / 4);
+ const result = beatsToBar(beat, timeSignature);
+ expect(result.bar).toBe(expectedBar);
+ expect(result.beatInBar).toBe(0); // Should be at start of bar
}
- })
+ });
it('should maintain note name to pitch conversion consistency', () => {
// Converting pitch to note name and back should be consistent
- const originalPitch = 60
- const noteObj = pitchToNoteName(originalPitch)
- const noteName = `${noteObj.note}${noteObj.octave}`
- const convertedPitch = noteNameToPitch(noteName)
+ const originalPitch = 60;
+ const noteObj = pitchToNoteName(originalPitch);
+ const noteName = `${noteObj.note}${noteObj.octave}`;
+ const convertedPitch = noteNameToPitch(noteName);
- expect(convertedPitch).toBe(originalPitch)
- })
- })
-})
\ No newline at end of file
+ expect(convertedPitch).toBe(originalPitch);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/util/scaleUtil.test.ts b/src/util/scaleUtil.test.ts
index ce25248..baa6852 100644
--- a/src/util/scaleUtil.test.ts
+++ b/src/util/scaleUtil.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
getRootNoteFromKeySignature,
noteNameToPitchClass,
@@ -9,208 +9,208 @@ import {
getMatchingChordsForPitch,
generatePianoGridBackground,
validateFunctionalChordsJSON
-} from './scaleUtil'
-import { KGCore } from '../core/KGCore'
-import type { KeySignature } from '../core/KGProject'
-import functionalChordsData from '../../public/resources/modes/functional_chords.json'
+} from './scaleUtil';
+import { KGCore } from '../core/KGCore';
+import type { KeySignature } from '../core/KGProject';
+import functionalChordsData from '../../public/resources/modes/functional_chords.json';
describe('scaleUtil', () => {
// Setup: Mock KGCore with real chord data
beforeEach(() => {
// Use real functional chords data from JSON file (includes name, steps, and chord data)
- KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData
- })
+ KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData;
+ });
describe('getRootNoteFromKeySignature', () => {
it('should extract root note from C major', () => {
- expect(getRootNoteFromKeySignature('C major')).toBe('C')
- })
+ expect(getRootNoteFromKeySignature('C major')).toBe('C');
+ });
it('should extract root note from F# minor', () => {
- expect(getRootNoteFromKeySignature('F# minor')).toBe('F#')
- })
+ expect(getRootNoteFromKeySignature('F# minor')).toBe('F#');
+ });
it('should extract root note from Bb major', () => {
- expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb')
- })
+ expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb');
+ });
it('should handle Db major', () => {
- expect(getRootNoteFromKeySignature('Db major')).toBe('Db')
- })
+ expect(getRootNoteFromKeySignature('Db major')).toBe('Db');
+ });
it('should default to C for invalid format', () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
- expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C')
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format'))
- warnSpy.mockRestore()
- })
- })
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C');
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format'));
+ warnSpy.mockRestore();
+ });
+ });
describe('noteNameToPitchClass', () => {
it('should convert C to 0', () => {
- expect(noteNameToPitchClass('C')).toBe(0)
- })
+ expect(noteNameToPitchClass('C')).toBe(0);
+ });
it('should convert C# to 1', () => {
- expect(noteNameToPitchClass('C#')).toBe(1)
- })
+ expect(noteNameToPitchClass('C#')).toBe(1);
+ });
it('should convert Db to 1', () => {
- expect(noteNameToPitchClass('Db')).toBe(1)
- })
+ expect(noteNameToPitchClass('Db')).toBe(1);
+ });
it('should convert D to 2', () => {
- expect(noteNameToPitchClass('D')).toBe(2)
- })
+ expect(noteNameToPitchClass('D')).toBe(2);
+ });
it('should convert E to 4', () => {
- expect(noteNameToPitchClass('E')).toBe(4)
- })
+ expect(noteNameToPitchClass('E')).toBe(4);
+ });
it('should convert F to 5', () => {
- expect(noteNameToPitchClass('F')).toBe(5)
- })
+ expect(noteNameToPitchClass('F')).toBe(5);
+ });
it('should convert F# to 6', () => {
- expect(noteNameToPitchClass('F#')).toBe(6)
- })
+ expect(noteNameToPitchClass('F#')).toBe(6);
+ });
it('should convert G to 7', () => {
- expect(noteNameToPitchClass('G')).toBe(7)
- })
+ expect(noteNameToPitchClass('G')).toBe(7);
+ });
it('should convert A to 9', () => {
- expect(noteNameToPitchClass('A')).toBe(9)
- })
+ expect(noteNameToPitchClass('A')).toBe(9);
+ });
it('should convert Bb to 10', () => {
- expect(noteNameToPitchClass('Bb')).toBe(10)
- })
+ expect(noteNameToPitchClass('Bb')).toBe(10);
+ });
it('should convert B to 11', () => {
- expect(noteNameToPitchClass('B')).toBe(11)
- })
+ expect(noteNameToPitchClass('B')).toBe(11);
+ });
it('should default to 0 for invalid note', () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
- expect(noteNameToPitchClass('X')).toBe(0)
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name'))
- warnSpy.mockRestore()
- })
- })
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ expect(noteNameToPitchClass('X')).toBe(0);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name'));
+ warnSpy.mockRestore();
+ });
+ });
describe('getModeSteps', () => {
it('should return ionian steps', () => {
- expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1])
- })
+ expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1]);
+ });
it('should return dorian steps', () => {
- expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2])
- })
+ expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2]);
+ });
it('should default to ionian for invalid mode', () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
- expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1])
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found'))
- warnSpy.mockRestore()
- })
- })
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1]);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found'));
+ warnSpy.mockRestore();
+ });
+ });
describe('getScalePitchClasses', () => {
it('should return C major scale pitch classes', () => {
- const steps = [2, 2, 1, 2, 2, 2, 1]
- const result = getScalePitchClasses('C', steps)
- expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]) // C D E F G A B
- })
+ const steps = [2, 2, 1, 2, 2, 2, 1];
+ const result = getScalePitchClasses('C', steps);
+ expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]); // C D E F G A B
+ });
it('should return D major scale pitch classes', () => {
- const steps = [2, 2, 1, 2, 2, 2, 1]
- const result = getScalePitchClasses('D', steps)
- expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]) // D E F# G A B C#
- })
+ const steps = [2, 2, 1, 2, 2, 2, 1];
+ const result = getScalePitchClasses('D', steps);
+ expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]); // D E F# G A B C#
+ });
it('should return F# dorian scale pitch classes', () => {
- const steps = [2, 1, 2, 2, 2, 1, 2]
- const result = getScalePitchClasses('F#', steps)
- expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]) // F# G# A B C# D# E
- })
- })
+ const steps = [2, 1, 2, 2, 2, 1, 2];
+ const result = getScalePitchClasses('F#', steps);
+ expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]); // F# G# A B C# D# E
+ });
+ });
describe('getSuitableChords', () => {
it('should return tonic chords for C major ionian', () => {
- const result = getSuitableChords('C major', 'ionian', 'T')
- expect(result).toHaveProperty('I')
- expect(result).toHaveProperty('vi')
- expect(result).toHaveProperty('iii')
- expect(result['I']).toEqual(['C', 'E', 'G'])
- expect(result['vi']).toEqual(['A', 'C', 'E'])
- })
+ const result = getSuitableChords('C major', 'ionian', 'T');
+ expect(result).toHaveProperty('I');
+ expect(result).toHaveProperty('vi');
+ expect(result).toHaveProperty('iii');
+ expect(result['I']).toEqual(['C', 'E', 'G']);
+ expect(result['vi']).toEqual(['A', 'C', 'E']);
+ });
it('should return subdominant chords for C major ionian', () => {
- const result = getSuitableChords('C major', 'ionian', 'S')
- expect(result).toHaveProperty('IV')
- expect(result).toHaveProperty('ii')
- expect(result['IV']).toEqual(['F', 'A', 'C'])
- expect(result['ii']).toEqual(['D', 'F', 'A'])
- })
+ const result = getSuitableChords('C major', 'ionian', 'S');
+ expect(result).toHaveProperty('IV');
+ expect(result).toHaveProperty('ii');
+ expect(result['IV']).toEqual(['F', 'A', 'C']);
+ expect(result['ii']).toEqual(['D', 'F', 'A']);
+ });
it('should return dominant chords for C major ionian', () => {
- const result = getSuitableChords('C major', 'ionian', 'D')
- expect(result).toHaveProperty('V')
- expect(result).toHaveProperty('V7')
- expect(result['V']).toEqual(['G', 'B', 'D'])
- expect(result['V7']).toEqual(['G', 'B', 'D', 'F'])
- })
+ const result = getSuitableChords('C major', 'ionian', 'D');
+ expect(result).toHaveProperty('V');
+ expect(result).toHaveProperty('V7');
+ expect(result['V']).toEqual(['G', 'B', 'D']);
+ expect(result['V7']).toEqual(['G', 'B', 'D', 'F']);
+ });
it('should transpose chords for D major ionian', () => {
- const result = getSuitableChords('D major', 'ionian', 'T')
- expect(result['I']).toEqual(['D', 'F#', 'A'])
- expect(result['vi']).toEqual(['B', 'D', 'F#'])
- })
+ const result = getSuitableChords('D major', 'ionian', 'T');
+ expect(result['I']).toEqual(['D', 'F#', 'A']);
+ expect(result['vi']).toEqual(['B', 'D', 'F#']);
+ });
it('should transpose chords for F# major ionian', () => {
- const result = getSuitableChords('F# major', 'ionian', 'T')
- expect(result['I']).toEqual(['F#', 'A#', 'C#'])
- })
+ const result = getSuitableChords('F# major', 'ionian', 'T');
+ expect(result['I']).toEqual(['F#', 'A#', 'C#']);
+ });
it('should return empty object for invalid mode', () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const result = getSuitableChords('C major', 'invalid', 'T')
- expect(result).toEqual({})
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found'))
- warnSpy.mockRestore()
- })
- })
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const result = getSuitableChords('C major', 'invalid', 'T');
+ expect(result).toEqual({});
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found'));
+ warnSpy.mockRestore();
+ });
+ });
describe('getChordNotesInKey', () => {
it('should return I chord notes in C major ionian', () => {
- const result = getChordNotesInKey('I', 'C major', 'ionian')
- expect(result).toEqual(['C', 'E', 'G'])
- })
+ const result = getChordNotesInKey('I', 'C major', 'ionian');
+ expect(result).toEqual(['C', 'E', 'G']);
+ });
it('should return V7 chord notes in C major ionian', () => {
- const result = getChordNotesInKey('V7', 'C major', 'ionian')
- expect(result).toEqual(['G', 'B', 'D', 'F'])
- })
+ const result = getChordNotesInKey('V7', 'C major', 'ionian');
+ expect(result).toEqual(['G', 'B', 'D', 'F']);
+ });
it('should transpose to D major', () => {
- const result = getChordNotesInKey('I', 'D major', 'ionian')
- expect(result).toEqual(['D', 'F#', 'A'])
- })
+ const result = getChordNotesInKey('I', 'D major', 'ionian');
+ expect(result).toEqual(['D', 'F#', 'A']);
+ });
it('should return empty array for invalid chord symbol', () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const result = getChordNotesInKey('invalid', 'C major', 'ionian')
- expect(result).toEqual([])
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found'))
- warnSpy.mockRestore()
- })
- })
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const result = getChordNotesInKey('invalid', 'C major', 'ionian');
+ expect(result).toEqual([]);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found'));
+ warnSpy.mockRestore();
+ });
+ });
describe('getMatchingChordsForPitch', () => {
describe('valid inputs', () => {
it('should return matching chords for C (pitch 60) in C major ionian tonic', () => {
- const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
+ const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
// C major ionian T chords from JSON: I ["C","E","G"], vi ["A","C","E"], iii ["E","G","B"], I⁶ ["E","G","C"]
// Pitch 60 = C (pitch class 0)
@@ -222,11 +222,11 @@ describe('scaleUtil', () => {
[0, 4, 7], // I chord (C-E-G): C is root
[-3, 0, 4], // vi chord (A-C-E): C is 2nd, offset applied
[-8, -5, 0] // I⁶ chord (E-G-C): C is 3rd, offset applied
- ])
- })
+ ]);
+ });
it('should return matching chords for E (pitch 64) in C major ionian tonic', () => {
- const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T')
+ const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T');
// Pitch 64 = E (pitch class 4)
// Expected matches prioritized by position:
@@ -239,19 +239,19 @@ describe('scaleUtil', () => {
[4, 7, 12], // I⁶ chord (E-G-C): E is root
[0, 4, 7], // I chord (C-E-G): E is 2nd
[-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied
- ])
- })
+ ]);
+ });
it('should prioritize root matches over other positions', () => {
- const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') // C
+ const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // C
// First chord should have C as root (position 0)
if (result.length > 0) {
- expect(result[0][0] % 12).toBe(0) // First note of first chord should be C
+ expect(result[0][0] % 12).toBe(0); // First note of first chord should be C
}
- })
+ });
it('should return matching chords for F (pitch 65) in C major ionian subdominant', () => {
- const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S') // F
+ const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S'); // F
// Pitch 65 = F (pitch class 5)
// C major ionian S chords from JSON: IV ["F","A","C"], ii ["D","F","A"], vi ["A","C","E"], IV⁶ ["A","C","F"]
@@ -263,11 +263,11 @@ describe('scaleUtil', () => {
[5, 9, 12], // IV chord (F-A-C): F is root, offset applied
[2, 5, 9], // ii chord (D-F-A): F is 2nd
[-3, 0, 5] // IV⁶ chord (A-C-F): F is 3rd, offset applied
- ])
- })
+ ]);
+ });
it('should return matching chords for G (pitch 67) in C major ionian dominant', () => {
- const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D') // G
+ const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D'); // G
// Pitch 67 = G (pitch class 7)
// C major ionian D chords from JSON: V ["G","B","D"], V7 ["G","B","D","F"], vii° ["B","D","F"], ♭II ["Db","F","Ab"]
@@ -277,122 +277,122 @@ describe('scaleUtil', () => {
expect(result).toEqual([
[7, 11, 14], // V chord (G-B-D): G is root, offset applied
[7, 11, 14, 17] // V7 chord (G-B-D-F): G is root, offset applied
- ])
- })
+ ]);
+ });
it('should work with different modes (dorian)', () => {
- const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T')
- expect(result.length).toBeGreaterThan(0)
- })
+ const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T');
+ expect(result.length).toBeGreaterThan(0);
+ });
it('should transpose correctly for D major', () => {
- const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T') // D
- expect(result.length).toBeGreaterThan(0)
- expect(result.some(chord => chord.includes(2))).toBe(true) // Contains D (pitch class 2)
- })
+ const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T'); // D
+ expect(result.length).toBeGreaterThan(0);
+ expect(result.some(chord => chord.includes(2))).toBe(true); // Contains D (pitch class 2)
+ });
it('should handle all MIDI pitch ranges (low)', () => {
- const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T') // C1
- expect(Array.isArray(result)).toBe(true)
- })
+ const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T'); // C1
+ expect(Array.isArray(result)).toBe(true);
+ });
it('should handle all MIDI pitch ranges (high)', () => {
- const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T') // C8
- expect(Array.isArray(result)).toBe(true)
- })
- })
+ const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T'); // C8
+ expect(Array.isArray(result)).toBe(true);
+ });
+ });
describe('edge cases', () => {
it('should return empty array for invalid mode', () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T')
- expect(result).toEqual([])
- warnSpy.mockRestore()
- })
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T');
+ expect(result).toEqual([]);
+ warnSpy.mockRestore();
+ });
it('should handle pitch class calculations correctly', () => {
// Test that pitch 60 (C4) and pitch 72 (C5) both match C chords
- const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
- const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T')
- expect(result1.length).toBe(result2.length) // Same chords match
- })
+ const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
+ const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T');
+ expect(result1.length).toBe(result2.length); // Same chords match
+ });
it('should return pitch classes in ascending order', () => {
- const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
+ const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
result.forEach(chord => {
for (let i = 1; i < chord.length; i++) {
- expect(chord[i]).toBeGreaterThan(chord[i - 1])
+ expect(chord[i]).toBeGreaterThan(chord[i - 1]);
}
- })
- })
+ });
+ });
it('should apply octave offset correctly for pitch classes >= 12', () => {
- const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
+ const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
// All pitch classes should be < 12 after offset
result.forEach(chord => {
chord.forEach(pitchClass => {
- expect(pitchClass).toBeLessThan(24) // Allowing for extended range
- })
- })
- })
- })
+ expect(pitchClass).toBeLessThan(24); // Allowing for extended range
+ });
+ });
+ });
+ });
describe('pitch class calculations', () => {
it('should correctly convert hover pitch to pitch class', () => {
// C4 (60) should match same chords as C5 (72)
- const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
- const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T')
- expect(result1).toEqual(result2)
- })
+ const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
+ const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T');
+ expect(result1).toEqual(result2);
+ });
it('should maintain ascending pitch order in results', () => {
- const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T')
+ const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T');
result.forEach(chord => {
for (let i = 1; i < chord.length; i++) {
- expect(chord[i]).toBeGreaterThan(chord[i - 1])
+ expect(chord[i]).toBeGreaterThan(chord[i - 1]);
}
- })
- })
- })
+ });
+ });
+ });
describe('integration with helper functions', () => {
it('should work with getSuitableChords', () => {
- const suitableChords = getSuitableChords('C major', 'ionian', 'T')
- expect(Object.keys(suitableChords).length).toBeGreaterThan(0)
+ const suitableChords = getSuitableChords('C major', 'ionian', 'T');
+ expect(Object.keys(suitableChords).length).toBeGreaterThan(0);
- const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
- expect(matchingChords.length).toBeGreaterThan(0)
- })
+ const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
+ expect(matchingChords.length).toBeGreaterThan(0);
+ });
it('should work with noteNameToPitchClass', () => {
- const cPitch = noteNameToPitchClass('C')
- const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
- expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true)
- })
+ const cPitch = noteNameToPitchClass('C');
+ const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
+ expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true);
+ });
it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => {
// Verify the data has the expected structure
- const data = KGCore.FUNCTIONAL_CHORDS_DATA
- expect(data).toHaveProperty('ionian')
- expect(data['ionian']).toHaveProperty('T')
- expect(data['ionian']).toHaveProperty('chords')
- })
- })
- })
+ const data = KGCore.FUNCTIONAL_CHORDS_DATA;
+ expect(data).toHaveProperty('ionian');
+ expect(data['ionian']).toHaveProperty('T');
+ expect(data['ionian']).toHaveProperty('chords');
+ });
+ });
+ });
describe('generatePianoGridBackground', () => {
it('should generate CSS background for C major ionian', () => {
- const result = generatePianoGridBackground('ionian', 'C major')
- expect(result).toContain('linear-gradient')
- expect(typeof result).toBe('string')
- })
+ const result = generatePianoGridBackground('ionian', 'C major');
+ expect(result).toContain('linear-gradient');
+ expect(typeof result).toBe('string');
+ });
it('should generate different backgrounds for different modes', () => {
- const ionian = generatePianoGridBackground('ionian', 'C major')
- const dorian = generatePianoGridBackground('dorian', 'C major')
- expect(ionian).not.toBe(dorian)
- })
- })
+ const ionian = generatePianoGridBackground('ionian', 'C major');
+ const dorian = generatePianoGridBackground('dorian', 'C major');
+ expect(ionian).not.toBe(dorian);
+ });
+ });
describe('validateFunctionalChordsJSON', () => {
const validJSON = `{
@@ -434,14 +434,14 @@ describe('scaleUtil', () => {
"♭VII": ["Bb", "D", "F"]
}
}
- }`
+ }`;
describe('valid JSON', () => {
it('should validate correct JSON with ionian and aeolian', () => {
- const result = validateFunctionalChordsJSON(validJSON)
- expect(result.valid).toBe(true)
- expect(result.errors).toEqual([])
- })
+ const result = validateFunctionalChordsJSON(validJSON);
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
it('should accept empty T/S/D arrays', () => {
const json = `{
@@ -453,10 +453,10 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(true);
+ });
it('should accept mode names with underscores, dashes, and spaces', () => {
const json = `{
@@ -468,10 +468,10 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(true);
+ });
it('should accept notes with sharp and flat', () => {
const json = `{
@@ -485,10 +485,10 @@ describe('scaleUtil', () => {
"I": ["C#", "Eb", "F#"]
}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(true);
+ });
it('should accept chord symbols with special characters', () => {
const json = `{
@@ -505,24 +505,24 @@ describe('scaleUtil', () => {
"♭II": ["Db", "F", "Ab"]
}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(true)
- })
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(true);
+ });
+ });
describe('invalid JSON', () => {
it('should reject malformed JSON', () => {
- const result = validateFunctionalChordsJSON('{ invalid json }')
- expect(result.valid).toBe(false)
- expect(result.errors).toContain('Invalid JSON format')
- })
+ const result = validateFunctionalChordsJSON('{ invalid json }');
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('Invalid JSON format');
+ });
it('should reject JSON array as root', () => {
- const result = validateFunctionalChordsJSON('[]')
- expect(result.valid).toBe(false)
- expect(result.errors).toContain('Root must be an object')
- })
+ const result = validateFunctionalChordsJSON('[]');
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('Root must be an object');
+ });
it('should reject missing ionian mode', () => {
const json = `{
@@ -534,11 +534,11 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors).toContain('Missing required mode: "ionian"')
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('Missing required mode: "ionian"');
+ });
it('should reject invalid mode name', () => {
const json = `{
@@ -550,11 +550,11 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true);
+ });
it('should reject steps with wrong number of elements', () => {
const json = `{
@@ -566,11 +566,11 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true);
+ });
it('should reject steps that do not sum to 12', () => {
const json = `{
@@ -582,11 +582,11 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true);
+ });
it('should reject non-integer steps', () => {
const json = `{
@@ -598,11 +598,11 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true);
+ });
it('should reject invalid chord symbol in T/S/D', () => {
const json = `{
@@ -616,11 +616,11 @@ describe('scaleUtil', () => {
"invalid123": ["C", "E", "G"]
}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true);
+ });
it('should reject chord referenced but not defined', () => {
const json = `{
@@ -632,11 +632,11 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true);
+ });
it('should reject invalid note name', () => {
const json = `{
@@ -650,11 +650,11 @@ describe('scaleUtil', () => {
"I": ["C", "X", "G"]
}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
+ });
it('should reject note with invalid accidental', () => {
const json = `{
@@ -668,11 +668,11 @@ describe('scaleUtil', () => {
"I": ["C##", "E", "G"]
}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
+ });
it('should reject lowercase note names', () => {
const json = `{
@@ -686,12 +686,12 @@ describe('scaleUtil', () => {
"I": ["c", "e", "g"]
}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
- })
- })
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
+ });
+ });
describe('error messages', () => {
it('should provide descriptive error messages for multiple errors', () => {
@@ -704,11 +704,11 @@ describe('scaleUtil', () => {
"D": [],
"chords": {}
}
- }`
- const result = validateFunctionalChordsJSON(json)
- expect(result.valid).toBe(false)
- expect(result.errors.length).toBeGreaterThan(1)
- })
- })
- })
-})
+ }`;
+ const result = validateFunctionalChordsJSON(json);
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBeGreaterThan(1);
+ });
+ });
+ });
+});
diff --git a/src/util/timeUtil.test.ts b/src/util/timeUtil.test.ts
index f9064e8..61696f6 100644
--- a/src/util/timeUtil.test.ts
+++ b/src/util/timeUtil.test.ts
@@ -1,316 +1,316 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil'
-import { TIME_CONSTANTS } from '../constants/coreConstants'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil';
+import { TIME_CONSTANTS } from '../constants/coreConstants';
describe('timeUtil', () => {
describe('parseTimeSignature', () => {
it('should parse valid time signatures', () => {
- expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 })
- expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 })
- expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 })
- expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 })
- expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 })
- })
+ expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 });
+ expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 });
+ expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 });
+ expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 });
+ expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 });
+ });
it('should handle whitespace around input', () => {
- expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 })
- expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 })
- expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 })
- })
+ expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 });
+ expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 });
+ expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 });
+ });
it('should return null for invalid formats', () => {
- expect(parseTimeSignature('4')).toBeNull()
- expect(parseTimeSignature('4/4/4')).toBeNull()
- expect(parseTimeSignature('4-4')).toBeNull()
- expect(parseTimeSignature('4:4')).toBeNull()
- expect(parseTimeSignature('')).toBeNull()
- expect(parseTimeSignature('/')).toBeNull()
- expect(parseTimeSignature('4/')).toBeNull()
- expect(parseTimeSignature('/4')).toBeNull()
- })
+ expect(parseTimeSignature('4')).toBeNull();
+ expect(parseTimeSignature('4/4/4')).toBeNull();
+ expect(parseTimeSignature('4-4')).toBeNull();
+ expect(parseTimeSignature('4:4')).toBeNull();
+ expect(parseTimeSignature('')).toBeNull();
+ expect(parseTimeSignature('/')).toBeNull();
+ expect(parseTimeSignature('4/')).toBeNull();
+ expect(parseTimeSignature('/4')).toBeNull();
+ });
it('should return null for non-numeric values', () => {
- expect(parseTimeSignature('a/4')).toBeNull()
- expect(parseTimeSignature('4/b')).toBeNull()
- expect(parseTimeSignature('x/y')).toBeNull()
+ expect(parseTimeSignature('a/4')).toBeNull();
+ expect(parseTimeSignature('4/b')).toBeNull();
+ expect(parseTimeSignature('x/y')).toBeNull();
// Note: parseInt('4.5') returns 4, so these will parse as integers
// Testing the actual behavior of parseInt
- expect(parseTimeSignature('4.5/4')).toEqual({ numerator: 4, denominator: 4 })
- expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 })
- })
+ expect(parseTimeSignature('4.5/4')).toEqual({ numerator: 4, denominator: 4 });
+ expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 });
+ });
it('should return null for numerators not in available list', () => {
// Assuming TIME_CONSTANTS has specific available numerators
- expect(parseTimeSignature('99/4')).toBeNull()
- expect(parseTimeSignature('0/4')).toBeNull()
- expect(parseTimeSignature('-1/4')).toBeNull()
- })
+ expect(parseTimeSignature('99/4')).toBeNull();
+ expect(parseTimeSignature('0/4')).toBeNull();
+ expect(parseTimeSignature('-1/4')).toBeNull();
+ });
it('should return null for denominators not in available list', () => {
// Assuming TIME_CONSTANTS has specific available denominators
- expect(parseTimeSignature('4/99')).toBeNull()
- expect(parseTimeSignature('4/0')).toBeNull()
- expect(parseTimeSignature('4/-1')).toBeNull()
- })
+ expect(parseTimeSignature('4/99')).toBeNull();
+ expect(parseTimeSignature('4/0')).toBeNull();
+ expect(parseTimeSignature('4/-1')).toBeNull();
+ });
it('should validate against TIME_CONSTANTS available values', () => {
// Test that function actually uses TIME_CONSTANTS for validation
- const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0]
- const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0]
- const invalidNumerator = 999 // Assuming this is not in the available list
- const invalidDenominator = 999 // Assuming this is not in the available list
+ const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0];
+ const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0];
+ const invalidNumerator = 999; // Assuming this is not in the available list
+ const invalidDenominator = 999; // Assuming this is not in the available list
- expect(parseTimeSignature(`${validNumerator}/${validDenominator}`)).not.toBeNull()
- expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull()
- expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull()
- })
- })
+ expect(parseTimeSignature(`${validNumerator}/${validDenominator}`)).not.toBeNull();
+ expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull();
+ expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull();
+ });
+ });
describe('getTimeSignatureErrorMessage', () => {
it('should return a formatted error message with available options', () => {
- const message = getTimeSignatureErrorMessage()
+ const message = getTimeSignatureErrorMessage();
- expect(message).toContain('Invalid time signature format')
- expect(message).toContain('numerator/denominator')
- expect(message).toContain('Available numerators:')
- expect(message).toContain('Available denominators:')
- expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8')
- })
+ expect(message).toContain('Invalid time signature format');
+ expect(message).toContain('numerator/denominator');
+ expect(message).toContain('Available numerators:');
+ expect(message).toContain('Available denominators:');
+ expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8');
+ });
it('should include actual available values from TIME_CONSTANTS', () => {
- const message = getTimeSignatureErrorMessage()
+ const message = getTimeSignatureErrorMessage();
// Check that it includes values from TIME_CONSTANTS
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.forEach(numerator => {
- expect(message).toContain(numerator.toString())
- })
+ expect(message).toContain(numerator.toString());
+ });
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS.forEach(denominator => {
- expect(message).toContain(denominator.toString())
- })
- })
+ expect(message).toContain(denominator.toString());
+ });
+ });
it('should be a consistent message format', () => {
- const message1 = getTimeSignatureErrorMessage()
- const message2 = getTimeSignatureErrorMessage()
+ const message1 = getTimeSignatureErrorMessage();
+ const message2 = getTimeSignatureErrorMessage();
- expect(message1).toBe(message2)
- })
- })
+ expect(message1).toBe(message2);
+ });
+ });
describe('beatsToTimeString', () => {
it('should format beats to BBB:B | mm:ss:mmm format', () => {
// Test basic 4/4 time signature
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
- .toBe('001:1 | 00:00:000')
+ .toBe('001:1 | 00:00:000');
expect(beatsToTimeString(4, 120, { numerator: 4, denominator: 4 }))
- .toBe('002:1 | 00:02:000')
+ .toBe('002:1 | 00:02:000');
expect(beatsToTimeString(8, 120, { numerator: 4, denominator: 4 }))
- .toBe('003:1 | 00:04:000')
- })
+ .toBe('003:1 | 00:04:000');
+ });
it('should handle different time signatures correctly', () => {
// 3/4 time signature - 3 beats per bar
expect(beatsToTimeString(0, 120, { numerator: 3, denominator: 4 }))
- .toBe('001:1 | 00:00:000')
+ .toBe('001:1 | 00:00:000');
expect(beatsToTimeString(3, 120, { numerator: 3, denominator: 4 }))
- .toBe('002:1 | 00:01:500')
+ .toBe('002:1 | 00:01:500');
expect(beatsToTimeString(6, 120, { numerator: 3, denominator: 4 }))
- .toBe('003:1 | 00:03:000')
+ .toBe('003:1 | 00:03:000');
// 6/8 time signature - 6 beats per bar
expect(beatsToTimeString(0, 120, { numerator: 6, denominator: 8 }))
- .toBe('001:1 | 00:00:000')
+ .toBe('001:1 | 00:00:000');
expect(beatsToTimeString(6, 120, { numerator: 6, denominator: 8 }))
- .toBe('002:1 | 00:03:000')
- })
+ .toBe('002:1 | 00:03:000');
+ });
it('should handle different BPM values correctly', () => {
// 60 BPM - 1 beat per second
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
- .toBe('001:2 | 00:01:000')
+ .toBe('001:2 | 00:01:000');
expect(beatsToTimeString(4, 60, { numerator: 4, denominator: 4 }))
- .toBe('002:1 | 00:04:000')
+ .toBe('002:1 | 00:04:000');
// 240 BPM - 4 beats per second
expect(beatsToTimeString(1, 240, { numerator: 4, denominator: 4 }))
- .toBe('001:2 | 00:00:250')
+ .toBe('001:2 | 00:00:250');
expect(beatsToTimeString(4, 240, { numerator: 4, denominator: 4 }))
- .toBe('002:1 | 00:01:000')
- })
+ .toBe('002:1 | 00:01:000');
+ });
it('should handle fractional beats correctly', () => {
expect(beatsToTimeString(1.5, 120, { numerator: 4, denominator: 4 }))
- .toBe('001:2 | 00:00:750')
+ .toBe('001:2 | 00:00:750');
expect(beatsToTimeString(2.25, 120, { numerator: 4, denominator: 4 }))
- .toBe('001:3 | 00:01:125')
+ .toBe('001:3 | 00:01:125');
expect(beatsToTimeString(4.75, 120, { numerator: 4, denominator: 4 }))
- .toBe('002:1 | 00:02:375')
- })
+ .toBe('002:1 | 00:02:375');
+ });
it('should pad numbers correctly in output format', () => {
// Test bar padding (BBB format)
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
- .toMatch(/^001:/)
+ .toMatch(/^001:/);
expect(beatsToTimeString(40, 120, { numerator: 4, denominator: 4 }))
- .toMatch(/^011:/) // Bar 11
+ .toMatch(/^011:/); // Bar 11
expect(beatsToTimeString(396, 120, { numerator: 4, denominator: 4 }))
- .toMatch(/^100:/) // Bar 100
+ .toMatch(/^100:/); // Bar 100
// Test time padding (mm:ss:mmm format)
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
- .toMatch(/\| 00:01:000$/)
+ .toMatch(/\| 00:01:000$/);
expect(beatsToTimeString(75, 60, { numerator: 4, denominator: 4 }))
- .toMatch(/\| 01:15:000$/) // 1 minute 15 seconds
- })
+ .toMatch(/\| 01:15:000$/); // 1 minute 15 seconds
+ });
it('should handle large beat values', () => {
- const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 })
- expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/)
- })
+ const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 });
+ expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/);
+ });
it('should handle edge case of zero BPM gracefully', () => {
// This might cause division by zero, should handle gracefully
expect(() => beatsToTimeString(1, 0, { numerator: 4, denominator: 4 }))
- .not.toThrow()
- })
+ .not.toThrow();
+ });
it('should handle negative beats', () => {
// Edge case - negative beats may produce negative values in time format
- const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 })
- expect(result).toBeDefined()
- expect(typeof result).toBe('string')
+ const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 });
+ expect(result).toBeDefined();
+ expect(typeof result).toBe('string');
// The function may produce negative time values for negative beats
- expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/)
- })
- })
+ expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/);
+ });
+ });
describe('formatLocalDateTime', () => {
- let mockDate: Date
+ let mockDate: Date;
beforeEach(() => {
// Use a fixed date for consistent testing
- mockDate = new Date('2025-08-21T19:57:11.123Z')
- })
+ mockDate = new Date('2025-08-21T19:57:11.123Z');
+ });
it('should format date with correct structure', () => {
- const result = formatLocalDateTime(mockDate)
+ const result = formatLocalDateTime(mockDate);
// Should contain date parts
- expect(result).toMatch(/\d{4}/) // Year
- expect(result).toMatch(/\d{2}/) // Month/day/hour/minute/second
- expect(result).toContain(':') // Time separator
- expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/) // Timezone
- })
+ expect(result).toMatch(/\d{4}/); // Year
+ expect(result).toMatch(/\d{2}/); // Month/day/hour/minute/second
+ expect(result).toContain(':'); // Time separator
+ expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/); // Timezone
+ });
it('should use 24-hour format', () => {
- const morningDate = new Date('2025-08-21T09:30:00Z')
- const eveningDate = new Date('2025-08-21T21:30:00Z')
+ const morningDate = new Date('2025-08-21T09:30:00Z');
+ const eveningDate = new Date('2025-08-21T21:30:00Z');
- const morningResult = formatLocalDateTime(morningDate)
- const eveningResult = formatLocalDateTime(eveningDate)
+ const morningResult = formatLocalDateTime(morningDate);
+ const eveningResult = formatLocalDateTime(eveningDate);
// Should not contain AM/PM indicators
- expect(morningResult).not.toMatch(/AM|PM/i)
- expect(eveningResult).not.toMatch(/AM|PM/i)
- })
+ expect(morningResult).not.toMatch(/AM|PM/i);
+ expect(eveningResult).not.toMatch(/AM|PM/i);
+ });
it('should include timezone information', () => {
- const result = formatLocalDateTime(mockDate)
+ const result = formatLocalDateTime(mockDate);
// Should contain some timezone indicator
- expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}|\+\d{4}|-\d{4}/)
- })
+ expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}|\+\d{4}|-\d{4}/);
+ });
it('should handle different dates consistently', () => {
const dates = [
new Date('2025-01-01T00:00:00Z'),
new Date('2025-06-15T12:30:45Z'),
new Date('2025-12-31T23:59:59Z')
- ]
+ ];
dates.forEach(date => {
- const result = formatLocalDateTime(date)
- expect(result).toBeDefined()
- expect(typeof result).toBe('string')
- expect(result.length).toBeGreaterThan(10)
- })
- })
+ const result = formatLocalDateTime(date);
+ expect(result).toBeDefined();
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(10);
+ });
+ });
it('should handle edge dates', () => {
const edgeDates = [
new Date('1970-01-01T00:00:00Z'), // Unix epoch
new Date('2038-01-19T03:14:07Z'), // Near 32-bit timestamp limit
new Date('2100-12-31T23:59:59Z') // Future date
- ]
+ ];
edgeDates.forEach(date => {
- expect(() => formatLocalDateTime(date)).not.toThrow()
- const result = formatLocalDateTime(date)
- expect(typeof result).toBe('string')
- expect(result.length).toBeGreaterThan(0)
- })
- })
+ expect(() => formatLocalDateTime(date)).not.toThrow();
+ const result = formatLocalDateTime(date);
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+ });
it('should be consistent for the same date', () => {
- const result1 = formatLocalDateTime(mockDate)
- const result2 = formatLocalDateTime(mockDate)
+ const result1 = formatLocalDateTime(mockDate);
+ const result2 = formatLocalDateTime(mockDate);
- expect(result1).toBe(result2)
- })
+ expect(result1).toBe(result2);
+ });
it('should handle leap year dates', () => {
- const leapYearDate = new Date('2024-02-29T12:00:00Z') // Leap year
+ const leapYearDate = new Date('2024-02-29T12:00:00Z'); // Leap year
- expect(() => formatLocalDateTime(leapYearDate)).not.toThrow()
- const result = formatLocalDateTime(leapYearDate)
- expect(result).toContain('2024')
- expect(result).toContain('02')
- expect(result).toContain('29')
- })
- })
+ expect(() => formatLocalDateTime(leapYearDate)).not.toThrow();
+ const result = formatLocalDateTime(leapYearDate);
+ expect(result).toContain('2024');
+ expect(result).toContain('02');
+ expect(result).toContain('29');
+ });
+ });
describe('integration tests', () => {
it('should work together for typical DAW workflow', () => {
// Parse time signature
- const timeSignature = parseTimeSignature('4/4')
- expect(timeSignature).not.toBeNull()
+ const timeSignature = parseTimeSignature('4/4');
+ expect(timeSignature).not.toBeNull();
// Use parsed time signature in time formatting
- const timeString = beatsToTimeString(16, 120, timeSignature!)
- expect(timeString).toBe('005:1 | 00:08:000')
+ const timeString = beatsToTimeString(16, 120, timeSignature!);
+ expect(timeString).toBe('005:1 | 00:08:000');
// Format current time
- const now = new Date()
- const formattedTime = formatLocalDateTime(now)
- expect(formattedTime).toBeDefined()
- })
+ const now = new Date();
+ const formattedTime = formatLocalDateTime(now);
+ expect(formattedTime).toBeDefined();
+ });
it('should handle error cases gracefully in workflow', () => {
// Invalid time signature should not break workflow
- const invalidTimeSignature = parseTimeSignature('invalid')
- expect(invalidTimeSignature).toBeNull()
+ const invalidTimeSignature = parseTimeSignature('invalid');
+ expect(invalidTimeSignature).toBeNull();
// Get error message for user feedback
- const errorMessage = getTimeSignatureErrorMessage()
- expect(errorMessage).toContain('Invalid time signature')
+ const errorMessage = getTimeSignatureErrorMessage();
+ expect(errorMessage).toContain('Invalid time signature');
// Fallback to default time signature
- const fallbackTimeSignature = { numerator: 4, denominator: 4 }
- const timeString = beatsToTimeString(8, 120, fallbackTimeSignature)
- expect(timeString).toBe('003:1 | 00:04:000')
- })
- })
-})
\ No newline at end of file
+ const fallbackTimeSignature = { numerator: 4, denominator: 4 };
+ const timeString = beatsToTimeString(8, 120, fallbackTimeSignature);
+ expect(timeString).toBe('003:1 | 00:04:000');
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/util/xmlUtil.test.ts b/src/util/xmlUtil.test.ts
index 8e2ec1c..03f5d31 100644
--- a/src/util/xmlUtil.test.ts
+++ b/src/util/xmlUtil.test.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect } from 'vitest'
-import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil'
+import { describe, it, expect } from 'vitest';
+import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil';
import {
longTextWithAddNotes,
longTextWithReadMusic,
@@ -9,323 +9,323 @@ import {
malformedXml,
noXmlContent,
emptyAndWhitespaceXml
-} from '../test/fixtures/xml-samples'
+} from '../test/fixtures/xml-samples';
describe('xmlUtil', () => {
describe('extractXMLFromString', () => {
it('should extract simple XML blocks', () => {
- const input = `Here is some text with content and more text.`
- const result = extractXMLFromString(input)
+ const input = `Here is some text with content and more text.`;
+ const result = extractXMLFromString(input);
- expect(result).toHaveLength(1)
- expect(result[0]).toBe('content')
- })
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe('content');
+ });
it('should extract multiple XML blocks', () => {
- const input = `content1 some text content2`
- const result = extractXMLFromString(input)
+ const input = `content1 some text content2`;
+ const result = extractXMLFromString(input);
- expect(result).toHaveLength(2)
- expect(result[0]).toBe('content1')
- expect(result[1]).toBe('content2')
- })
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe('content1');
+ expect(result[1]).toBe('content2');
+ });
it('should extract XML blocks with nested elements', () => {
- const input = `nested content`
- const result = extractXMLFromString(input)
+ const input = `nested content`;
+ const result = extractXMLFromString(input);
- expect(result).toHaveLength(1)
- expect(result[0]).toBe('nested content')
- })
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe('nested content');
+ });
it('should extract XML blocks with attributes', () => {
- const input = `content`
- const result = extractXMLFromString(input)
+ const input = `content`;
+ const result = extractXMLFromString(input);
- expect(result).toHaveLength(1)
- expect(result[0]).toBe('content')
- })
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe('content');
+ });
it('should handle multiline XML blocks', () => {
const input = `content1content2
-`
- const result = extractXMLFromString(input)
+`;
+ const result = extractXMLFromString(input);
- expect(result).toHaveLength(1)
- expect(result[0]).toContain('')
- expect(result[0]).toContain('content1')
- expect(result[0]).toContain('content2')
- expect(result[0]).toContain('')
- })
+ expect(result).toHaveLength(1);
+ expect(result[0]).toContain('');
+ expect(result[0]).toContain('content1');
+ expect(result[0]).toContain('content2');
+ expect(result[0]).toContain('');
+ });
it('should extract XML from complex nested content fixture', () => {
- const result = extractXMLFromString(nestedXmlContent)
+ const result = extractXMLFromString(nestedXmlContent);
- expect(result).toHaveLength(1)
- expect(result[0]).toContain('')
- expect(result[0]).toContain('')
- expect(result[0]).toContain('')
- expect(result[0]).toContain('Test Content')
- expect(result[0]).toContain('')
- })
+ expect(result).toHaveLength(1);
+ expect(result[0]).toContain('');
+ expect(result[0]).toContain('');
+ expect(result[0]).toContain('');
+ expect(result[0]).toContain('Test Content');
+ expect(result[0]).toContain('');
+ });
it('should extract multiple XML blocks from fixture', () => {
- const result = extractXMLFromString(multipleXmlBlocks)
+ const result = extractXMLFromString(multipleXmlBlocks);
- expect(result).toHaveLength(3)
- expect(result[0]).toContain('')
- expect(result[0]).toContain('')
- expect(result[1]).toContain('')
- expect(result[1]).toContain('')
- expect(result[2]).toContain('')
- expect(result[2]).toContain('')
- })
+ expect(result).toHaveLength(3);
+ expect(result[0]).toContain('');
+ expect(result[0]).toContain('');
+ expect(result[1]).toContain('');
+ expect(result[1]).toContain('');
+ expect(result[2]).toContain('');
+ expect(result[2]).toContain('');
+ });
it('should extract XML with attributes from fixture', () => {
- const result = extractXMLFromString(xmlWithAttributes)
+ const result = extractXMLFromString(xmlWithAttributes);
- expect(result).toHaveLength(1)
- expect(result[0]).toContain('region_id="main"')
- expect(result[0]).toContain('track="melody"')
- expect(result[0]).toContain('id="1"')
- expect(result[0]).toContain('velocity="127"')
- })
+ expect(result).toHaveLength(1);
+ expect(result[0]).toContain('region_id="main"');
+ expect(result[0]).toContain('track="melody"');
+ expect(result[0]).toContain('id="1"');
+ expect(result[0]).toContain('velocity="127"');
+ });
it('should handle the long text with add_notes fixture (Case 1)', () => {
- const result = extractXMLFromString(longTextWithAddNotes)
+ const result = extractXMLFromString(longTextWithAddNotes);
- expect(result).toHaveLength(2) // Contains thinking tag and add_notes block
+ expect(result).toHaveLength(2); // Contains thinking tag and add_notes block
// Find the add_notes block
- const addNotesBlock = result.find(block => block.includes(''))
- expect(addNotesBlock).toBeDefined()
- expect(addNotesBlock).toContain('')
- expect(addNotesBlock).toContain('')
- expect(addNotesBlock).toContain('C4')
- expect(addNotesBlock).toContain('0')
- expect(addNotesBlock).toContain('4')
- expect(addNotesBlock).toContain('')
+ const addNotesBlock = result.find(block => block.includes(''));
+ expect(addNotesBlock).toBeDefined();
+ expect(addNotesBlock).toContain('');
+ expect(addNotesBlock).toContain('');
+ expect(addNotesBlock).toContain('C4');
+ expect(addNotesBlock).toContain('0');
+ expect(addNotesBlock).toContain('4');
+ expect(addNotesBlock).toContain('');
// Should contain all 12 notes
- const noteMatches = addNotesBlock!.match(//g)
- expect(noteMatches).toHaveLength(12)
- })
+ const noteMatches = addNotesBlock!.match(//g);
+ expect(noteMatches).toHaveLength(12);
+ });
it('should handle the long text with read_music fixture (Case 2)', () => {
- const result = extractXMLFromString(longTextWithReadMusic)
+ const result = extractXMLFromString(longTextWithReadMusic);
- expect(result).toHaveLength(2)
+ expect(result).toHaveLength(2);
// First XML block (inside thinking tag)
- expect(result[0]).toContain('')
- expect(result[0]).toContain('0')
- expect(result[0]).toContain('32')
- expect(result[0]).toContain('')
+ expect(result[0]).toContain('');
+ expect(result[0]).toContain('0');
+ expect(result[0]).toContain('32');
+ expect(result[0]).toContain('');
// Second XML block (at the end)
- expect(result[1]).toContain('')
- expect(result[1]).toContain('0')
- expect(result[1]).toContain('32')
- expect(result[1]).toContain('')
- })
+ expect(result[1]).toContain('');
+ expect(result[1]).toContain('0');
+ expect(result[1]).toContain('32');
+ expect(result[1]).toContain('');
+ });
it('should handle malformed XML gracefully', () => {
- const result = extractXMLFromString(malformedXml)
+ const result = extractXMLFromString(malformedXml);
// Should extract valid XML blocks (ignores malformed ones)
- expect(result.length).toBeGreaterThanOrEqual(1)
+ expect(result.length).toBeGreaterThanOrEqual(1);
// Find the definitely valid block
- const validBlock = result.find(block => block.includes(''))
- expect(validBlock).toBeDefined()
- expect(validBlock).toContain('This is valid')
- })
+ const validBlock = result.find(block => block.includes(''));
+ expect(validBlock).toBeDefined();
+ expect(validBlock).toContain('This is valid');
+ });
it('should return empty array for content with no XML', () => {
- const result = extractXMLFromString(noXmlContent)
+ const result = extractXMLFromString(noXmlContent);
- expect(result).toHaveLength(0)
- expect(result).toEqual([])
- })
+ expect(result).toHaveLength(0);
+ expect(result).toEqual([]);
+ });
it('should handle empty and whitespace XML', () => {
- const result = extractXMLFromString(emptyAndWhitespaceXml)
+ const result = extractXMLFromString(emptyAndWhitespaceXml);
- expect(result).toHaveLength(3)
- expect(result[0]).toBe('')
- expect(result[1]).toContain('')
- expect(result[1]).toContain('')
- expect(result[2]).toContain('')
- expect(result[2]).toContain('Some text with spaces')
- expect(result[2]).toContain('')
- })
+ expect(result).toHaveLength(3);
+ expect(result[0]).toBe('');
+ expect(result[1]).toContain('');
+ expect(result[1]).toContain('');
+ expect(result[2]).toContain('');
+ expect(result[2]).toContain('Some text with spaces');
+ expect(result[2]).toContain('');
+ });
it('should handle XML with underscores and hyphens in tag names', () => {
- const input = `content and content`
- const result = extractXMLFromString(input)
+ const input = `content and content`;
+ const result = extractXMLFromString(input);
- expect(result).toHaveLength(2)
- expect(result[0]).toBe('content')
- expect(result[1]).toBe('content')
- })
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe('content');
+ expect(result[1]).toBe('content');
+ });
it('should handle self-closing tags (not currently supported)', () => {
- const input = ` and content`
- const result = extractXMLFromString(input)
+ const input = ` and content`;
+ const result = extractXMLFromString(input);
// Current implementation doesn't support self-closing tags
- expect(result).toHaveLength(1)
- expect(result[0]).toBe('content')
- })
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe('content');
+ });
it('should trim whitespace around extracted XML', () => {
- const input = ` content `
- const result = extractXMLFromString(input)
+ const input = ` content `;
+ const result = extractXMLFromString(input);
- expect(result).toHaveLength(1)
- expect(result[0]).toBe('content')
- })
- })
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe('content');
+ });
+ });
describe('wrapXmlBlocksInContent', () => {
it('should wrap single XML block in fenced code block', () => {
- const input = `Here is content in text.`
- const result = wrapXmlBlocksInContent(input)
+ const input = `Here is content in text.`;
+ const result = wrapXmlBlocksInContent(input);
- expect(result).toBe('Here is ```xml\ncontent\n``` in text.')
- })
+ expect(result).toBe('Here is ```xml\ncontent\n``` in text.');
+ });
it('should wrap multiple XML blocks', () => {
- const input = `content1 text content2`
- const result = wrapXmlBlocksInContent(input)
+ const input = `content1 text content2`;
+ const result = wrapXmlBlocksInContent(input);
- expect(result).toContain('```xml\ncontent1\n```')
- expect(result).toContain('```xml\ncontent2\n```')
- })
+ expect(result).toContain('```xml\ncontent1\n```');
+ expect(result).toContain('```xml\ncontent2\n```');
+ });
it('should return original content when no XML blocks present', () => {
- const input = noXmlContent
- const result = wrapXmlBlocksInContent(input)
+ const input = noXmlContent;
+ const result = wrapXmlBlocksInContent(input);
- expect(result).toBe(input)
- })
+ expect(result).toBe(input);
+ });
it('should handle empty input', () => {
- expect(wrapXmlBlocksInContent('')).toBe('')
- expect(wrapXmlBlocksInContent(null as any)).toBeNull()
- expect(wrapXmlBlocksInContent(undefined as any)).toBeUndefined()
- })
+ expect(wrapXmlBlocksInContent('')).toBe('');
+ expect(wrapXmlBlocksInContent(null as any)).toBeNull();
+ expect(wrapXmlBlocksInContent(undefined as any)).toBeUndefined();
+ });
it('should wrap XML blocks from multipleXmlBlocks fixture', () => {
- const result = wrapXmlBlocksInContent(multipleXmlBlocks)
+ const result = wrapXmlBlocksInContent(multipleXmlBlocks);
- expect(result).toContain('```xml\n')
- expect(result).toContain('\n```')
- expect(result).toContain('```xml\n')
- expect(result).toContain('\n```')
- expect(result).toContain('```xml\n')
- expect(result).toContain('\n```')
+ expect(result).toContain('```xml\n');
+ expect(result).toContain('\n```');
+ expect(result).toContain('```xml\n');
+ expect(result).toContain('\n```');
+ expect(result).toContain('```xml\n');
+ expect(result).toContain('\n```');
// Should preserve the surrounding text
- expect(result).toContain('Here\'s how to add multiple musical elements:')
- expect(result).toContain('First, let\'s add some notes:')
- expect(result).toContain('Then we can read the current music:')
- })
+ expect(result).toContain('Here\'s how to add multiple musical elements:');
+ expect(result).toContain('First, let\'s add some notes:');
+ expect(result).toContain('Then we can read the current music:');
+ });
it('should wrap the long add_notes XML block (Case 1)', () => {
- const result = wrapXmlBlocksInContent(longTextWithAddNotes)
+ const result = wrapXmlBlocksInContent(longTextWithAddNotes);
- expect(result).toContain('```xml\n')
- expect(result).toContain('\n```')
+ expect(result).toContain('```xml\n');
+ expect(result).toContain('\n```');
// Should preserve the thinking content and other text
- expect(result).toContain('')
- expect(result).toContain('Perfect! I can see this is a beautiful')
- expect(result).toContain('Let me start by adding the harmony')
- })
+ expect(result).toContain('');
+ expect(result).toContain('Perfect! I can see this is a beautiful');
+ expect(result).toContain('Let me start by adding the harmony');
+ });
it('should wrap the long read_music XML blocks (Case 2)', () => {
- const result = wrapXmlBlocksInContent(longTextWithReadMusic)
+ const result = wrapXmlBlocksInContent(longTextWithReadMusic);
// Should contain two wrapped XML blocks
- const fencedBlocks = result.match(/```xml\n[\s\S]*?<\/read_music>\n```/g)
- expect(fencedBlocks).toHaveLength(2)
+ const fencedBlocks = result.match(/```xml\n[\s\S]*?<\/read_music>\n```/g);
+ expect(fencedBlocks).toHaveLength(2);
// Should preserve the thinking content and other text
- expect(result).toContain('')
- expect(result).toContain('I\'ll help you create a pad harmony track')
- expect(result).toContain('Let me first read the existing music')
- })
+ expect(result).toContain('');
+ expect(result).toContain('I\'ll help you create a pad harmony track');
+ expect(result).toContain('Let me first read the existing music');
+ });
it('should handle nested XML correctly', () => {
- const result = wrapXmlBlocksInContent(nestedXmlContent)
+ const result = wrapXmlBlocksInContent(nestedXmlContent);
- expect(result).toContain('```xml\n')
- expect(result).toContain('')
- expect(result).toContain('')
- expect(result).toContain('Test Content')
- expect(result).toContain('\n```')
- })
+ expect(result).toContain('```xml\n');
+ expect(result).toContain('');
+ expect(result).toContain('');
+ expect(result).toContain('Test Content');
+ expect(result).toContain('\n```');
+ });
it('should preserve XML block integrity when wrapping', () => {
- const input = `Text before\n\n value\n content\n\nText after`
- const result = wrapXmlBlocksInContent(input)
+ const input = `Text before\n\n value\n content\n\nText after`;
+ const result = wrapXmlBlocksInContent(input);
- expect(result).toBe(`Text before\n\`\`\`xml\n\n value\n content\n\n\`\`\`\nText after`)
- })
+ expect(result).toBe(`Text before\n\`\`\`xml\n\n value\n content\n\n\`\`\`\nText after`);
+ });
it('should handle duplicate XML blocks correctly', () => {
- const input = `content and then content again`
- const result = wrapXmlBlocksInContent(input)
+ const input = `content and then content again`;
+ const result = wrapXmlBlocksInContent(input);
// Both instances should be wrapped
- const wrappedBlocks = result.match(/```xml\ncontent<\/same>\n```/g)
- expect(wrappedBlocks).toHaveLength(2)
- })
- })
+ const wrappedBlocks = result.match(/```xml\ncontent<\/same>\n```/g);
+ expect(wrappedBlocks).toHaveLength(2);
+ });
+ });
describe('edge cases and error handling', () => {
it('should handle very large XML blocks', () => {
- const largeContent = 'x'.repeat(10000)
- const input = `${largeContent}`
+ const largeContent = 'x'.repeat(10000);
+ const input = `${largeContent}`;
- const extracted = extractXMLFromString(input)
- expect(extracted).toHaveLength(1)
- expect(extracted[0]).toContain(largeContent)
+ const extracted = extractXMLFromString(input);
+ expect(extracted).toHaveLength(1);
+ expect(extracted[0]).toContain(largeContent);
- const wrapped = wrapXmlBlocksInContent(input)
- expect(wrapped).toContain('```xml\n')
- expect(wrapped).toContain('\n```')
- })
+ const wrapped = wrapXmlBlocksInContent(input);
+ expect(wrapped).toContain('```xml\n');
+ expect(wrapped).toContain('\n```');
+ });
it('should handle XML with special characters', () => {
- const input = `Content with < > & " '`
+ const input = `Content with < > & " '`;
- const extracted = extractXMLFromString(input)
- expect(extracted).toHaveLength(1)
- expect(extracted[0]).toContain('< > & " '')
+ const extracted = extractXMLFromString(input);
+ expect(extracted).toHaveLength(1);
+ expect(extracted[0]).toContain('< > & " '');
- const wrapped = wrapXmlBlocksInContent(input)
- expect(wrapped).toContain('```xml\nContent with < > & " '\n```')
- })
+ const wrapped = wrapXmlBlocksInContent(input);
+ expect(wrapped).toContain('```xml\nContent with < > & " '\n```');
+ });
it('should handle XML with CDATA sections', () => {
- const input = ` chars]]>`
+ const input = ` chars]]>`;
- const extracted = extractXMLFromString(input)
- expect(extracted).toHaveLength(1)
- expect(extracted[0]).toContain('')
- })
+ const extracted = extractXMLFromString(input);
+ expect(extracted).toHaveLength(1);
+ expect(extracted[0]).toContain('');
+ });
it('should handle mixed content with partial XML-like text', () => {
- const input = `This < is not XML and neither > is this content is valid`
+ const input = `This < is not XML and neither > is this content is valid`;
- const extracted = extractXMLFromString(input)
- expect(extracted).toHaveLength(1)
- expect(extracted[0]).toBe('content')
- })
- })
-})
\ No newline at end of file
+ const extracted = extractXMLFromString(input);
+ expect(extracted).toHaveLength(1);
+ expect(extracted[0]).toBe('content');
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 54eaa07..dbb4c62 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1,3 +1,3 @@
///
-declare const __APP_VERSION__: string
+declare const __APP_VERSION__: string;
From cd90dc1c99dc602875a0ccfaa96abb72c3e24779 Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Fri, 1 May 2026 14:36:56 -0700
Subject: [PATCH 7/7] fix: linter errors
---
src/App.tsx | 2 +-
src/components/KGOnePanel.tsx | 2 +-
src/components/Toolbar.tsx | 2 +-
src/components/common/DialogProvider.tsx | 61 +----------------
src/components/common/FileImportModal.tsx | 2 +-
src/components/common/OpenProjectModal.tsx | 2 +-
src/components/common/index.ts | 5 +-
src/components/piano-roll/PianoRoll.tsx | 2 +-
.../settings/sections/ChordGuideSettings.tsx | 2 +-
src/components/track/TrackGridPanel.tsx | 2 +-
src/components/track/TrackInfoItem.tsx | 2 +-
src/core/KGDebugger.ts | 6 +-
.../config-upgrader/upgradeConfigToV1.test.ts | 3 +-
src/core/io/KGProjectStorage.test.ts | 3 +-
src/test/mocks/indexed-db.ts | 3 +
src/util/dialogUtil.ts | 65 +++++++++++++++++++
src/util/saveUtil.ts | 2 +-
src/util/xmlUtil.test.ts | 4 +-
18 files changed, 90 insertions(+), 80 deletions(-)
create mode 100644 src/util/dialogUtil.ts
diff --git a/src/App.tsx b/src/App.tsx
index 381b81b..aea7230 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,7 +18,7 @@ import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
import { KGCore } from './core/KGCore';
import { ConfigManager } from './core/config/ConfigManager';
import { validateFunctionalChordsJSON } from './util/scaleUtil';
-import { showAlert } from './components/common/DialogProvider';
+import { showAlert } from './util/dialogUtil';
import { KGProjectStorage } from './core/io/KGProjectStorage';
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx
index ffecc44..d53dbd2 100644
--- a/src/components/KGOnePanel.tsx
+++ b/src/components/KGOnePanel.tsx
@@ -14,7 +14,7 @@ import { sliceAudioToWav } from '../util/audioUtil';
import type { KeySignature } from '../core/KGProject';
import { ImportStemsCommand } from '../core/commands';
import type { StemImportEntry } from '../core/commands';
-import { showAlert } from './common/DialogProvider';
+import { showAlert } from '../util/dialogUtil';
// ─── Types ────────────────────────────────────────────────────────────────────
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index b4b0a44..ca5d52f 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -33,7 +33,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon';
import { ConfigManager } from '../core/config/ConfigManager';
-import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './common/DialogProvider';
+import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
const Toolbar: React.FC = () => {
const {
diff --git a/src/components/common/DialogProvider.tsx b/src/components/common/DialogProvider.tsx
index 5e18460..eadeffb 100644
--- a/src/components/common/DialogProvider.tsx
+++ b/src/components/common/DialogProvider.tsx
@@ -1,60 +1,8 @@
import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa';
-
-export interface ConfirmOptions {
- confirmLabel?: string;
- cancelLabel?: string;
-}
-
-export interface PromptOptions {
- confirmLabel?: string;
- cancelLabel?: string;
- placeholder?: string;
-}
-
-export interface TimeSigResult {
- numerator: number;
- denominator: number;
-}
-
-let _showAlertFn: ((message: string) => Promise) | null = null;
-let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise) | null = null;
-let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise) | null = null;
-let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise) | null = null;
-
-export function showAlert(message: string): Promise {
- if (!_showAlertFn) {
- window.alert(message);
- return Promise.resolve();
- }
- return _showAlertFn(message);
-}
-
-export function showConfirm(message: string, options?: ConfirmOptions): Promise {
- if (!_showConfirmFn) {
- return Promise.resolve(window.confirm(message));
- }
- return _showConfirmFn(message, options);
-}
-
-export function showPrompt(message: string, defaultValue?: string, options?: PromptOptions): Promise {
- if (!_showPromptFn) {
- return Promise.resolve(window.prompt(message, defaultValue));
- }
- return _showPromptFn(message, defaultValue, options);
-}
-
-export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise {
- if (!_showTimeSigFn) {
- const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
- if (!raw) return Promise.resolve(null);
- const [n, d] = raw.split('/').map(Number);
- if (!n || !d) return Promise.resolve(null);
- return Promise.resolve({ numerator: n, denominator: d });
- }
- return _showTimeSigFn(message, defaultValue);
-}
+import { registerDialogFns } from '../../util/dialogUtil';
+import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
interface DialogInfo {
type: 'alert' | 'confirm' | 'prompt' | 'timesig';
@@ -129,10 +77,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const registered = useRef(false);
if (!registered.current) {
registered.current = true;
- _showAlertFn = openAlert;
- _showConfirmFn = openConfirm;
- _showPromptFn = openPrompt;
- _showTimeSigFn = openTimeSig;
+ registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig);
}
if (!dialog) {
diff --git a/src/components/common/FileImportModal.tsx b/src/components/common/FileImportModal.tsx
index c5fddf3..bffecf2 100644
--- a/src/components/common/FileImportModal.tsx
+++ b/src/components/common/FileImportModal.tsx
@@ -1,7 +1,7 @@
import React, { useCallback, useRef, useState } from 'react';
import './FileImportModal.css';
import { FaTimes } from 'react-icons/fa';
-import { showAlert } from './DialogProvider';
+import { showAlert } from '../../util/dialogUtil';
interface FileImportModalProps {
isVisible: boolean;
diff --git a/src/components/common/OpenProjectModal.tsx b/src/components/common/OpenProjectModal.tsx
index 099103a..0308a39 100644
--- a/src/components/common/OpenProjectModal.tsx
+++ b/src/components/common/OpenProjectModal.tsx
@@ -3,7 +3,7 @@ import { FaTimes, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-ic
import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage';
import { isValidProjectName } from '../../util/projectNameUtil';
import './OpenProjectModal.css';
-import { showAlert, showConfirm, showPrompt } from './DialogProvider';
+import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
interface OpenProjectModalProps {
onClose: () => void;
diff --git a/src/components/common/index.ts b/src/components/common/index.ts
index 4dd4446..9fafde9 100644
--- a/src/components/common/index.ts
+++ b/src/components/common/index.ts
@@ -3,5 +3,6 @@ export { default as Playhead } from './Playhead';
export { default as FileImportModal } from './FileImportModal';
export { default as LoadingOverlay } from './LoadingOverlay';
export { default as OpenProjectModal } from './OpenProjectModal';
-export { default as DialogProvider, showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './DialogProvider';
-export type { ConfirmOptions, PromptOptions, TimeSigResult } from './DialogProvider';
\ No newline at end of file
+export { default as DialogProvider } from './DialogProvider';
+export { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil';
+export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
\ No newline at end of file
diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx
index 524afc8..2fe27a5 100644
--- a/src/components/piano-roll/PianoRoll.tsx
+++ b/src/components/piano-roll/PianoRoll.tsx
@@ -15,7 +15,7 @@ import { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil';
import { UpdateRegionCommand } from '../../core/commands';
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
-import { showAlert, showPrompt } from '../common/DialogProvider';
+import { showAlert, showPrompt } from '../../util/dialogUtil';
interface PianoRollProps {
onClose: () => void;
diff --git a/src/components/settings/sections/ChordGuideSettings.tsx b/src/components/settings/sections/ChordGuideSettings.tsx
index 2f8c2c5..69ca7b5 100644
--- a/src/components/settings/sections/ChordGuideSettings.tsx
+++ b/src/components/settings/sections/ChordGuideSettings.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
import { validateFunctionalChordsJSON } from '../../../util/scaleUtil';
import { KGCore } from '../../../core/KGCore';
-import { showAlert } from '../../common/DialogProvider';
+import { showAlert } from '../../../util/dialogUtil';
const ChordGuideSettings: React.FC = () => {
const [chordDefinition, setChordDefinition] = useState('');
diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx
index 560ed16..da64b29 100644
--- a/src/components/track/TrackGridPanel.tsx
+++ b/src/components/track/TrackGridPanel.tsx
@@ -13,7 +13,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { generateNewRegionName } from '../../util/miscUtil';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
-import { showAlert } from '../common/DialogProvider';
+import { showAlert } from '../../util/dialogUtil';
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
import * as Tone from 'tone';
diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx
index f03b95f..7cf9cc3 100644
--- a/src/components/track/TrackInfoItem.tsx
+++ b/src/components/track/TrackInfoItem.tsx
@@ -12,7 +12,7 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { DEBUG_MODE } from '../../constants/uiConstants';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
-import { showAlert, showConfirm, showPrompt } from '../common/DialogProvider';
+import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
const UNITY_POS = 750;
const SLIDER_MAX = 1000;
diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts
index a80ee13..e32d302 100644
--- a/src/core/KGDebugger.ts
+++ b/src/core/KGDebugger.ts
@@ -513,8 +513,7 @@ export class KGDebugger {
try {
textarea.selectionStart = textarea.selectionEnd = typed.length;
- } catch {
- }
+ } catch { /* setting cursor position can fail in read-only or special inputs */ }
textarea.scrollTop = textarea.scrollHeight;
@@ -583,8 +582,7 @@ export class KGDebugger {
try {
textarea.selectionStart = textarea.selectionEnd = typed.length;
- } catch {
- }
+ } catch { /* setting cursor position can fail in read-only or special inputs */ }
textarea.scrollTop = textarea.scrollHeight;
diff --git a/src/core/config-upgrader/upgradeConfigToV1.test.ts b/src/core/config-upgrader/upgradeConfigToV1.test.ts
index cc99d2f..190addd 100644
--- a/src/core/config-upgrader/upgradeConfigToV1.test.ts
+++ b/src/core/config-upgrader/upgradeConfigToV1.test.ts
@@ -47,9 +47,8 @@ class MockFileHandle {
async getFile() { return { text: () => Promise.resolve(this._content) }; }
async createWritable() {
const w = new MockWritable();
- const self = this;
const origClose = w.close.bind(w);
- w.close = async () => { self._content = w.data; await origClose(); };
+ w.close = async () => { this._content = w.data; await origClose(); };
return w;
}
}
diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts
index 3ded8e9..85b382f 100644
--- a/src/core/io/KGProjectStorage.test.ts
+++ b/src/core/io/KGProjectStorage.test.ts
@@ -19,10 +19,9 @@ class MockFileSystemFileHandle {
async createWritable() {
const stream = new MockFileSystemWritableFileStream();
// When stream closes, update our content
- const self = this;
const origClose = stream.close.bind(stream);
stream.close = async () => {
- self._content = stream.data;
+ this._content = stream.data;
await origClose();
};
return stream;
diff --git a/src/test/mocks/indexed-db.ts b/src/test/mocks/indexed-db.ts
index 606a102..3e631a0 100644
--- a/src/test/mocks/indexed-db.ts
+++ b/src/test/mocks/indexed-db.ts
@@ -5,11 +5,13 @@
import { vi } from 'vitest';
// In-memory storage for tests
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockStorage = new Map();
export const mockIndexedDB = {
openDB: vi.fn().mockImplementation(() => {
return Promise.resolve({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
put: vi.fn().mockImplementation((storeName: string, data: any, key?: string) => {
const actualKey = key || data.id || 'default';
mockStorage.set(`${storeName}:${actualKey}`, data);
@@ -21,6 +23,7 @@ export const mockIndexedDB = {
}),
getAll: vi.fn().mockImplementation((storeName: string) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const results: any[] = [];
for (const [key, value] of mockStorage.entries()) {
if (key.startsWith(`${storeName}:`)) {
diff --git a/src/util/dialogUtil.ts b/src/util/dialogUtil.ts
new file mode 100644
index 0000000..c9b84a8
--- /dev/null
+++ b/src/util/dialogUtil.ts
@@ -0,0 +1,65 @@
+export interface ConfirmOptions {
+ confirmLabel?: string;
+ cancelLabel?: string;
+}
+
+export interface PromptOptions {
+ confirmLabel?: string;
+ cancelLabel?: string;
+ placeholder?: string;
+}
+
+export interface TimeSigResult {
+ numerator: number;
+ denominator: number;
+}
+
+let _showAlertFn: ((message: string) => Promise) | null = null;
+let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise) | null = null;
+let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise) | null = null;
+let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise) | null = null;
+
+export function registerDialogFns(
+ alertFn: (message: string) => Promise,
+ confirmFn: (message: string, options?: ConfirmOptions) => Promise,
+ promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise,
+ timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise,
+) {
+ _showAlertFn = alertFn;
+ _showConfirmFn = confirmFn;
+ _showPromptFn = promptFn;
+ _showTimeSigFn = timeSigFn;
+}
+
+export function showAlert(message: string): Promise {
+ if (!_showAlertFn) {
+ window.alert(message);
+ return Promise.resolve();
+ }
+ return _showAlertFn(message);
+}
+
+export function showConfirm(message: string, options?: ConfirmOptions): Promise {
+ if (!_showConfirmFn) {
+ return Promise.resolve(window.confirm(message));
+ }
+ return _showConfirmFn(message, options);
+}
+
+export function showPrompt(message: string, defaultValue?: string, options?: PromptOptions): Promise {
+ if (!_showPromptFn) {
+ return Promise.resolve(window.prompt(message, defaultValue));
+ }
+ return _showPromptFn(message, defaultValue, options);
+}
+
+export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise {
+ if (!_showTimeSigFn) {
+ const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
+ if (!raw) return Promise.resolve(null);
+ const [n, d] = raw.split('/').map(Number);
+ if (!n || !d) return Promise.resolve(null);
+ return Promise.resolve({ numerator: n, denominator: d });
+ }
+ return _showTimeSigFn(message, defaultValue);
+}
diff --git a/src/util/saveUtil.ts b/src/util/saveUtil.ts
index 690db4c..9114669 100644
--- a/src/util/saveUtil.ts
+++ b/src/util/saveUtil.ts
@@ -1,7 +1,7 @@
import { KGProjectStorage } from '../core/io/KGProjectStorage';
import { KGCore } from '../core/KGCore';
import { RESERVED_PROJECT_NAME } from './projectNameUtil';
-import { showAlert } from '../components/common/DialogProvider';
+import { showAlert } from './dialogUtil';
/**
* Save project utility function.
diff --git a/src/util/xmlUtil.test.ts b/src/util/xmlUtil.test.ts
index 03f5d31..1b909a4 100644
--- a/src/util/xmlUtil.test.ts
+++ b/src/util/xmlUtil.test.ts
@@ -214,8 +214,8 @@ describe('xmlUtil', () => {
it('should handle empty input', () => {
expect(wrapXmlBlocksInContent('')).toBe('');
- expect(wrapXmlBlocksInContent(null as any)).toBeNull();
- expect(wrapXmlBlocksInContent(undefined as any)).toBeUndefined();
+ expect(wrapXmlBlocksInContent(null as unknown as string)).toBeNull();
+ expect(wrapXmlBlocksInContent(undefined as unknown as string)).toBeUndefined();
});
it('should wrap XML blocks from multipleXmlBlocks fixture', () => {