feat: add R as shortcut of recording button; fixed linter errors.

This commit is contained in:
Xiaohan-Tian
2026-05-01 14:29:27 -07:00
parent 043e90b1d7
commit 9be96cd957
27 changed files with 2266 additions and 2225 deletions
+87 -87
View File
@@ -673,212 +673,212 @@ export class KGDebugger {
* await KGDebugger.opfs('cat project.json')
*/
public async opfs(command: string): Promise<void> {
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 <path>, cat <file>, dl <file>, rm <name>')
console.log(`opfs: command not found: ${cmd}`);
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>');
}
} catch (error) {
console.error(`opfs: ${error}`)
console.error(`opfs: ${error}`);
}
}
private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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`);
}
}
}
@@ -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);
});
});
+36 -36
View File
@@ -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();
});
});
@@ -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<typeof vi.fn>
}
describe('CreateNoteCommand', () => {
let mockCore: MockCore
let mockProject: ReturnType<typeof createMockProject>
let mockTrack: ReturnType<typeof createMockMidiTrack>
let mockRegion: ReturnType<typeof createMockMidiRegion>
let command: CreateNoteCommand
let mockCore: MockCore;
let mockProject: ReturnType<typeof createMockProject>;
let mockTrack: ReturnType<typeof createMockMidiTrack>;
let mockRegion: ReturnType<typeof createMockMidiRegion>;
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)
})
})
})
command.undo();
expect(removeNoteSpy).toHaveBeenCalledTimes(2);
});
});
});
+89 -89
View File
@@ -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
})
})
})
});
});
});
+210 -210
View File
@@ -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
})
})
})
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
});
});
});
+200 -200
View File
@@ -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)
})
})
})
})
expect(region).toBeInstanceOf(KGMidiRegion);
});
});
});
});