feat: add R as shortcut of recording button; fixed linter errors.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -300,7 +300,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ 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;
|
||||
|
||||
|
||||
+87
-87
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -1197,7 +1197,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
set({ showChatBox: defaultChatBoxOpen });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
storeState = useProjectStore.getState();
|
||||
expect(storeState.canUndo).toBe(false);
|
||||
expect(storeState.canRedo).toBe(true);
|
||||
expect(storeState.undoDescription).toBeNull();
|
||||
expect(storeState.redoDescription).toBe('Create note C4');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>): 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
const lines = result.split('\n');
|
||||
const musicLine = lines[lines.length - 1]; // Last line contains the music
|
||||
expect(musicLine).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface);
|
||||
@@ -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<string, any>()
|
||||
const mockStorage = new Map<string, any>();
|
||||
|
||||
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()
|
||||
}
|
||||
mockStorage.clear();
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
};
|
||||
+13
-13
@@ -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);
|
||||
};
|
||||
|
||||
+18
-18
@@ -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()
|
||||
})
|
||||
global.URL.createObjectURL = vi.fn(() => 'mocked-url');
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
+27
-27
@@ -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 }
|
||||
}
|
||||
return { project, track, region };
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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 }
|
||||
export * from '@testing-library/react';
|
||||
export { customRender as render };
|
||||
+113
-113
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(convertedPitch).toBe(originalPitch);
|
||||
});
|
||||
});
|
||||
});
|
||||
+292
-292
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+162
-162
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
const fallbackTimeSignature = { numerator: 4, denominator: 4 };
|
||||
const timeString = beatsToTimeString(8, 120, fallbackTimeSignature);
|
||||
expect(timeString).toBe('003:1 | 00:04:000');
|
||||
});
|
||||
});
|
||||
});
|
||||
+204
-204
@@ -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 <test>content</test> and more text.`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `Here is some text with <test>content</test> and more text.`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<test>content</test>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<test>content</test>');
|
||||
});
|
||||
|
||||
it('should extract multiple XML blocks', () => {
|
||||
const input = `<first>content1</first> some text <second>content2</second>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<first>content1</first> some text <second>content2</second>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toBe('<first>content1</first>')
|
||||
expect(result[1]).toBe('<second>content2</second>')
|
||||
})
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('<first>content1</first>');
|
||||
expect(result[1]).toBe('<second>content2</second>');
|
||||
});
|
||||
|
||||
it('should extract XML blocks with nested elements', () => {
|
||||
const input = `<outer><inner>nested content</inner></outer>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<outer><inner>nested content</inner></outer>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<outer><inner>nested content</inner></outer>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<outer><inner>nested content</inner></outer>');
|
||||
});
|
||||
|
||||
it('should extract XML blocks with attributes', () => {
|
||||
const input = `<tag attr="value" id="123">content</tag>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<tag attr="value" id="123">content</tag>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<tag attr="value" id="123">content</tag>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<tag attr="value" id="123">content</tag>');
|
||||
});
|
||||
|
||||
it('should handle multiline XML blocks', () => {
|
||||
const input = `<multiline>
|
||||
<line1>content1</line1>
|
||||
<line2>content2</line2>
|
||||
</multiline>`
|
||||
const result = extractXMLFromString(input)
|
||||
</multiline>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toContain('<multiline>')
|
||||
expect(result[0]).toContain('<line1>content1</line1>')
|
||||
expect(result[0]).toContain('<line2>content2</line2>')
|
||||
expect(result[0]).toContain('</multiline>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('<multiline>');
|
||||
expect(result[0]).toContain('<line1>content1</line1>');
|
||||
expect(result[0]).toContain('<line2>content2</line2>');
|
||||
expect(result[0]).toContain('</multiline>');
|
||||
});
|
||||
|
||||
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('<container>')
|
||||
expect(result[0]).toContain('<inner_element>')
|
||||
expect(result[0]).toContain('<deep_nested>')
|
||||
expect(result[0]).toContain('<value>Test Content</value>')
|
||||
expect(result[0]).toContain('</container>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('<container>');
|
||||
expect(result[0]).toContain('<inner_element>');
|
||||
expect(result[0]).toContain('<deep_nested>');
|
||||
expect(result[0]).toContain('<value>Test Content</value>');
|
||||
expect(result[0]).toContain('</container>');
|
||||
});
|
||||
|
||||
it('should extract multiple XML blocks from fixture', () => {
|
||||
const result = extractXMLFromString(multipleXmlBlocks)
|
||||
const result = extractXMLFromString(multipleXmlBlocks);
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toContain('<add_notes>')
|
||||
expect(result[0]).toContain('</add_notes>')
|
||||
expect(result[1]).toContain('<read_music>')
|
||||
expect(result[1]).toContain('</read_music>')
|
||||
expect(result[2]).toContain('<modify_tempo>')
|
||||
expect(result[2]).toContain('</modify_tempo>')
|
||||
})
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toContain('<add_notes>');
|
||||
expect(result[0]).toContain('</add_notes>');
|
||||
expect(result[1]).toContain('<read_music>');
|
||||
expect(result[1]).toContain('</read_music>');
|
||||
expect(result[2]).toContain('<modify_tempo>');
|
||||
expect(result[2]).toContain('</modify_tempo>');
|
||||
});
|
||||
|
||||
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('<add_notes>'))
|
||||
expect(addNotesBlock).toBeDefined()
|
||||
expect(addNotesBlock).toContain('<notes>')
|
||||
expect(addNotesBlock).toContain('<note>')
|
||||
expect(addNotesBlock).toContain('<pitch>C4</pitch>')
|
||||
expect(addNotesBlock).toContain('<start_beat>0</start_beat>')
|
||||
expect(addNotesBlock).toContain('<length>4</length>')
|
||||
expect(addNotesBlock).toContain('</add_notes>')
|
||||
const addNotesBlock = result.find(block => block.includes('<add_notes>'));
|
||||
expect(addNotesBlock).toBeDefined();
|
||||
expect(addNotesBlock).toContain('<notes>');
|
||||
expect(addNotesBlock).toContain('<note>');
|
||||
expect(addNotesBlock).toContain('<pitch>C4</pitch>');
|
||||
expect(addNotesBlock).toContain('<start_beat>0</start_beat>');
|
||||
expect(addNotesBlock).toContain('<length>4</length>');
|
||||
expect(addNotesBlock).toContain('</add_notes>');
|
||||
|
||||
// Should contain all 12 notes
|
||||
const noteMatches = addNotesBlock!.match(/<note>/g)
|
||||
expect(noteMatches).toHaveLength(12)
|
||||
})
|
||||
const noteMatches = addNotesBlock!.match(/<note>/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('<read_music>')
|
||||
expect(result[0]).toContain('<start_beat>0</start_beat>')
|
||||
expect(result[0]).toContain('<length>32</length>')
|
||||
expect(result[0]).toContain('</read_music>')
|
||||
expect(result[0]).toContain('<read_music>');
|
||||
expect(result[0]).toContain('<start_beat>0</start_beat>');
|
||||
expect(result[0]).toContain('<length>32</length>');
|
||||
expect(result[0]).toContain('</read_music>');
|
||||
|
||||
// Second XML block (at the end)
|
||||
expect(result[1]).toContain('<read_music>')
|
||||
expect(result[1]).toContain('<start_beat>0</start_beat>')
|
||||
expect(result[1]).toContain('<length>32</length>')
|
||||
expect(result[1]).toContain('</read_music>')
|
||||
})
|
||||
expect(result[1]).toContain('<read_music>');
|
||||
expect(result[1]).toContain('<start_beat>0</start_beat>');
|
||||
expect(result[1]).toContain('<length>32</length>');
|
||||
expect(result[1]).toContain('</read_music>');
|
||||
});
|
||||
|
||||
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('<valid_tag>'))
|
||||
expect(validBlock).toBeDefined()
|
||||
expect(validBlock).toContain('<content>This is valid</content>')
|
||||
})
|
||||
const validBlock = result.find(block => block.includes('<valid_tag>'));
|
||||
expect(validBlock).toBeDefined();
|
||||
expect(validBlock).toContain('<content>This is valid</content>');
|
||||
});
|
||||
|
||||
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('<empty_tag></empty_tag>')
|
||||
expect(result[1]).toContain('<whitespace_tag>')
|
||||
expect(result[1]).toContain('</whitespace_tag>')
|
||||
expect(result[2]).toContain('<mixed_content>')
|
||||
expect(result[2]).toContain('Some text with spaces')
|
||||
expect(result[2]).toContain('</mixed_content>')
|
||||
})
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toBe('<empty_tag></empty_tag>');
|
||||
expect(result[1]).toContain('<whitespace_tag>');
|
||||
expect(result[1]).toContain('</whitespace_tag>');
|
||||
expect(result[2]).toContain('<mixed_content>');
|
||||
expect(result[2]).toContain('Some text with spaces');
|
||||
expect(result[2]).toContain('</mixed_content>');
|
||||
});
|
||||
|
||||
it('should handle XML with underscores and hyphens in tag names', () => {
|
||||
const input = `<tag_name>content</tag_name> and <tag-name>content</tag-name>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<tag_name>content</tag_name> and <tag-name>content</tag-name>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toBe('<tag_name>content</tag_name>')
|
||||
expect(result[1]).toBe('<tag-name>content</tag-name>')
|
||||
})
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('<tag_name>content</tag_name>');
|
||||
expect(result[1]).toBe('<tag-name>content</tag-name>');
|
||||
});
|
||||
|
||||
it('should handle self-closing tags (not currently supported)', () => {
|
||||
const input = `<self-closing /> and <normal>content</normal>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<self-closing /> and <normal>content</normal>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
// Current implementation doesn't support self-closing tags
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<normal>content</normal>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<normal>content</normal>');
|
||||
});
|
||||
|
||||
it('should trim whitespace around extracted XML', () => {
|
||||
const input = ` <tag>content</tag> `
|
||||
const result = extractXMLFromString(input)
|
||||
const input = ` <tag>content</tag> `;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<tag>content</tag>')
|
||||
})
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<tag>content</tag>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapXmlBlocksInContent', () => {
|
||||
it('should wrap single XML block in fenced code block', () => {
|
||||
const input = `Here is <test>content</test> in text.`
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = `Here is <test>content</test> in text.`;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
expect(result).toBe('Here is ```xml\n<test>content</test>\n``` in text.')
|
||||
})
|
||||
expect(result).toBe('Here is ```xml\n<test>content</test>\n``` in text.');
|
||||
});
|
||||
|
||||
it('should wrap multiple XML blocks', () => {
|
||||
const input = `<first>content1</first> text <second>content2</second>`
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = `<first>content1</first> text <second>content2</second>`;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
expect(result).toContain('```xml\n<first>content1</first>\n```')
|
||||
expect(result).toContain('```xml\n<second>content2</second>\n```')
|
||||
})
|
||||
expect(result).toContain('```xml\n<first>content1</first>\n```');
|
||||
expect(result).toContain('```xml\n<second>content2</second>\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<add_notes>')
|
||||
expect(result).toContain('</add_notes>\n```')
|
||||
expect(result).toContain('```xml\n<read_music>')
|
||||
expect(result).toContain('</read_music>\n```')
|
||||
expect(result).toContain('```xml\n<modify_tempo>')
|
||||
expect(result).toContain('</modify_tempo>\n```')
|
||||
expect(result).toContain('```xml\n<add_notes>');
|
||||
expect(result).toContain('</add_notes>\n```');
|
||||
expect(result).toContain('```xml\n<read_music>');
|
||||
expect(result).toContain('</read_music>\n```');
|
||||
expect(result).toContain('```xml\n<modify_tempo>');
|
||||
expect(result).toContain('</modify_tempo>\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<add_notes>')
|
||||
expect(result).toContain('</add_notes>\n```')
|
||||
expect(result).toContain('```xml\n<add_notes>');
|
||||
expect(result).toContain('</add_notes>\n```');
|
||||
|
||||
// Should preserve the thinking content and other text
|
||||
expect(result).toContain('<thinking>')
|
||||
expect(result).toContain('Perfect! I can see this is a beautiful')
|
||||
expect(result).toContain('Let me start by adding the harmony')
|
||||
})
|
||||
expect(result).toContain('<thinking>');
|
||||
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<read_music>[\s\S]*?<\/read_music>\n```/g)
|
||||
expect(fencedBlocks).toHaveLength(2)
|
||||
const fencedBlocks = result.match(/```xml\n<read_music>[\s\S]*?<\/read_music>\n```/g);
|
||||
expect(fencedBlocks).toHaveLength(2);
|
||||
|
||||
// Should preserve the thinking content and other text
|
||||
expect(result).toContain('<thinking>')
|
||||
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('<thinking>');
|
||||
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<container>')
|
||||
expect(result).toContain('<inner_element>')
|
||||
expect(result).toContain('<deep_nested>')
|
||||
expect(result).toContain('<value>Test Content</value>')
|
||||
expect(result).toContain('</container>\n```')
|
||||
})
|
||||
expect(result).toContain('```xml\n<container>');
|
||||
expect(result).toContain('<inner_element>');
|
||||
expect(result).toContain('<deep_nested>');
|
||||
expect(result).toContain('<value>Test Content</value>');
|
||||
expect(result).toContain('</container>\n```');
|
||||
});
|
||||
|
||||
it('should preserve XML block integrity when wrapping', () => {
|
||||
const input = `Text before\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\nText after`
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = `Text before\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\nText after`;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
expect(result).toBe(`Text before\n\`\`\`xml\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\n\`\`\`\nText after`)
|
||||
})
|
||||
expect(result).toBe(`Text before\n\`\`\`xml\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\n\`\`\`\nText after`);
|
||||
});
|
||||
|
||||
it('should handle duplicate XML blocks correctly', () => {
|
||||
const input = `<same>content</same> and then <same>content</same> again`
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = `<same>content</same> and then <same>content</same> again`;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
// Both instances should be wrapped
|
||||
const wrappedBlocks = result.match(/```xml\n<same>content<\/same>\n```/g)
|
||||
expect(wrappedBlocks).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
const wrappedBlocks = result.match(/```xml\n<same>content<\/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 = `<large>${largeContent}</large>`
|
||||
const largeContent = 'x'.repeat(10000);
|
||||
const input = `<large>${largeContent}</large>`;
|
||||
|
||||
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<large>')
|
||||
expect(wrapped).toContain('</large>\n```')
|
||||
})
|
||||
const wrapped = wrapXmlBlocksInContent(input);
|
||||
expect(wrapped).toContain('```xml\n<large>');
|
||||
expect(wrapped).toContain('</large>\n```');
|
||||
});
|
||||
|
||||
it('should handle XML with special characters', () => {
|
||||
const input = `<tag>Content with < > & " '</tag>`
|
||||
const input = `<tag>Content with < > & " '</tag>`;
|
||||
|
||||
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\n<tag>Content with < > & " '</tag>\n```')
|
||||
})
|
||||
const wrapped = wrapXmlBlocksInContent(input);
|
||||
expect(wrapped).toContain('```xml\n<tag>Content with < > & " '</tag>\n```');
|
||||
});
|
||||
|
||||
it('should handle XML with CDATA sections', () => {
|
||||
const input = `<tag><![CDATA[Some content with <special> chars]]></tag>`
|
||||
const input = `<tag><![CDATA[Some content with <special> chars]]></tag>`;
|
||||
|
||||
const extracted = extractXMLFromString(input)
|
||||
expect(extracted).toHaveLength(1)
|
||||
expect(extracted[0]).toContain('<![CDATA[')
|
||||
expect(extracted[0]).toContain(']]>')
|
||||
})
|
||||
const extracted = extractXMLFromString(input);
|
||||
expect(extracted).toHaveLength(1);
|
||||
expect(extracted[0]).toContain('<![CDATA[');
|
||||
expect(extracted[0]).toContain(']]>');
|
||||
});
|
||||
|
||||
it('should handle mixed content with partial XML-like text', () => {
|
||||
const input = `This < is not XML and neither > is this <incomplete and <valid>content</valid> is valid`
|
||||
const input = `This < is not XML and neither > is this <incomplete and <valid>content</valid> is valid`;
|
||||
|
||||
const extracted = extractXMLFromString(input)
|
||||
expect(extracted).toHaveLength(1)
|
||||
expect(extracted[0]).toBe('<valid>content</valid>')
|
||||
})
|
||||
})
|
||||
})
|
||||
const extracted = extractXMLFromString(input);
|
||||
expect(extracted).toHaveLength(1);
|
||||
expect(extracted[0]).toBe('<valid>content</valid>');
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
Reference in New Issue
Block a user